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}
+
+{:else if loading}
+
+{:else}
+
+{/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)}
+
+
+
+ 
+
+
+
+ {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 @@
+
|