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:
vicrodh committed 2026-01-09 07:16:46 -06:00
1 parent ef92897d43
commit cdfd3263de
23 files changed
+4505 -134

No files matched your search

+10
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+132
View File
@@ -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>
+116
View File
@@ -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>
+346
View File
@@ -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>
+127
View File
@@ -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>
+62
View File
@@ -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>
+525
View File
@@ -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>
+362
View File
@@ -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>
+285
View File
@@ -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>
+101
View File
@@ -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>
+50
View File
@@ -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>
+55
View File
@@ -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>
+219
View File
@@ -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>
+117
View File
@@ -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>
+112
View File
@@ -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>
+118
View File
@@ -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
View File
@@ -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>