From fc2b987f637e92c6d558aa650d1313ae3ffb689d Mon Sep 17 00:00:00 2001 From: Markury Date: Fri, 3 Oct 2025 20:59:37 -0400 Subject: [PATCH] feat(ui): add now playing panel and context menu for tracks --- src/lib/ToolBar.svelte | 10 +- src/lib/components/CollectionView.svelte | 46 ++++ src/lib/components/ContextMenu.svelte | 108 ++++++++ src/lib/components/LyricsDisplay.svelte | 165 ++++++++++++ src/lib/components/NowPlayingPanel.svelte | 293 ++++++++++++++++++++++ src/lib/services/audioPlayer.ts | 164 ++++++++++++ src/lib/stores/playback.ts | 221 ++++++++++++++++ src/routes/+layout.svelte | 30 ++- static/icons/pause.svg | 3 + 9 files changed, 1035 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/ContextMenu.svelte create mode 100644 src/lib/components/LyricsDisplay.svelte create mode 100644 src/lib/components/NowPlayingPanel.svelte create mode 100644 src/lib/services/audioPlayer.ts create mode 100644 src/lib/stores/playback.ts create mode 100644 static/icons/pause.svg diff --git a/src/lib/ToolBar.svelte b/src/lib/ToolBar.svelte index 4b4575c..4b1710a 100644 --- a/src/lib/ToolBar.svelte +++ b/src/lib/ToolBar.svelte @@ -1,6 +1,8 @@ @@ -100,6 +135,8 @@ handleTrackClick(i)} + ondblclick={() => handleTrackDoubleClick(i)} + oncontextmenu={(e) => handleContextMenu(e, i)} > {useSequentialNumbers ? i + 1 : (track.metadata.trackNumber ?? i + 1)} @@ -126,6 +163,15 @@ +{#if contextMenu} + contextMenu = null} + /> +{/if} + diff --git a/src/lib/components/LyricsDisplay.svelte b/src/lib/components/LyricsDisplay.svelte new file mode 100644 index 0000000..8143278 --- /dev/null +++ b/src/lib/components/LyricsDisplay.svelte @@ -0,0 +1,165 @@ + + +{#if lyrics.length > 0} +
+
+ {#each lyrics as line, i} +

+ {line.text} +

+ {/each} +
+
+{:else if loading} +
+

Loading lyrics...

+
+{:else} +
+

No lyrics available

+
+{/if} + + diff --git a/src/lib/components/NowPlayingPanel.svelte b/src/lib/components/NowPlayingPanel.svelte new file mode 100644 index 0000000..ddf4203 --- /dev/null +++ b/src/lib/components/NowPlayingPanel.svelte @@ -0,0 +1,293 @@ + + +
+
+ + + + + +
+ +
+ {#if hasTrack && $playback.currentTrack} +
{$playback.currentTrack.metadata.title || $playback.currentTrack.filename}
+
{$playback.currentTrack.metadata.artist || 'Unknown Artist'}
+ {:else} +
No track playing
+ {/if} +
+ +
+ {formatTime($playback.currentTime)} +
+
+ +
+ +
+ {formatTime($playback.duration)} +
+ +
+ Volume +
+ +
+ {Math.round($playback.volume * 100)}% +
+ + +
+ + diff --git a/src/lib/services/audioPlayer.ts b/src/lib/services/audioPlayer.ts new file mode 100644 index 0000000..416a40a --- /dev/null +++ b/src/lib/services/audioPlayer.ts @@ -0,0 +1,164 @@ +import { convertFileSrc } from '@tauri-apps/api/core'; +import { playback } from '$lib/stores/playback'; +import type { Track } from '$lib/types/track'; + +class AudioPlayer { + private audio: HTMLAudioElement | null = null; + private currentTrackPath: string | null = null; + private isSeeking = false; + + constructor() { + if (typeof window !== 'undefined') { + this.audio = new Audio(); + this.setupEventListeners(); + } + } + + private setupEventListeners() { + if (!this.audio) return; + + // Time updates + this.audio.addEventListener('timeupdate', () => { + if (this.audio && !this.isSeeking) { + playback.setCurrentTime(this.audio.currentTime); + } + }); + + // Seeking events + this.audio.addEventListener('seeking', () => { + this.isSeeking = true; + }); + + this.audio.addEventListener('seeked', () => { + this.isSeeking = false; + if (this.audio) { + playback.setCurrentTime(this.audio.currentTime); + } + }); + + // Duration loaded + this.audio.addEventListener('loadedmetadata', () => { + if (this.audio) { + playback.setDuration(this.audio.duration); + } + }); + + // Track ended - auto-advance + this.audio.addEventListener('ended', () => { + console.log('[AudioPlayer] Track ended, advancing to next'); + playback.next(); + }); + + // Error handling + this.audio.addEventListener('error', (e) => { + console.error('[AudioPlayer] Playback error:', e); + const error = this.audio?.error; + + if (error) { + console.error(`[AudioPlayer] Error code: ${error.code}, message: ${error.message}`); + } + + // Skip to next track on error + console.log('[AudioPlayer] Skipping to next track due to error'); + playback.next(); + }); + } + + async loadTrack(track: Track) { + if (!this.audio) { + console.error('[AudioPlayer] Audio element not initialized'); + return; + } + + try { + // Convert file path to Tauri asset URL + const audioUrl = convertFileSrc(track.path); + + console.log('[AudioPlayer] Loading track:', track.metadata.title || track.filename); + console.log('[AudioPlayer] File path:', track.path); + console.log('[AudioPlayer] Asset URL:', audioUrl); + + // Only reload if different track + if (this.currentTrackPath !== track.path) { + this.audio.src = audioUrl; + this.currentTrackPath = track.path; + + // Check for LRC file (same path but .lrc extension) + const lrcPath = track.path.replace(/\.(flac|mp3|opus|ogg|m4a|wav)$/i, '.lrc'); + playback.setLrcPath(lrcPath); + + await this.audio.load(); + } + } catch (error) { + console.error('[AudioPlayer] Error loading track:', error); + // Skip to next on load error + playback.next(); + } + } + + async play() { + if (!this.audio) return; + + try { + await this.audio.play(); + } catch (error) { + console.error('[AudioPlayer] Error playing:', error); + } + } + + pause() { + if (!this.audio) return; + this.audio.pause(); + } + + seek(time: number) { + if (!this.audio) return; + this.audio.currentTime = time; + } + + setVolume(volume: number) { + if (!this.audio) return; + this.audio.volume = Math.max(0, Math.min(1, volume)); + } + + getCurrentTime(): number { + return this.audio?.currentTime || 0; + } + + getDuration(): number { + return this.audio?.duration || 0; + } +} + +// Singleton instance +export const audioPlayer = new AudioPlayer(); + +// Watch playback state and control audio +if (typeof window !== 'undefined') { + let prevTrack: Track | null = null; + let prevIsPlaying = false; + + playback.subscribe(state => { + const { currentTrack, isPlaying } = state; + + // Track changed + if (currentTrack && currentTrack !== prevTrack) { + audioPlayer.loadTrack(currentTrack).then(() => { + if (isPlaying) { + audioPlayer.play(); + } + }); + prevTrack = currentTrack; + } + + // Play/pause state changed + if (isPlaying !== prevIsPlaying) { + if (isPlaying && currentTrack) { + audioPlayer.play(); + } else { + audioPlayer.pause(); + } + prevIsPlaying = isPlaying; + } + }); +} diff --git a/src/lib/stores/playback.ts b/src/lib/stores/playback.ts new file mode 100644 index 0000000..17cf5cd --- /dev/null +++ b/src/lib/stores/playback.ts @@ -0,0 +1,221 @@ +import { writable, get, type Writable } from 'svelte/store'; +import type { Track } from '$lib/types/track'; + +export interface PlaybackState { + currentTrack: Track | null; + queue: Track[]; + queueIndex: number; + isPlaying: boolean; + volume: number; // 0-1 + currentTime: number; + duration: number; + lrcPath: string | null; // Path to LRC file if available +} + +const initialState: PlaybackState = { + currentTrack: null, + queue: [], + queueIndex: -1, + isPlaying: false, + volume: 1, + currentTime: 0, + duration: 0, + lrcPath: null +}; + +interface PlaybackStore extends Writable { + playTrack(track: Track): void; + playQueue(tracks: Track[], startIndex?: number): void; + addToQueue(tracks: Track[]): void; + playNext(tracks: Track[]): void; + removeFromQueue(index: number): void; + play(): void; + pause(): void; + togglePlayPause(): void; + stop(): void; + next(): void; + previous(): void; + setCurrentTime(time: number): void; + setDuration(duration: number): void; + setVolume(volume: number): void; + setLrcPath(path: string | null): void; +} + +function createPlaybackStore(): PlaybackStore { + const { subscribe, set, update } = writable(initialState); + + return { + subscribe, + + // Queue management + playTrack(track: Track) { + update(state => ({ + ...state, + currentTrack: track, + queue: [track], + queueIndex: 0, + isPlaying: true + })); + }, + + playQueue(tracks: Track[], startIndex = 0) { + if (tracks.length === 0) return; + update(state => ({ + ...state, + queue: tracks, + queueIndex: startIndex, + currentTrack: tracks[startIndex], + isPlaying: true + })); + }, + + addToQueue(tracks: Track[]) { + update(state => ({ + ...state, + queue: [...state.queue, ...tracks] + })); + }, + + playNext(tracks: Track[]) { + update(state => { + // Insert after current track + if (state.queueIndex >= 0) { + const newQueue = [...state.queue]; + newQueue.splice(state.queueIndex + 1, 0, ...tracks); + return { + ...state, + queue: newQueue + }; + } else { + return { + ...state, + queue: [...tracks], + queueIndex: 0, + currentTrack: tracks[0], + isPlaying: true + }; + } + }); + }, + + removeFromQueue(index: number) { + update(state => { + const newQueue = [...state.queue]; + newQueue.splice(index, 1); + + let newQueueIndex = state.queueIndex; + if (index < state.queueIndex) { + newQueueIndex--; + } else if (index === state.queueIndex) { + // Removed current track + if (newQueue.length === 0) { + return { + ...state, + isPlaying: false, + currentTrack: null, + queue: [], + queueIndex: -1, + currentTime: 0, + duration: 0 + }; + } + } + + return { + ...state, + queue: newQueue, + queueIndex: newQueueIndex + }; + }); + }, + + // Playback control + play() { + update(state => { + if (state.currentTrack) { + return { ...state, isPlaying: true }; + } + return state; + }); + }, + + pause() { + update(state => ({ ...state, isPlaying: false })); + }, + + togglePlayPause() { + update(state => ({ ...state, isPlaying: !state.isPlaying })); + }, + + stop() { + set({ + ...initialState, + volume: get({ subscribe }).volume // Preserve volume + }); + }, + + next() { + update(state => { + if (state.queueIndex < state.queue.length - 1) { + return { + ...state, + queueIndex: state.queueIndex + 1, + currentTrack: state.queue[state.queueIndex + 1], + isPlaying: true, + currentTime: 0 + }; + } else { + // End of queue - stop + return { + ...initialState, + volume: state.volume // Preserve volume + }; + } + }); + }, + + previous() { + update(state => { + // If more than 3 seconds into track, restart current track + if (state.currentTime > 3) { + return { ...state, currentTime: 0 }; + } else if (state.queueIndex > 0) { + // Go to previous track + return { + ...state, + queueIndex: state.queueIndex - 1, + currentTrack: state.queue[state.queueIndex - 1], + isPlaying: true, + currentTime: 0 + }; + } else { + // At beginning of queue, restart current track + return { ...state, currentTime: 0 }; + } + }); + }, + + // Time and volume + setCurrentTime(time: number) { + update(state => ({ ...state, currentTime: time })); + }, + + setDuration(duration: number) { + update(state => ({ ...state, duration })); + }, + + setVolume(volume: number) { + update(state => ({ + ...state, + volume: Math.max(0, Math.min(1, volume)) + })); + }, + + // Lyrics + setLrcPath(path: string | null) { + update(state => ({ ...state, lrcPath: path })); + } + }; +} + +export const playback = createPlaybackStore(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a11d6cf..b49d34a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,16 +3,38 @@ import TitleBar from "$lib/TitleBar.svelte"; import MenuBar from "$lib/MenuBar.svelte"; import ToolBar from "$lib/ToolBar.svelte"; + import NowPlayingPanel from "$lib/components/NowPlayingPanel.svelte"; import { settings, loadSettings } from '$lib/stores/settings'; import { scanPlaylists, type Playlist } from '$lib/library/scanner'; import { downloadQueue } from '$lib/stores/downloadQueue'; import { deezerQueueManager } from '$lib/services/deezer/queueManager'; + import { playback } from '$lib/stores/playback'; let { children } = $props(); let playlists = $state([]); let playlistsLoadTimestamp = $state(0); - let showBanner = $state(false); + let showNowPlaying = $state(false); + let userHasClosedPanel = $state(false); + + // Auto-show now playing panel when track starts (but only if user hasn't manually closed it) + $effect(() => { + if ($playback.currentTrack && !showNowPlaying && !userHasClosedPanel) { + showNowPlaying = true; + } + // Reset the closed flag when there's no track + if (!$playback.currentTrack) { + userHasClosedPanel = false; + } + }); + + export function toggleNowPlaying() { + showNowPlaying = !showNowPlaying; + // Track when user manually closes the panel + if (!showNowPlaying) { + userHasClosedPanel = true; + } + } // Count active downloads (queued or downloading) let activeDownloads = $derived( @@ -86,7 +108,7 @@
- +
diff --git a/static/icons/pause.svg b/static/icons/pause.svg new file mode 100644 index 0000000..8e78020 --- /dev/null +++ b/static/icons/pause.svg @@ -0,0 +1,3 @@ + + +