feat: Convert Figma React UI to Svelte 5
- Add lucide-svelte for icons - Create app.css with theme CSS variables (Dark, Light, OLED, Warm) - Convert all React components to Svelte 5 using runes ($state, $derived, $effect, $props) - Implement core components: Sidebar, NowPlayingBar, NavigationItem, UserCard, AlbumCard, HorizontalScrollRow, HeroSection, TrackRow, Toggle, Dropdown, Tooltip, VolumeSlider - Implement view components: HomeView, SettingsView, AlbumDetailView - Implement overlay components: QueuePanel, FullScreenNowPlaying, FocusMode, Toast - Create main +page.svelte with routing, state management and keyboard shortcuts - All components use Svelte 5 snippets for component children patterns
This commit is contained in:
1 parent
ef92897d43
commit
cdfd3263de
23 files changed
+4505
-134
No files matched your search
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"svelte-i18n": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1629,6 +1630,15 @@
|
||||
"es5-ext": "~0.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-svelte": {
|
||||
"version": "0.562.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.562.0.tgz",
|
||||
"integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
+3
-2
@@ -15,16 +15,17 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"svelte-i18n": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2"
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400&display=swap');
|
||||
|
||||
:root {
|
||||
/* QBZ-NIX Dark Theme Colors */
|
||||
--bg-primary: #0f0f0f;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #2a2a2a;
|
||||
--bg-hover: #1f1f1f;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #cccccc;
|
||||
--text-muted: #888888;
|
||||
--text-disabled: #555555;
|
||||
|
||||
--accent-primary: #4285F4;
|
||||
--accent-hover: #5a9bf4;
|
||||
--accent-active: #3275e4;
|
||||
|
||||
--border-subtle: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
|
||||
/* Spacing System */
|
||||
--spacing-micro: 4px;
|
||||
--spacing-tight: 8px;
|
||||
--spacing-default: 12px;
|
||||
--spacing-component: 16px;
|
||||
--spacing-section: 24px;
|
||||
--spacing-large: 32px;
|
||||
--spacing-page: 48px;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f5f5;
|
||||
--bg-tertiary: #e8e8e8;
|
||||
--bg-hover: #ebebeb;
|
||||
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #444444;
|
||||
--text-muted: #666666;
|
||||
--text-disabled: #999999;
|
||||
|
||||
--border-subtle: #e0e0e0;
|
||||
--border-strong: #cccccc;
|
||||
}
|
||||
|
||||
/* OLED Theme */
|
||||
[data-theme="oled"] {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #0a0a0a;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--bg-hover: #111111;
|
||||
}
|
||||
|
||||
/* Warm Theme */
|
||||
[data-theme="warm"] {
|
||||
--bg-primary: #1a1512;
|
||||
--bg-secondary: #231d18;
|
||||
--bg-tertiary: #2d2520;
|
||||
--bg-hover: #28211c;
|
||||
|
||||
--accent-primary: #e89b5a;
|
||||
--accent-hover: #f0ab6e;
|
||||
--accent-active: #d88b4a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for horizontal scroll containers */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { Play } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
quality?: string;
|
||||
size?: 'standard' | 'large';
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { artwork, title, artist, quality, size = 'standard', onclick }: Props = $props();
|
||||
|
||||
let isHovered = $state(false);
|
||||
const cardSize = $derived(size === 'large' ? 200 : 180);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="album-card"
|
||||
style="width: {cardSize}px"
|
||||
onmouseenter={() => (isHovered = true)}
|
||||
onmouseleave={() => (isHovered = false)}
|
||||
onclick={onclick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
|
||||
>
|
||||
<!-- Artwork Container -->
|
||||
<div
|
||||
class="artwork-container"
|
||||
style="width: {cardSize}px; height: {cardSize}px; transform: scale({isHovered ? 1.02 : 1})"
|
||||
>
|
||||
<img src={artwork} alt={title} />
|
||||
|
||||
<!-- Quality Indicator -->
|
||||
{#if quality}
|
||||
<div class="quality-badge">{quality}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Play Button Overlay -->
|
||||
{#if isHovered}
|
||||
<div class="play-overlay">
|
||||
<div class="play-button">
|
||||
<Play size={24} fill="white" color="white" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Text Info -->
|
||||
<div class="info">
|
||||
<div class="title">{title}</div>
|
||||
<div class="artist">{artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.album-card {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
|
||||
.artwork-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.quality-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(66, 133, 244, 0.9);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: string[];
|
||||
onchange: (value: string) => void;
|
||||
}
|
||||
|
||||
let { value, options, onchange }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let dropdownRef: HTMLDivElement;
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown" bind:this={dropdownRef}>
|
||||
<button class="trigger" onclick={() => (isOpen = !isOpen)}>
|
||||
<span>{value}</span>
|
||||
<ChevronDown size={16} class="chevron" />
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="menu">
|
||||
{#each options as option}
|
||||
<button
|
||||
class="option"
|
||||
class:selected={option === value}
|
||||
onclick={() => {
|
||||
onchange(option);
|
||||
isOpen = false;
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
height: 40px;
|
||||
min-width: 160px;
|
||||
padding: 0 16px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
.trigger :global(.chevron) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.option {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.option.selected {
|
||||
background-color: #333333;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { X, SkipBack, Play, Pause, SkipForward } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
artwork: string;
|
||||
trackTitle: string;
|
||||
artist: string;
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen,
|
||||
onClose,
|
||||
artwork,
|
||||
trackTitle,
|
||||
artist,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek
|
||||
}: Props = $props();
|
||||
|
||||
let showControls = $state(false);
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let progressRef: HTMLDivElement;
|
||||
|
||||
const progress = $derived((currentTime / duration) * 100);
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleProgressClick(e: MouseEvent) {
|
||||
if (progressRef) {
|
||||
const rect = progressRef.getBoundingClientRect();
|
||||
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
onSeek(Math.round((percentage / 100) * duration));
|
||||
}
|
||||
}
|
||||
|
||||
function showControlsTemporarily() {
|
||||
showControls = true;
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => (showControls = false), 3000);
|
||||
}
|
||||
|
||||
function handleKeyPress(e: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTogglePlay();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove() {
|
||||
showControlsTemporarily();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="focus-mode" onclick={showControlsTemporarily} role="presentation">
|
||||
<!-- Close Button -->
|
||||
<button class="close-btn" class:visible={showControls} onclick={onClose}>
|
||||
<X size={32} />
|
||||
</button>
|
||||
|
||||
<!-- Album Artwork -->
|
||||
<div class="artwork-container">
|
||||
<img src={artwork} alt={trackTitle} />
|
||||
|
||||
<!-- Overlay Controls -->
|
||||
<div class="overlay" class:visible={showControls}>
|
||||
<div class="playback-controls">
|
||||
<button class="nav-btn">
|
||||
<SkipBack size={40} />
|
||||
</button>
|
||||
<button
|
||||
class="play-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePlay();
|
||||
}}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<Pause size={36} fill="white" color="white" />
|
||||
{:else}
|
||||
<Play size={36} fill="white" color="white" class="play-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
<button class="nav-btn">
|
||||
<SkipForward size={40} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="track-info">
|
||||
<h1 class="title">{trackTitle}</h1>
|
||||
<h2 class="artist">{artist}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container" class:visible={showControls}>
|
||||
<div
|
||||
class="progress-bar"
|
||||
bind:this={progressRef}
|
||||
onclick={handleProgressClick}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
>
|
||||
<div class="progress-fill" style="width: {progress}%"></div>
|
||||
<div class="progress-thumb" style="left: {progress}%"></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.focus-mode {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #0a0a0a;
|
||||
cursor: default;
|
||||
animation: fadeIn 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
|
||||
.close-btn.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
margin-bottom: 32px;
|
||||
animation: scaleIn 400ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.artwork-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playback-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.play-btn :global(.play-icon) {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.progress-container.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background-color: #333333;
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,501 @@
|
||||
<script lang="ts">
|
||||
import { X, Shuffle, SkipBack, Play, Pause, SkipForward, Repeat, Heart, List, Mic2, Maximize2, MoreHorizontal } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
artwork: string;
|
||||
trackTitle: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
quality: string;
|
||||
qualityLevel: number;
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
volume: number;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
isShuffle: boolean;
|
||||
onToggleShuffle: () => void;
|
||||
repeatMode: 'off' | 'all' | 'one';
|
||||
onToggleRepeat: () => void;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
onOpenQueue?: () => void;
|
||||
onOpenFocusMode?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen,
|
||||
onClose,
|
||||
artwork,
|
||||
trackTitle,
|
||||
artist,
|
||||
album,
|
||||
quality,
|
||||
qualityLevel,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
isShuffle,
|
||||
onToggleShuffle,
|
||||
repeatMode,
|
||||
onToggleRepeat,
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
onOpenQueue,
|
||||
onOpenFocusMode
|
||||
}: Props = $props();
|
||||
|
||||
let progressRef: HTMLDivElement;
|
||||
let volumeRef: HTMLDivElement;
|
||||
let isDraggingProgress = $state(false);
|
||||
let isDraggingVolume = $state(false);
|
||||
let playBtnHovered = $state(false);
|
||||
|
||||
const progress = $derived((currentTime / duration) * 100);
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleProgressMouseDown(e: MouseEvent) {
|
||||
isDraggingProgress = true;
|
||||
updateProgress(e);
|
||||
}
|
||||
|
||||
function updateProgress(e: MouseEvent) {
|
||||
if (progressRef) {
|
||||
const rect = progressRef.getBoundingClientRect();
|
||||
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
onSeek(Math.round((percentage / 100) * duration));
|
||||
}
|
||||
}
|
||||
|
||||
function handleVolumeMouseDown(e: MouseEvent) {
|
||||
isDraggingVolume = true;
|
||||
updateVolume(e);
|
||||
}
|
||||
|
||||
function updateVolume(e: MouseEvent) {
|
||||
if (volumeRef) {
|
||||
const rect = volumeRef.getBoundingClientRect();
|
||||
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
onVolumeChange(Math.round(percentage));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isDraggingProgress) updateProgress(e);
|
||||
if (isDraggingVolume) updateVolume(e);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDraggingProgress = false;
|
||||
isDraggingVolume = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDraggingProgress || isDraggingVolume) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fullscreen-player">
|
||||
<!-- Close Button -->
|
||||
<button class="close-btn" onclick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
<!-- Album Artwork -->
|
||||
<div class="artwork">
|
||||
<img src={artwork} alt={trackTitle} />
|
||||
</div>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="track-info">
|
||||
<h1 class="title">{trackTitle}</h1>
|
||||
<h2 class="artist">{artist}</h2>
|
||||
<h3 class="album">{album}</h3>
|
||||
</div>
|
||||
|
||||
<!-- Quality Info -->
|
||||
<div class="quality-info">
|
||||
<div class="quality-text">{quality}</div>
|
||||
<div class="quality-dots">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="dot" class:active={i < qualityLevel}></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container">
|
||||
<div
|
||||
class="progress-bar"
|
||||
bind:this={progressRef}
|
||||
onmousedown={handleProgressMouseDown}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
>
|
||||
<div class="progress-fill" style="width: {progress}%"></div>
|
||||
<div class="progress-thumb" style="left: {progress}%"></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playback Controls -->
|
||||
<div class="controls">
|
||||
<button class="control-btn" class:active={isShuffle} onclick={onToggleShuffle}>
|
||||
<Shuffle size={24} />
|
||||
</button>
|
||||
<button class="control-btn primary">
|
||||
<SkipBack size={32} />
|
||||
</button>
|
||||
<button
|
||||
class="play-btn"
|
||||
style="background-color: {playBtnHovered ? 'var(--accent-hover)' : 'var(--accent-primary)'}"
|
||||
onmouseenter={() => (playBtnHovered = true)}
|
||||
onmouseleave={() => (playBtnHovered = false)}
|
||||
onclick={onTogglePlay}
|
||||
>
|
||||
{#if isPlaying}
|
||||
<Pause size={28} fill="white" color="white" />
|
||||
{:else}
|
||||
<Play size={28} fill="white" color="white" class="play-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
<button class="control-btn primary">
|
||||
<SkipForward size={32} />
|
||||
</button>
|
||||
<button class="control-btn" class:active={repeatMode !== 'off'} onclick={onToggleRepeat}>
|
||||
<Repeat size={24} />
|
||||
{#if repeatMode === 'one'}
|
||||
<span class="repeat-one">1</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="volume-container">
|
||||
<div
|
||||
class="volume-bar"
|
||||
bind:this={volumeRef}
|
||||
onmousedown={handleVolumeMouseDown}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={volume}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div class="volume-fill" style="width: {volume}%"></div>
|
||||
<div class="volume-thumb" style="left: {volume}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Actions -->
|
||||
<div class="bottom-actions">
|
||||
<button class="action-btn" onclick={onOpenQueue}>
|
||||
<List size={24} />
|
||||
</button>
|
||||
<button class="action-btn" onclick={onOpenFocusMode}>
|
||||
<Maximize2 size={24} />
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Mic2 size={24} />
|
||||
</button>
|
||||
<button class="action-btn" onclick={onToggleFavorite}>
|
||||
<Heart size={24} fill={isFavorite ? 'var(--accent-primary)' : 'none'} color={isFavorite ? 'var(--accent-primary)' : '#888888'} />
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<MoreHorizontal size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fullscreen-player {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #0a0a0a;
|
||||
animation: fadeIn 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.artwork {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.artwork img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.album {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.quality-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.quality-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.quality-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #444444;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background-color: #888888;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background-color: #333333;
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.control-btn.primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.control-btn.primary:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.repeat-one {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-btn :global(.play-icon) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
height: 4px;
|
||||
background-color: #333333;
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.volume-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.volume-bar:hover .volume-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { Play, MoreHorizontal } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
year?: string;
|
||||
}
|
||||
|
||||
let { artwork, title, artist, year }: Props = $props();
|
||||
|
||||
let playBtnHovered = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="hero" style="background-image: url({artwork})">
|
||||
<!-- Gradient Overlay -->
|
||||
<div class="gradient"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<h1 class="title">{title}</h1>
|
||||
<p class="artist">{artist}{year ? ` • ${year}` : ''}</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="play-btn"
|
||||
style="background-color: {playBtnHovered ? 'var(--accent-hover)' : 'var(--accent-primary)'}"
|
||||
onmouseenter={() => (playBtnHovered = true)}
|
||||
onmouseleave={() => (playBtnHovered = false)}
|
||||
>
|
||||
<Play size={16} fill="white" color="white" />
|
||||
<span>Play</span>
|
||||
</button>
|
||||
<button class="more-btn">
|
||||
<MoreHorizontal size={16} color="white" />
|
||||
<span>More</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 32px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 40%, rgba(0, 0, 0, 0.8) 100%);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: 16px;
|
||||
color: #aaaaaa;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.more-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.more-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
|
||||
let scrollContainer: HTMLDivElement;
|
||||
|
||||
function scroll(direction: 'left' | 'right') {
|
||||
if (scrollContainer) {
|
||||
const scrollAmount = 600;
|
||||
const currentScroll = scrollContainer.scrollLeft;
|
||||
const targetScroll = direction === 'left'
|
||||
? currentScroll - scrollAmount
|
||||
: currentScroll + scrollAmount;
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="scroll-row">
|
||||
<!-- Section Header -->
|
||||
<div class="header">
|
||||
<h2 class="title">{title}</h2>
|
||||
<div class="nav-buttons">
|
||||
<button onclick={() => scroll('left')} class="nav-btn">
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<button onclick={() => scroll('right')} class="nav-btn">
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal Scroll Container -->
|
||||
<div class="scroll-container hide-scrollbar" bind:this={scrollContainer}>
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll-row {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: max-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: Snippet;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { icon, label, active = false, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{onclick}
|
||||
class="nav-item"
|
||||
class:active
|
||||
>
|
||||
<div class="icon-container">
|
||||
{@render icon()}
|
||||
</div>
|
||||
<span class="label">{label}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,525 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
Play,
|
||||
Pause,
|
||||
SkipForward,
|
||||
Repeat,
|
||||
Heart,
|
||||
Plus,
|
||||
List,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Volume1
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
artwork?: string;
|
||||
trackTitle?: string;
|
||||
artist?: string;
|
||||
quality?: string;
|
||||
qualityLevel?: number;
|
||||
isPlaying?: boolean;
|
||||
onTogglePlay?: () => void;
|
||||
currentTime?: number;
|
||||
duration?: number;
|
||||
onSeek?: (time: number) => void;
|
||||
volume?: number;
|
||||
onVolumeChange?: (volume: number) => void;
|
||||
isShuffle?: boolean;
|
||||
onToggleShuffle?: () => void;
|
||||
repeatMode?: 'off' | 'all' | 'one';
|
||||
onToggleRepeat?: () => void;
|
||||
isFavorite?: boolean;
|
||||
onToggleFavorite?: () => void;
|
||||
onOpenQueue?: () => void;
|
||||
onOpenFullScreen?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
artwork = 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=200&h=200&fit=crop',
|
||||
trackTitle = 'Irreplaceable',
|
||||
artist = 'El Silencio de las Vega',
|
||||
quality = 'FLAC • 24/192',
|
||||
qualityLevel = 5,
|
||||
isPlaying = false,
|
||||
onTogglePlay,
|
||||
currentTime = 201,
|
||||
duration = 345,
|
||||
onSeek,
|
||||
volume = 70,
|
||||
onVolumeChange,
|
||||
isShuffle = false,
|
||||
onToggleShuffle,
|
||||
repeatMode = 'off',
|
||||
onToggleRepeat,
|
||||
isFavorite = false,
|
||||
onToggleFavorite,
|
||||
onOpenQueue,
|
||||
onOpenFullScreen
|
||||
}: Props = $props();
|
||||
|
||||
let progressRef: HTMLDivElement;
|
||||
let volumeRef: HTMLDivElement;
|
||||
let isDraggingProgress = $state(false);
|
||||
let isDraggingVolume = $state(false);
|
||||
|
||||
const progress = $derived((currentTime / duration) * 100);
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleProgressMouseDown(e: MouseEvent) {
|
||||
isDraggingProgress = true;
|
||||
updateProgress(e);
|
||||
}
|
||||
|
||||
function updateProgress(e: MouseEvent) {
|
||||
if (progressRef) {
|
||||
const rect = progressRef.getBoundingClientRect();
|
||||
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
const newTime = Math.round((percentage / 100) * duration);
|
||||
onSeek?.(newTime);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVolumeMouseDown(e: MouseEvent) {
|
||||
isDraggingVolume = true;
|
||||
updateVolume(e);
|
||||
}
|
||||
|
||||
function updateVolume(e: MouseEvent) {
|
||||
if (volumeRef) {
|
||||
const rect = volumeRef.getBoundingClientRect();
|
||||
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
onVolumeChange?.(Math.round(percentage));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isDraggingProgress) updateProgress(e);
|
||||
if (isDraggingVolume) updateVolume(e);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDraggingProgress = false;
|
||||
isDraggingVolume = false;
|
||||
}
|
||||
|
||||
function handleRepeatClick() {
|
||||
onToggleRepeat?.();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDraggingProgress || isDraggingVolume) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="now-playing-bar">
|
||||
<!-- Left Section - Track Info -->
|
||||
<div class="track-info">
|
||||
<button class="artwork-btn" onclick={onOpenFullScreen}>
|
||||
<img src={artwork} alt={trackTitle} />
|
||||
</button>
|
||||
<div class="track-details">
|
||||
<div class="track-title">{trackTitle}</div>
|
||||
<div class="track-artist">{artist}</div>
|
||||
<div class="track-quality">
|
||||
<span>{quality}</span>
|
||||
<span>•</span>
|
||||
<div class="quality-dots">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="dot" class:active={i < qualityLevel}></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Section - Controls & Progress -->
|
||||
<div class="controls-section">
|
||||
<div class="control-buttons">
|
||||
<button
|
||||
class="control-btn"
|
||||
class:active={isShuffle}
|
||||
onclick={onToggleShuffle}
|
||||
>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
<button class="control-btn primary">
|
||||
<SkipBack size={24} />
|
||||
</button>
|
||||
<button class="play-btn" onclick={onTogglePlay}>
|
||||
{#if isPlaying}
|
||||
<Pause size={24} fill="white" color="white" />
|
||||
{:else}
|
||||
<Play size={24} fill="white" color="white" class="play-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
<button class="control-btn primary">
|
||||
<SkipForward size={24} />
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
class:active={repeatMode !== 'off'}
|
||||
onclick={handleRepeatClick}
|
||||
>
|
||||
<Repeat size={20} />
|
||||
{#if repeatMode === 'one'}
|
||||
<span class="repeat-one">1</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container">
|
||||
<span class="time">{formatTime(currentTime)}</span>
|
||||
<div
|
||||
class="progress-bar"
|
||||
bind:this={progressRef}
|
||||
onmousedown={handleProgressMouseDown}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
>
|
||||
<div class="progress-fill" style="width: {progress}%"></div>
|
||||
<div class="progress-thumb" style="left: {progress}%"></div>
|
||||
</div>
|
||||
<span class="time">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Section - Actions & Volume -->
|
||||
<div class="actions-section">
|
||||
<button
|
||||
class="action-btn"
|
||||
class:active={isFavorite}
|
||||
onclick={onToggleFavorite}
|
||||
>
|
||||
<Heart size={20} fill={isFavorite ? 'var(--accent-primary)' : 'none'} color={isFavorite ? 'var(--accent-primary)' : '#888888'} />
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button class="action-btn" onclick={onOpenQueue}>
|
||||
<List size={20} />
|
||||
</button>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<div class="volume-control">
|
||||
<button class="action-btn" onclick={() => onVolumeChange?.(volume === 0 ? 70 : 0)}>
|
||||
{#if volume === 0}
|
||||
<VolumeX size={20} />
|
||||
{:else if volume < 50}
|
||||
<Volume1 size={20} />
|
||||
{:else}
|
||||
<Volume2 size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
<div
|
||||
class="volume-bar"
|
||||
bind:this={volumeRef}
|
||||
onmousedown={handleVolumeMouseDown}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={volume}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div class="volume-fill" style="width: {volume}%"></div>
|
||||
<div class="volume-thumb" style="left: {volume}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="remaining-time">2:34</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.now-playing-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 24px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Track Info */
|
||||
.track-info {
|
||||
width: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.artwork-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.artwork-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.artwork-btn img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-quality {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.quality-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #444444;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background-color: #888888;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.control-btn.primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.control-btn.primary:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.repeat-one {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
top: -8px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.play-btn :global(.play-icon) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: #333333;
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
transition: width 50ms linear;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions-section {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Volume */
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background-color: #333333;
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.volume-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.volume-bar:hover .volume-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remaining-time {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<script lang="ts">
|
||||
import { X, GripVertical, Play } from 'lucide-svelte';
|
||||
|
||||
interface QueueTrack {
|
||||
id: string;
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentTrack?: QueueTrack;
|
||||
upcomingTracks: QueueTrack[];
|
||||
onPlayTrack?: (trackId: string) => void;
|
||||
onClearQueue?: () => void;
|
||||
onSaveAsPlaylist?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen,
|
||||
onClose,
|
||||
currentTrack,
|
||||
upcomingTracks,
|
||||
onPlayTrack,
|
||||
onClearQueue,
|
||||
onSaveAsPlaylist
|
||||
}: Props = $props();
|
||||
|
||||
let hoveredTrack = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div class="backdrop" onclick={onClose} role="presentation"></div>
|
||||
|
||||
<!-- Queue Panel -->
|
||||
<div class="queue-panel">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h2>Queue</h2>
|
||||
<button class="close-btn" onclick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<!-- Now Playing -->
|
||||
{#if currentTrack}
|
||||
<div class="section">
|
||||
<div class="section-header">Now Playing</div>
|
||||
<div class="now-playing-card">
|
||||
<img src={currentTrack.artwork} alt={currentTrack.title} />
|
||||
<div class="track-info">
|
||||
<div class="track-title">{currentTrack.title}</div>
|
||||
<div class="track-artist">{currentTrack.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Next Up -->
|
||||
{#if upcomingTracks.length > 0}
|
||||
<div class="section">
|
||||
<div class="section-header">Next Up ({upcomingTracks.length})</div>
|
||||
<div class="tracks">
|
||||
{#each upcomingTracks as track, index}
|
||||
<div
|
||||
class="queue-track"
|
||||
class:hovered={hoveredTrack === track.id}
|
||||
onmouseenter={() => (hoveredTrack = track.id)}
|
||||
onmouseleave={() => (hoveredTrack = null)}
|
||||
onclick={() => onPlayTrack?.(track.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Enter' && onPlayTrack?.(track.id)}
|
||||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle" class:visible={hoveredTrack === track.id}>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
<!-- Track Number / Play Icon -->
|
||||
<div class="track-number">
|
||||
{#if hoveredTrack === track.id}
|
||||
<Play size={14} fill="white" color="white" />
|
||||
{:else}
|
||||
<span>{index + 1}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="track-info">
|
||||
<div class="track-title">{track.title}</div>
|
||||
<div class="track-artist">{track.artist}</div>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="track-duration">{track.duration}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if upcomingTracks.length === 0 && !currentTrack}
|
||||
<div class="empty-state">
|
||||
<div class="emoji">🎵</div>
|
||||
<div class="empty-title">Queue is empty</div>
|
||||
<div class="empty-text">Search for music or browse your library to add tracks.</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
{#if upcomingTracks.length > 0 || currentTrack}
|
||||
<div class="footer">
|
||||
<button class="clear-btn" onclick={onClearQueue}>Clear Queue</button>
|
||||
<button class="save-btn" onclick={onSaveAsPlaylist}>Save as Playlist</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.queue-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 360px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-left: 1px solid var(--bg-tertiary);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||
animation: slideInRight 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #666666;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.now-playing-card {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.now-playing-card img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.queue-track {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.queue-track.hovered {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #666666;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.drag-handle.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-number {
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.track-number span {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
font-size: 13px;
|
||||
color: #666666;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import { Search, Home, Radio, Clock, Music, Disc3, Mic2, Plus, MoreVertical, ChevronDown } from 'lucide-svelte';
|
||||
import NavigationItem from './NavigationItem.svelte';
|
||||
import UserCard from './UserCard.svelte';
|
||||
|
||||
interface Props {
|
||||
activeView: string;
|
||||
onNavigate: (view: string) => void;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let { activeView, onNavigate, onSettingsClick }: Props = $props();
|
||||
|
||||
function handleViewChange(view: string) {
|
||||
onNavigate(view);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="content">
|
||||
<!-- Search Bar -->
|
||||
<div class="search-container">
|
||||
<Search class="search-icon" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Section -->
|
||||
<nav class="nav-section">
|
||||
<NavigationItem
|
||||
label="Home"
|
||||
active={activeView === 'home'}
|
||||
onclick={() => handleViewChange('home')}
|
||||
>
|
||||
{#snippet icon()}<Home size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="New"
|
||||
active={activeView === 'new'}
|
||||
onclick={() => handleViewChange('new')}
|
||||
>
|
||||
{#snippet icon()}<Radio size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="Radio"
|
||||
active={activeView === 'radio'}
|
||||
onclick={() => handleViewChange('radio')}
|
||||
>
|
||||
{#snippet icon()}<Radio size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
</nav>
|
||||
|
||||
<!-- Library Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">Library</div>
|
||||
<NavigationItem
|
||||
label="Recently Added"
|
||||
active={activeView === 'recently-added'}
|
||||
onclick={() => handleViewChange('recently-added')}
|
||||
>
|
||||
{#snippet icon()}<Clock size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="Songs"
|
||||
active={activeView === 'songs'}
|
||||
onclick={() => handleViewChange('songs')}
|
||||
>
|
||||
{#snippet icon()}<Music size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="Albums"
|
||||
active={activeView === 'albums'}
|
||||
onclick={() => handleViewChange('albums')}
|
||||
>
|
||||
{#snippet icon()}<Disc3 size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="Artists"
|
||||
active={activeView === 'artists'}
|
||||
onclick={() => handleViewChange('artists')}
|
||||
>
|
||||
{#snippet icon()}<Mic2 size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
</div>
|
||||
|
||||
<!-- Playlists Section -->
|
||||
<div class="section playlists-section">
|
||||
<div class="playlists-header">
|
||||
<div class="section-header">Playlists</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn">
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
<button class="icon-btn">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="create-btn">
|
||||
<Plus size={18} />
|
||||
<span>Create New...</span>
|
||||
</button>
|
||||
|
||||
<NavigationItem
|
||||
label="All Playlists"
|
||||
active={activeView === 'all-playlists'}
|
||||
onclick={() => handleViewChange('all-playlists')}
|
||||
>
|
||||
{#snippet icon()}<Music size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="My Playlist 1"
|
||||
active={activeView === 'playlist-1'}
|
||||
onclick={() => handleViewChange('playlist-1')}
|
||||
>
|
||||
{#snippet icon()}<Music size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="My Playlist 2"
|
||||
active={activeView === 'playlist-2'}
|
||||
onclick={() => handleViewChange('playlist-2')}
|
||||
>
|
||||
{#snippet icon()}<Music size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
<NavigationItem
|
||||
label="My Playlist 3"
|
||||
active={activeView === 'playlist-3'}
|
||||
onclick={() => handleViewChange('playlist-3')}
|
||||
>
|
||||
{#snippet icon()}<Music size={18} />{/snippet}
|
||||
</NavigationItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed User Profile at Bottom -->
|
||||
<div class="user-section">
|
||||
<UserCard
|
||||
username="Username"
|
||||
subscription="Studio Premier"
|
||||
onSettingsClick={onSettingsClick ?? (() => handleViewChange('settings'))}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-container :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding-left: 40px;
|
||||
padding-right: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.playlists-section {
|
||||
flex: 1;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.playlists-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlists-header .section-header {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.create-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { CheckCircle, AlertCircle, Info, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { message, type = 'success', onClose }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const timer = setTimeout(onClose, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="toast" class:success={type === 'success'} class:error={type === 'error'} class:info={type === 'info'}>
|
||||
<div class="icon">
|
||||
{#if type === 'success'}
|
||||
<CheckCircle size={20} />
|
||||
{:else if type === 'error'}
|
||||
<AlertCircle size={20} />
|
||||
{:else}
|
||||
<Info size={20} />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="message">{message}</span>
|
||||
<button class="close-btn" onclick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast.success .icon {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.toast.error .icon {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.toast.info .icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
onchange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
let { enabled, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="toggle"
|
||||
class:enabled
|
||||
onclick={() => onchange(!enabled)}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
>
|
||||
<div class="thumb"></div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background-color: #333333;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.toggle.enabled {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.toggle.enabled .thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Info } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
let { text }: Props = $props();
|
||||
|
||||
let isVisible = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tooltip-container"
|
||||
onmouseenter={() => (isVisible = true)}
|
||||
onmouseleave={() => (isVisible = false)}
|
||||
>
|
||||
<Info size={14} class="icon" />
|
||||
{#if isVisible}
|
||||
<div class="tooltip">
|
||||
{text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tooltip-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.tooltip-container :global(.icon) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
white-space: normal;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { Play, Plus, MoreHorizontal } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
number: number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration: string;
|
||||
quality?: string;
|
||||
isPlaying?: boolean;
|
||||
onPlay?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
number,
|
||||
title,
|
||||
artist,
|
||||
duration,
|
||||
quality,
|
||||
isPlaying = false,
|
||||
onPlay
|
||||
}: Props = $props();
|
||||
|
||||
let isHovered = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="track-row"
|
||||
class:playing={isPlaying}
|
||||
class:hovered={isHovered && !isPlaying}
|
||||
onmouseenter={() => (isHovered = true)}
|
||||
onmouseleave={() => (isHovered = false)}
|
||||
onclick={onPlay}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Enter' && onPlay?.()}
|
||||
>
|
||||
<!-- Track Number / Play Button -->
|
||||
<div class="track-number">
|
||||
{#if isHovered && !isPlaying}
|
||||
<Play size={16} class="play-icon" fill="white" />
|
||||
{:else if isPlaying}
|
||||
<div class="playing-indicator">
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<span>{number}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="track-info">
|
||||
<div class="track-title" class:active={isPlaying}>{title}</div>
|
||||
{#if artist}
|
||||
<div class="track-artist">{artist}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="track-duration">{duration}</div>
|
||||
|
||||
<!-- Quality -->
|
||||
{#if quality}
|
||||
<div class="track-quality">{quality}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hover Actions -->
|
||||
{#if isHovered && !isPlaying}
|
||||
<div class="track-actions">
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.track-row {
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.track-row.hovered {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.track-row.playing {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.track-number {
|
||||
width: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.track-number span {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.track-number :global(.play-icon) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.playing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.playing-indicator .bar {
|
||||
width: 3px;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.playing-indicator .bar:nth-child(1) {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.playing-indicator .bar:nth-child(2) {
|
||||
height: 16px;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.playing-indicator .bar:nth-child(3) {
|
||||
height: 10px;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-title.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-quality {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
subscription: string;
|
||||
avatarUrl?: string;
|
||||
onSettingsClick: () => void;
|
||||
}
|
||||
|
||||
let { username, subscription, avatarUrl, onSettingsClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="user-card">
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt={username} />
|
||||
{:else}
|
||||
{username.charAt(0).toUpperCase()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="user-info">
|
||||
<div class="username">{username}</div>
|
||||
<div class="subscription">{subscription}</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Button -->
|
||||
<button
|
||||
class="settings-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSettingsClick();
|
||||
}}
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscription {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number;
|
||||
onchange: (value: number) => void;
|
||||
max?: number;
|
||||
showValue?: boolean;
|
||||
}
|
||||
|
||||
let { value, onchange, max = 100, showValue = false }: Props = $props();
|
||||
|
||||
let sliderRef: HTMLDivElement;
|
||||
let isDragging = $state(false);
|
||||
|
||||
const percentage = $derived((value / max) * 100);
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
isDragging = true;
|
||||
updateValue(e);
|
||||
}
|
||||
|
||||
function updateValue(e: MouseEvent) {
|
||||
if (sliderRef) {
|
||||
const rect = sliderRef.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
||||
onchange(Math.round((pct / 100) * max));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isDragging) updateValue(e);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="slider-container">
|
||||
<div
|
||||
class="slider"
|
||||
bind:this={sliderRef}
|
||||
onmousedown={handleMouseDown}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
>
|
||||
<div class="fill" style="width: {percentage}%"></div>
|
||||
<div class="thumb" style="left: {percentage}%"></div>
|
||||
</div>
|
||||
{#if showValue}
|
||||
<span class="value">{value}s</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: #333333;
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.slider:hover .thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Play, Shuffle, Heart, Plus, MoreHorizontal } from 'lucide-svelte';
|
||||
import TrackRow from '../TrackRow.svelte';
|
||||
|
||||
interface Track {
|
||||
number: number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
album: {
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
year: string;
|
||||
label: string;
|
||||
genre: string;
|
||||
quality: string;
|
||||
trackCount: number;
|
||||
duration: string;
|
||||
tracks: Track[];
|
||||
};
|
||||
onBack: () => void;
|
||||
onArtistClick?: () => void;
|
||||
}
|
||||
|
||||
let { album, onBack, onArtistClick }: Props = $props();
|
||||
|
||||
let currentTrack = $state<number | null>(null);
|
||||
let isFavorite = $state(false);
|
||||
let playBtnHovered = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="album-detail">
|
||||
<!-- Back Navigation -->
|
||||
<button class="back-btn" onclick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
<!-- Album Header -->
|
||||
<div class="album-header">
|
||||
<!-- Album Artwork -->
|
||||
<div class="artwork">
|
||||
<img src={album.artwork} alt={album.title} />
|
||||
</div>
|
||||
|
||||
<!-- Album Metadata -->
|
||||
<div class="metadata">
|
||||
<h1 class="album-title">{album.title}</h1>
|
||||
<button class="artist-link" onclick={onArtistClick}>
|
||||
{album.artist}
|
||||
</button>
|
||||
<div class="album-info">{album.year} • {album.label} • {album.genre}</div>
|
||||
<div class="album-quality">{album.quality}</div>
|
||||
<div class="album-stats">{album.trackCount} tracks • {album.duration}</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="actions">
|
||||
<button
|
||||
class="play-btn"
|
||||
style="background-color: {playBtnHovered ? 'var(--accent-hover)' : 'var(--accent-primary)'}"
|
||||
onmouseenter={() => (playBtnHovered = true)}
|
||||
onmouseleave={() => (playBtnHovered = false)}
|
||||
>
|
||||
<Play size={18} fill="white" color="white" />
|
||||
<span>Play</span>
|
||||
</button>
|
||||
<button class="secondary-btn">
|
||||
<Shuffle size={18} />
|
||||
<span>Shuffle</span>
|
||||
</button>
|
||||
<button class="icon-btn" onclick={() => (isFavorite = !isFavorite)}>
|
||||
<Heart
|
||||
size={20}
|
||||
color={isFavorite ? 'var(--accent-primary)' : 'white'}
|
||||
fill={isFavorite ? 'var(--accent-primary)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
<button class="icon-btn">
|
||||
<Plus size={20} color="white" />
|
||||
</button>
|
||||
<button class="icon-btn">
|
||||
<MoreHorizontal size={20} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Track List -->
|
||||
<div class="track-list">
|
||||
<!-- Table Header -->
|
||||
<div class="table-header">
|
||||
<div class="col-number">#</div>
|
||||
<div class="col-title">Title</div>
|
||||
<div class="col-duration">Duration</div>
|
||||
<div class="col-quality">Quality</div>
|
||||
</div>
|
||||
|
||||
<!-- Track Rows -->
|
||||
<div class="tracks">
|
||||
{#each album.tracks as track}
|
||||
<TrackRow
|
||||
number={track.number}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
duration={track.duration}
|
||||
quality={track.quality}
|
||||
isPlaying={currentTrack === track.number}
|
||||
onPlay={() => (currentTrack = track.number)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.album-detail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 24px;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.album-header {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.artwork {
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.artwork img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.artist-link {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: fit-content;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artist-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.album-quality {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.album-stats {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.secondary-btn span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--bg-tertiary);
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #666666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.col-number {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-duration {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.col-quality {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import HeroSection from '../HeroSection.svelte';
|
||||
import HorizontalScrollRow from '../HorizontalScrollRow.svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
|
||||
interface Album {
|
||||
id?: string;
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
featuredAlbum?: {
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
year: string;
|
||||
};
|
||||
recentAlbums?: Album[];
|
||||
recommendedAlbums?: Album[];
|
||||
newReleases?: Album[];
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
}
|
||||
|
||||
let { featuredAlbum, recentAlbums = [], recommendedAlbums = [], newReleases = [], onAlbumClick }: Props = $props();
|
||||
|
||||
const defaultFeatured = {
|
||||
artwork: 'https://picsum.photos/seed/featured/800/400',
|
||||
title: 'Featured Album',
|
||||
artist: 'Featured Artist',
|
||||
year: '2024'
|
||||
};
|
||||
|
||||
const featured = featuredAlbum ?? (recentAlbums[0] ? {
|
||||
artwork: recentAlbums[0].artwork,
|
||||
title: recentAlbums[0].title,
|
||||
artist: recentAlbums[0].artist,
|
||||
year: '2024'
|
||||
} : defaultFeatured);
|
||||
</script>
|
||||
|
||||
<div class="home-view">
|
||||
<!-- Hero/Featured Section -->
|
||||
<HeroSection
|
||||
artwork={featured.artwork}
|
||||
title={featured.title}
|
||||
artist={featured.artist}
|
||||
year={featured.year}
|
||||
/>
|
||||
|
||||
<!-- Recently Played -->
|
||||
{#if recentAlbums.length > 0}
|
||||
<HorizontalScrollRow title="Escuchado recientemente">
|
||||
{#snippet children()}
|
||||
{#each recentAlbums as album}
|
||||
<AlbumCard
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
quality={album.quality}
|
||||
onclick={() => onAlbumClick?.(album.id ?? '')}
|
||||
/>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
|
||||
<!-- Recommended For You -->
|
||||
{#if recommendedAlbums.length > 0}
|
||||
<HorizontalScrollRow title="Recomendado para ti">
|
||||
{#snippet children()}
|
||||
{#each recommendedAlbums as album}
|
||||
<AlbumCard
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
quality={album.quality}
|
||||
size="large"
|
||||
onclick={() => onAlbumClick?.(album.id ?? '')}
|
||||
/>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
|
||||
<!-- New Releases -->
|
||||
{#if newReleases.length > 0}
|
||||
<HorizontalScrollRow title="Nuevos lanzamientos">
|
||||
{#snippet children()}
|
||||
{#each newReleases as album}
|
||||
<AlbumCard
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
quality={album.quality}
|
||||
onclick={() => onAlbumClick?.(album.id ?? '')}
|
||||
/>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.home-view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
import Toggle from '../Toggle.svelte';
|
||||
import Dropdown from '../Dropdown.svelte';
|
||||
import VolumeSlider from '../VolumeSlider.svelte';
|
||||
import Tooltip from '../Tooltip.svelte';
|
||||
|
||||
interface Props {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
let { onBack }: Props = $props();
|
||||
|
||||
// Audio settings
|
||||
let streamingQuality = $state('Hi-Res');
|
||||
let preferHighest = $state(true);
|
||||
let outputDevice = $state('System Default');
|
||||
let exclusiveMode = $state(false);
|
||||
let dacPassthrough = $state(false);
|
||||
|
||||
// Playback settings
|
||||
let gaplessPlayback = $state(true);
|
||||
let crossfade = $state(0);
|
||||
let normalizeVolume = $state(false);
|
||||
|
||||
// Appearance settings
|
||||
let theme = $state('Dark');
|
||||
let compactMode = $state(false);
|
||||
|
||||
// Integrations
|
||||
let lastfmConnected = $state(false);
|
||||
let scrobbling = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="settings-view">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
{#if onBack}
|
||||
<button class="back-btn" onclick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
{/if}
|
||||
<h1 class="title">Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Account</h3>
|
||||
<div class="account-card">
|
||||
<div class="avatar">U</div>
|
||||
<div class="account-info">
|
||||
<div class="username">Username</div>
|
||||
<div class="email">email@example.com</div>
|
||||
<div class="subscription">Studio Premier</div>
|
||||
</div>
|
||||
<button class="logout-btn">Logout</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Audio</h3>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Streaming Quality</span>
|
||||
<Dropdown
|
||||
value={streamingQuality}
|
||||
options={['MP3', 'CD Quality', 'Hi-Res', 'Hi-Res+']}
|
||||
onchange={(v) => (streamingQuality = v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Prefer highest available</span>
|
||||
<Toggle enabled={preferHighest} onchange={(v) => (preferHighest = v)} />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Output Device</span>
|
||||
<Dropdown
|
||||
value={outputDevice}
|
||||
options={['System Default', 'Built-in Speakers', 'USB DAC', 'HDMI Output']}
|
||||
onchange={(v) => (outputDevice = v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="label-with-tooltip">
|
||||
<span class="setting-label">Exclusive Mode</span>
|
||||
<Tooltip text="Locks the audio device for exclusive use by QBZ-NIX for better quality" />
|
||||
</div>
|
||||
<Toggle enabled={exclusiveMode} onchange={(v) => (exclusiveMode = v)} />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="label-with-tooltip">
|
||||
<span class="setting-label">DAC Passthrough</span>
|
||||
<Tooltip text="Bypass the system audio mixer to send audio directly to your DAC at its native sample rate. Recommended for external DACs." />
|
||||
</div>
|
||||
<Toggle enabled={dacPassthrough} onchange={(v) => (dacPassthrough = v)} />
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Sample Rate</span>
|
||||
<span class="setting-value">192 kHz</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playback Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Playback</h3>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Gapless Playback</span>
|
||||
<Toggle enabled={gaplessPlayback} onchange={(v) => (gaplessPlayback = v)} />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Crossfade</span>
|
||||
<div class="slider-container">
|
||||
<VolumeSlider value={crossfade} onchange={(v) => (crossfade = v)} max={12} showValue />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Normalize Volume</span>
|
||||
<Toggle enabled={normalizeVolume} onchange={(v) => (normalizeVolume = v)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Appearance</h3>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Theme</span>
|
||||
<Dropdown
|
||||
value={theme}
|
||||
options={['Dark', 'Light', 'OLED Black', 'Warm']}
|
||||
onchange={(v) => (theme = v)}
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Compact Mode</span>
|
||||
<Toggle enabled={compactMode} onchange={(v) => (compactMode = v)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Integrations Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Integrations</h3>
|
||||
<div class="setting-row" class:last={!lastfmConnected}>
|
||||
<span class="setting-label">Last.fm</span>
|
||||
<button
|
||||
class="connect-btn"
|
||||
class:connected={lastfmConnected}
|
||||
onclick={() => (lastfmConnected = !lastfmConnected)}
|
||||
>
|
||||
{lastfmConnected ? 'Disconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
{#if lastfmConnected}
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Scrobbling</span>
|
||||
<Toggle enabled={scrobbling} onchange={(v) => (scrobbling = v)} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Storage Section -->
|
||||
<section class="section">
|
||||
<h3 class="section-title">Storage</h3>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Cache Size</span>
|
||||
<span class="setting-value">2.4 GB</span>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">Clear Cache</span>
|
||||
<button class="clear-btn">Clear</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.subscription {
|
||||
font-size: 14px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ff6b6b;
|
||||
background: none;
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.setting-row.last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.label-with-tooltip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.connect-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.connect-btn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.connect-btn.connected {
|
||||
background: none;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.connect-btn.connected:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ff6b6b;
|
||||
background: none;
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
}
|
||||
</style>
|
||||
+369
-132
@@ -1,156 +1,393 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let name = $state("");
|
||||
let greetMsg = $state("");
|
||||
// Components
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import NowPlayingBar from '$lib/components/NowPlayingBar.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
|
||||
async function greet(event: Event) {
|
||||
event.preventDefault();
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
greetMsg = await invoke("greet", { name });
|
||||
// Views
|
||||
import HomeView from '$lib/components/views/HomeView.svelte';
|
||||
import SettingsView from '$lib/components/views/SettingsView.svelte';
|
||||
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
|
||||
|
||||
// Overlays
|
||||
import QueuePanel from '$lib/components/QueuePanel.svelte';
|
||||
import FullScreenNowPlaying from '$lib/components/FullScreenNowPlaying.svelte';
|
||||
import FocusMode from '$lib/components/FocusMode.svelte';
|
||||
|
||||
// Types
|
||||
interface Album {
|
||||
id: string;
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
number: number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
interface AlbumDetail {
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
year: string;
|
||||
label: string;
|
||||
genre: string;
|
||||
quality: string;
|
||||
trackCount: number;
|
||||
duration: string;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
interface QueueTrack {
|
||||
id: string;
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
// View State
|
||||
let activeView = $state<'home' | 'search' | 'library' | 'settings' | 'album'>('home');
|
||||
let viewHistory = $state<string[]>(['home']);
|
||||
let selectedAlbum = $state<AlbumDetail | null>(null);
|
||||
|
||||
// Overlay States
|
||||
let isQueueOpen = $state(false);
|
||||
let isFullScreenOpen = $state(false);
|
||||
let isFocusModeOpen = $state(false);
|
||||
|
||||
// Playback State
|
||||
let isPlaying = $state(false);
|
||||
let currentTime = $state(73);
|
||||
let duration = $state(245);
|
||||
let volume = $state(75);
|
||||
let isShuffle = $state(false);
|
||||
let repeatMode = $state<'off' | 'all' | 'one'>('off');
|
||||
let isFavorite = $state(false);
|
||||
|
||||
// Toast State
|
||||
let toast = $state<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||
|
||||
// Mock Data
|
||||
const mockAlbums: Album[] = [
|
||||
{ id: '1', artwork: 'https://picsum.photos/seed/album1/300/300', title: 'Midnight Dreams', artist: 'Luna Nova' },
|
||||
{ id: '2', artwork: 'https://picsum.photos/seed/album2/300/300', title: 'Electric Soul', artist: 'The Voltage' },
|
||||
{ id: '3', artwork: 'https://picsum.photos/seed/album3/300/300', title: 'Cosmic Journey', artist: 'Stellar Wind' },
|
||||
{ id: '4', artwork: 'https://picsum.photos/seed/album4/300/300', title: 'Urban Echoes', artist: 'City Lights' },
|
||||
{ id: '5', artwork: 'https://picsum.photos/seed/album5/300/300', title: 'Ocean Waves', artist: 'Deep Blue' },
|
||||
{ id: '6', artwork: 'https://picsum.photos/seed/album6/300/300', title: 'Mountain High', artist: 'Peak Sound' },
|
||||
{ id: '7', artwork: 'https://picsum.photos/seed/album7/300/300', title: 'Desert Storm', artist: 'Sand Dunes' },
|
||||
{ id: '8', artwork: 'https://picsum.photos/seed/album8/300/300', title: 'Forest Tales', artist: 'Green Echo' },
|
||||
];
|
||||
|
||||
const mockAlbumDetail: AlbumDetail = {
|
||||
artwork: 'https://picsum.photos/seed/album1/500/500',
|
||||
title: 'Midnight Dreams',
|
||||
artist: 'Luna Nova',
|
||||
year: '2024',
|
||||
label: 'Stellar Records',
|
||||
genre: 'Electronic',
|
||||
quality: '24-Bit / 96 kHz',
|
||||
trackCount: 12,
|
||||
duration: '48:32',
|
||||
tracks: [
|
||||
{ number: 1, title: 'Moonrise', duration: '4:23', quality: 'Hi-Res' },
|
||||
{ number: 2, title: 'Starlight Serenade', duration: '3:45', quality: 'Hi-Res' },
|
||||
{ number: 3, title: 'Night Sky', duration: '5:12', quality: 'Hi-Res' },
|
||||
{ number: 4, title: 'Celestial Dance', duration: '4:01', quality: 'Hi-Res' },
|
||||
{ number: 5, title: 'Lunar Eclipse', duration: '3:56', quality: 'Hi-Res' },
|
||||
{ number: 6, title: 'Cosmic Dust', duration: '4:33', quality: 'Hi-Res' },
|
||||
{ number: 7, title: 'Nebula Dreams', duration: '5:08', quality: 'Hi-Res' },
|
||||
{ number: 8, title: 'Aurora', duration: '3:42', quality: 'Hi-Res' },
|
||||
{ number: 9, title: 'Twilight Zone', duration: '4:15', quality: 'Hi-Res' },
|
||||
{ number: 10, title: 'Midnight Hour', duration: '3:58', quality: 'Hi-Res' },
|
||||
{ number: 11, title: 'Dream Sequence', duration: '4:47', quality: 'Hi-Res' },
|
||||
{ number: 12, title: 'Dawn Breaking', duration: '4:12', quality: 'Hi-Res' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentTrack: QueueTrack = {
|
||||
id: 'current',
|
||||
artwork: 'https://picsum.photos/seed/album1/300/300',
|
||||
title: 'Moonrise',
|
||||
artist: 'Luna Nova',
|
||||
duration: '4:23',
|
||||
};
|
||||
|
||||
const upcomingTracks: QueueTrack[] = [
|
||||
{ id: '2', artwork: 'https://picsum.photos/seed/album1/300/300', title: 'Starlight Serenade', artist: 'Luna Nova', duration: '3:45' },
|
||||
{ id: '3', artwork: 'https://picsum.photos/seed/album1/300/300', title: 'Night Sky', artist: 'Luna Nova', duration: '5:12' },
|
||||
{ id: '4', artwork: 'https://picsum.photos/seed/album1/300/300', title: 'Celestial Dance', artist: 'Luna Nova', duration: '4:01' },
|
||||
];
|
||||
|
||||
// View Types
|
||||
type ViewType = 'home' | 'search' | 'library' | 'settings' | 'album';
|
||||
|
||||
// Navigation Functions
|
||||
function navigateTo(view: string) {
|
||||
const typedView = view as ViewType;
|
||||
if (typedView !== activeView) {
|
||||
viewHistory = [...viewHistory, typedView];
|
||||
activeView = typedView;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (viewHistory.length > 1) {
|
||||
viewHistory = viewHistory.slice(0, -1);
|
||||
activeView = viewHistory[viewHistory.length - 1] as typeof activeView;
|
||||
if (activeView !== 'album') {
|
||||
selectedAlbum = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAlbumClick(albumId: string) {
|
||||
selectedAlbum = mockAlbumDetail;
|
||||
navigateTo('album');
|
||||
}
|
||||
|
||||
// Playback Functions
|
||||
function togglePlay() {
|
||||
isPlaying = !isPlaying;
|
||||
}
|
||||
|
||||
function handleSeek(time: number) {
|
||||
currentTime = Math.max(0, Math.min(duration, time));
|
||||
}
|
||||
|
||||
function handleVolumeChange(newVolume: number) {
|
||||
volume = Math.max(0, Math.min(100, newVolume));
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
isShuffle = !isShuffle;
|
||||
showToast(isShuffle ? 'Shuffle enabled' : 'Shuffle disabled', 'info');
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
if (repeatMode === 'off') repeatMode = 'all';
|
||||
else if (repeatMode === 'all') repeatMode = 'one';
|
||||
else repeatMode = 'off';
|
||||
|
||||
const messages = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' };
|
||||
showToast(messages[repeatMode], 'info');
|
||||
}
|
||||
|
||||
function toggleFavorite() {
|
||||
isFavorite = !isFavorite;
|
||||
showToast(isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success');
|
||||
}
|
||||
|
||||
// Toast Function
|
||||
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||
toast = { message, type };
|
||||
}
|
||||
|
||||
function hideToast() {
|
||||
toast = null;
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'f':
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
isFocusModeOpen = !isFocusModeOpen;
|
||||
}
|
||||
break;
|
||||
case 'q':
|
||||
isQueueOpen = !isQueueOpen;
|
||||
break;
|
||||
case 'Escape':
|
||||
if (isFocusModeOpen) isFocusModeOpen = false;
|
||||
else if (isFullScreenOpen) isFullScreenOpen = false;
|
||||
else if (isQueueOpen) isQueueOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="container">
|
||||
<h1>Welcome to Tauri + Svelte</h1>
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<Sidebar
|
||||
{activeView}
|
||||
onNavigate={navigateTo}
|
||||
onSettingsClick={() => navigateTo('settings')}
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src="/vite.svg" class="logo vite" alt="Vite Logo" />
|
||||
</a>
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<img src="/tauri.svg" class="logo tauri" alt="Tauri Logo" />
|
||||
</a>
|
||||
<a href="https://svelte.dev" target="_blank">
|
||||
<img src="/svelte.svg" class="logo svelte-kit" alt="SvelteKit Logo" />
|
||||
</a>
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
{#if activeView === 'home'}
|
||||
<HomeView
|
||||
recentAlbums={mockAlbums}
|
||||
recommendedAlbums={mockAlbums.slice().reverse()}
|
||||
newReleases={mockAlbums.slice(2, 6)}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
/>
|
||||
{:else if activeView === 'settings'}
|
||||
<SettingsView />
|
||||
{:else if activeView === 'album' && selectedAlbum}
|
||||
<AlbumDetailView
|
||||
album={selectedAlbum}
|
||||
onBack={goBack}
|
||||
/>
|
||||
{:else if activeView === 'search'}
|
||||
<div class="placeholder-view">
|
||||
<h1>Search</h1>
|
||||
<p>Search functionality coming soon...</p>
|
||||
</div>
|
||||
<p>Click on the Tauri, Vite, and SvelteKit logos to learn more.</p>
|
||||
|
||||
<form class="row" onsubmit={greet}>
|
||||
<input id="greet-input" placeholder="Enter a name..." bind:value={name} />
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
{:else if activeView === 'library'}
|
||||
<div class="placeholder-view">
|
||||
<h1>Library</h1>
|
||||
<p>Your library will appear here...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Now Playing Bar -->
|
||||
<NowPlayingBar
|
||||
artwork={currentTrack.artwork}
|
||||
trackTitle={currentTrack.title}
|
||||
artist={currentTrack.artist}
|
||||
{isPlaying}
|
||||
onTogglePlay={togglePlay}
|
||||
{currentTime}
|
||||
{duration}
|
||||
onSeek={handleSeek}
|
||||
{volume}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
{isShuffle}
|
||||
onToggleShuffle={toggleShuffle}
|
||||
{repeatMode}
|
||||
onToggleRepeat={toggleRepeat}
|
||||
{isFavorite}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onOpenQueue={() => (isQueueOpen = true)}
|
||||
onOpenFullScreen={() => (isFullScreenOpen = true)}
|
||||
/>
|
||||
|
||||
<!-- Queue Panel -->
|
||||
<QueuePanel
|
||||
isOpen={isQueueOpen}
|
||||
onClose={() => (isQueueOpen = false)}
|
||||
{currentTrack}
|
||||
{upcomingTracks}
|
||||
onClearQueue={() => showToast('Queue cleared', 'info')}
|
||||
onSaveAsPlaylist={() => showToast('Saved as playlist', 'success')}
|
||||
/>
|
||||
|
||||
<!-- Full Screen Now Playing -->
|
||||
<FullScreenNowPlaying
|
||||
isOpen={isFullScreenOpen}
|
||||
onClose={() => (isFullScreenOpen = false)}
|
||||
artwork={currentTrack.artwork}
|
||||
trackTitle={currentTrack.title}
|
||||
artist={currentTrack.artist}
|
||||
album="Midnight Dreams"
|
||||
quality="24-Bit / 96 kHz"
|
||||
qualityLevel={4}
|
||||
{isPlaying}
|
||||
onTogglePlay={togglePlay}
|
||||
{currentTime}
|
||||
{duration}
|
||||
onSeek={handleSeek}
|
||||
{volume}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
{isShuffle}
|
||||
onToggleShuffle={toggleShuffle}
|
||||
{repeatMode}
|
||||
onToggleRepeat={toggleRepeat}
|
||||
{isFavorite}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onOpenQueue={() => {
|
||||
isFullScreenOpen = false;
|
||||
isQueueOpen = true;
|
||||
}}
|
||||
onOpenFocusMode={() => {
|
||||
isFullScreenOpen = false;
|
||||
isFocusModeOpen = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Focus Mode -->
|
||||
<FocusMode
|
||||
isOpen={isFocusModeOpen}
|
||||
onClose={() => (isFocusModeOpen = false)}
|
||||
artwork={currentTrack.artwork}
|
||||
trackTitle={currentTrack.title}
|
||||
artist={currentTrack.artist}
|
||||
{isPlaying}
|
||||
onTogglePlay={togglePlay}
|
||||
{currentTime}
|
||||
{duration}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
|
||||
<!-- Toast -->
|
||||
{#if toast}
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={hideToast}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.logo.svelte-kit:hover {
|
||||
filter: drop-shadow(0 0 2em #ff3e00);
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 240px;
|
||||
margin-bottom: 80px;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
.placeholder-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
.placeholder-view h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
.placeholder-view p {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in new issue
Block a user