diff --git a/app/frontend/__tests__/queue-builder.test.js b/app/frontend/__tests__/queue-builder.test.js index d764387c..68ea7d2a 100644 --- a/app/frontend/__tests__/queue-builder.test.js +++ b/app/frontend/__tests__/queue-builder.test.js @@ -46,11 +46,6 @@ function createMockCtx(queueOverrides = {}, playerOverrides = {}) { currentIndex: -1, shuffle: false, _updating: false, - _originalOrder: [], - _playHistory: [], - _playNextOffset: 0, - _playNextTrackIds: new Set(), - _pushToHistory: vi.fn(), ...queueOverrides, }, player: { @@ -131,37 +126,17 @@ describe('handleDoubleClickPlay', () => { expect(ctx.queue.currentIndex).toBe(1); }); - it('resets queue state fields after play context', async () => { + it('applies shuffle_enabled from result', async () => { const tracks = makeTracks(['A', 'B']); const result = makePlayContextResult(tracks, 0); + result.shuffle_enabled = true; queueApi.playContext.mockResolvedValue(result); - const existingHistory = [{ id: 99, title: 'Old' }]; - const ctx = createMockCtx({ - _playHistory: existingHistory, - _playNextOffset: 5, - _playNextTrackIds: new Set([99]), - }); - - await handleDoubleClickPlay(ctx, tracks[0], tracks, 0, 'test'); - - expect(ctx.queue._playHistory).toEqual([]); - expect(ctx.queue._playNextOffset).toBe(0); - expect(ctx.queue._playNextTrackIds.size).toBe(0); - }); - - it('sets _originalOrder to match items', async () => { - const tracks = makeTracks(['A', 'B', 'C']); - const result = makePlayContextResult(tracks, 0); - queueApi.playContext.mockResolvedValue(result); - - const ctx = createMockCtx(); + const ctx = createMockCtx({ shuffle: false }); await handleDoubleClickPlay(ctx, tracks[0], tracks, 0, 'test'); - expect(ctx.queue._originalOrder).toEqual(tracks); - // Should be a copy, not the same reference - expect(ctx.queue._originalOrder).not.toBe(ctx.queue.items); + expect(ctx.queue.shuffle).toBe(true); }); it('calls updateTrackState with track and duration_ms', async () => { diff --git a/app/frontend/__tests__/queue.props.test.js b/app/frontend/__tests__/queue.props.test.js index 419e8a83..ef289943 100644 --- a/app/frontend/__tests__/queue.props.test.js +++ b/app/frontend/__tests__/queue.props.test.js @@ -31,9 +31,17 @@ vi.mock('../js/api/queue.js', () => ({ setShuffle: vi.fn().mockResolvedValue({}), setLoop: vi.fn().mockResolvedValue({}), setCurrentIndex: vi.fn().mockResolvedValue({}), + addPlayNext: vi.fn().mockResolvedValue({}), + playNextTrack: vi.fn().mockResolvedValue({}), + playPreviousTrack: vi.fn().mockResolvedValue({}), + skipNext: vi.fn().mockResolvedValue({}), + skipPrevious: vi.fn().mockResolvedValue({}), + checkIntegrity: vi.fn().mockResolvedValue({}), }, })); +import { queue as queueApi } from '../js/api/queue.js'; + // Mock Tauri global.window = { __TAURI__: undefined, @@ -71,178 +79,73 @@ describe('Queue Store - Property-Based Tests', () => { store = Alpine.store('queue'); }); - describe('Shuffle Invariants', () => { - test.prop([trackListArbitrary])( - 'shuffle preserves all tracks (current track stored separately)', - async (tracks) => { - // Need at least 2 tracks to shuffle - fc.pre(tracks.length >= 2); - - // Setup - store.items = [...tracks]; - store._originalOrder = [...tracks]; - store.currentIndex = 0; - - // Get original track IDs - const originalIds = new Set(tracks.map((t) => t.id)); - - // Shuffle - store._shuffleItems(); - - // Verify same tracks are preserved (all tracks still in queue) - const shuffledIds = new Set(store.items.map((t) => t.id)); - expect(shuffledIds).toEqual(originalIds); - - // Queue should have same length (current track stays at index 0) - expect(store.items.length).toBe(tracks.length); - }, - ); - + describe('Shuffle (Backend-Delegated)', () => { test.prop([trackListArbitrary])( - 'shuffle twice produces different order (probabilistic)', + 'toggleShuffle calls setShuffle with inverted state', async (tracks) => { - // Skip if too few tracks to shuffle meaningfully - fc.pre(tracks.length >= 3); + fc.pre(tracks.length >= 1); store.items = [...tracks]; - store._originalOrder = [...tracks]; store.currentIndex = 0; + store.shuffle = false; - // First shuffle - current track at index 0 - store._shuffleItems(); - const firstShuffle = store.items.map((t) => t.id); - - // Queue should have same length after shuffle - expect(store.items.length).toBe(tracks.length); + // Mock backend returning a snapshot + const snapshot = { + items: tracks.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: true, + loop_mode: 'none', + play_next_offset: 0, + }; + queueApi.setShuffle.mockResolvedValue(snapshot); - // Second shuffle (simulating re-shuffle at end of queue with loop) - store._shuffleItems(); - const secondShuffle = store.items.map((t) => t.id); + await store.toggleShuffle(); - // With 3+ tracks, probability of identical shuffle is low - // (we accept some false negatives for simplicity) - // Both shuffles should maintain all tracks - expect(store.items.length).toBe(tracks.length); + expect(queueApi.setShuffle).toHaveBeenCalledWith(true); }, ); test.prop([trackListArbitrary])( - 'shuffle keeps current track at index 0 (task-213)', + 'toggleShuffle applies snapshot from backend', async (tracks) => { fc.pre(tracks.length >= 2); store.items = [...tracks]; - store._originalOrder = [...tracks]; - const currentIdx = Math.floor(tracks.length / 2); - store.currentIndex = currentIdx; - const currentTrack = tracks[currentIdx]; - - store._shuffleItems(); - - // Current track should be at index 0 - expect(store.items[0].id).toBe(currentTrack.id); - - // Queue should have same length (no tracks removed) - expect(store.items.length).toBe(tracks.length); - - // currentIndex should be 0 - expect(store.currentIndex).toBe(0); - }, - ); - - it('shuffle with duplicate tracks handles current track correctly (task-213)', () => { - // Create queue with duplicate tracks: [A, B, A, C] - // Playing track at index 2 (second occurrence of A) - const trackA1 = { id: 1, title: 'Track A', artist: 'Artist', album: 'Album' }; - const trackB = { id: 2, title: 'Track B', artist: 'Artist', album: 'Album' }; - const trackA2 = { id: 1, title: 'Track A', artist: 'Artist', album: 'Album' }; // Same ID, different object - const trackC = { id: 3, title: 'Track C', artist: 'Artist', album: 'Album' }; - - store.items = [trackA1, trackB, trackA2, trackC]; - store._originalOrder = [trackA1, trackB, trackA2, trackC]; - store.currentIndex = 2; // Playing second occurrence of A (trackA2) - - // Shuffle the queue - store._shuffleItems(); - - // Queue should have 4 items (current track stays at index 0) - expect(store.items.length).toBe(4); - - // Current track (second A) should be at index 0 - expect(store.items[0]).toBe(trackA2); - expect(store.currentIndex).toBe(0); - - // Both occurrences of A should still be in the queue - // since we filter by index, not by ID - const trackACount = store.items.filter((t) => t.id === 1).length; - expect(trackACount).toBe(2); - - // Verify queue has all 4 tracks (IDs 1, 2, 1, 3 sorted as 1, 1, 2, 3) - const trackIds = store.items.map((t) => t.id).sort(); - expect(trackIds).toEqual([1, 1, 2, 3]); - }); - - test.prop([trackListArbitrary])( - 'toggle shuffle twice returns to original order', - async (tracks) => { - fc.pre(tracks.length >= 1); - - store.items = [...tracks]; - store._originalOrder = [...tracks]; - store.currentIndex = tracks.length > 0 ? 0 : -1; - const originalOrder = tracks.map((t) => t.id); - - // Shuffle on + store.currentIndex = 0; store.shuffle = false; - await store.toggleShuffle(); - // Shuffle off (should restore) + // Simulate backend returning shuffled order + const shuffled = [...tracks].reverse(); + const snapshot = { + items: shuffled.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: true, + loop_mode: 'none', + play_next_offset: 0, + }; + queueApi.setShuffle.mockResolvedValue(snapshot); + await store.toggleShuffle(); - const restoredOrder = store.items.map((t) => t.id); - expect(restoredOrder).toEqual(originalOrder); + expect(store.shuffle).toBe(true); + expect(store.items.map((t) => t.id)).toEqual(shuffled.map((t) => t.id)); + expect(store.currentIndex).toBe(0); }, ); - test.prop([trackListArbitrary])( - '_reshuffleForLoopRestart does not put just-played track first (task-222)', - async (tracks) => { - // Need at least 2 tracks for reshuffle to be meaningful - fc.pre(tracks.length >= 2); - - store.items = [...tracks]; - store._originalOrder = [...tracks]; - // Set current index to last track (simulating end of queue) - store.currentIndex = tracks.length - 1; - const justPlayedTrack = tracks[tracks.length - 1]; - - // Reshuffle for loop restart - store._reshuffleForLoopRestart(); - - // Just-played track should NOT be at index 0 - expect(store.items[0].id).not.toBe(justPlayedTrack.id); + it('toggleShuffle handles API error gracefully', async () => { + store.items = [{ id: 1, title: 'A' }]; + store.shuffle = false; - // Just-played track should be at the END - expect(store.items[store.items.length - 1].id).toBe(justPlayedTrack.id); - - // All tracks should still be preserved - expect(store.items.length).toBe(tracks.length); - - // Original order should be updated to new shuffle - expect(store._originalOrder.length).toBe(tracks.length); - }, - ); + queueApi.setShuffle.mockRejectedValue(new Error('IPC failed')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - it('_reshuffleForLoopRestart with single track does nothing', () => { - const trackA = { id: 1, title: 'Track A', artist: 'Artist', album: 'Album' }; - store.items = [trackA]; - store.currentIndex = 0; + await store.toggleShuffle(); - store._reshuffleForLoopRestart(); + // Shuffle state should not change on error + expect(store.shuffle).toBe(false); - // Single track - nothing should change - expect(store.items.length).toBe(1); - expect(store.items[0].id).toBe(1); + consoleSpy.mockRestore(); }); }); @@ -565,88 +468,60 @@ describe('Queue Store - Property-Based Tests', () => { }); }); - describe('Play Next Invariants', () => { + describe('Play Next (Backend-Delegated)', () => { test.prop([ trackListArbitrary, fc.array(trackArbitrary, { minLength: 1, maxLength: 5 }), - fc.nat(), ])( - 'playNextTracks inserts after currentIndex', - async (tracks, playNextTracks, rawIdx) => { - fc.pre(tracks.length >= 2); + 'playNextTracks calls addPlayNext with track IDs', + async (tracks, playNextTracks) => { + fc.pre(tracks.length >= 1); store.items = [...tracks]; - store._originalOrder = [...tracks]; - const currentIdx = rawIdx % tracks.length; - store.currentIndex = currentIdx; - store._playNextOffset = 0; + store.currentIndex = 0; - const originalLength = store.items.length; - const currentTrackId = store.items[currentIdx].id; - - // Simulate the store's move-then-insert logic to compute expected length: - // For each input track, if it exists in the queue (and isn't current), - // it's removed first. The track is always added to tracksToInsert - // (including duplicates in input). Current track is skipped entirely. - const simQueue = new Set(tracks.map((t) => t.id)); - let removals = 0; - let inserts = 0; - for (const t of playNextTracks) { - if (t.id === currentTrackId) continue; - if (simQueue.has(t.id)) { - simQueue.delete(t.id); - removals++; - } - inserts++; - } - const expectedLength = originalLength - removals + inserts; + // Mock backend returning a snapshot with all tracks + const allTracks = [...tracks, ...playNextTracks]; + const snapshot = { + items: allTracks.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: false, + loop_mode: 'none', + play_next_offset: playNextTracks.length, + }; + queueApi.addPlayNext.mockResolvedValue(snapshot); await store.playNextTracks(playNextTracks); - // Invariant: current track is still in the queue - expect(store.items.find((t) => t.id === currentTrackId)).toBeTruthy(); - - // Invariant: total queue length matches simulated move-then-insert - expect(store.items.length).toBe(expectedLength); + expect(queueApi.addPlayNext).toHaveBeenCalledWith( + playNextTracks.map((t) => t.id), + ); }, ); test.prop([trackListArbitrary, fc.array(trackArbitrary, { minLength: 1, maxLength: 5 })])( - 'playNextTracks preserves all tracks', + 'playNextTracks applies snapshot from backend', async (tracks, playNextTracks) => { fc.pre(tracks.length >= 1); store.items = [...tracks]; - store._originalOrder = [...tracks]; store.currentIndex = 0; - store._playNextOffset = 0; - - const currentTrackId = tracks[0].id; - - // Simulate the store's move-then-insert logic - const simQueue = new Set(tracks.map((t) => t.id)); - let removals = 0; - let inserts = 0; - for (const t of playNextTracks) { - if (t.id === currentTrackId) continue; - if (simQueue.has(t.id)) { - simQueue.delete(t.id); - removals++; - } - inserts++; - } - const expectedLength = tracks.length - removals + inserts; - await store.playNextTracks(playNextTracks); + const resultTracks = [...tracks, ...playNextTracks]; + const snapshot = { + items: resultTracks.map((t) => ({ track: t })), + current_index: 0, + shuffle_enabled: false, + loop_mode: 'none', + play_next_offset: playNextTracks.length, + }; + queueApi.addPlayNext.mockResolvedValue(snapshot); - // Invariant: all play-next tracks present - const resultIds = store.items.map((t) => t.id); - for (const t of playNextTracks) { - expect(resultIds).toContain(t.id); - } + await store.playNextTracks(playNextTracks); - // Invariant: total count matches simulated move-then-insert - expect(store.items.length).toBe(expectedLength); + // Items should be set from snapshot + expect(store.items.length).toBe(resultTracks.length); + expect(store.currentIndex).toBe(0); }, ); }); @@ -660,9 +535,6 @@ describe('Queue Store - Property-Based Tests', () => { await expect(store.clear()).resolves.not.toThrow(); await expect(store.remove(0)).resolves.not.toThrow(); await expect(store.reorder(0, 1)).resolves.not.toThrow(); - - store._shuffleItems(); - expect(store.items.length).toBe(0); }); test.prop([fc.nat()])('operations with out-of-bounds indices are safe', async (idx) => { @@ -678,10 +550,6 @@ describe('Queue Store - Property-Based Tests', () => { store.items = [track]; store.currentIndex = 0; - // Shuffle should not change anything - store._shuffleItems(); - expect(store.items[0].id).toBe(track.id); - // Remove should empty queue await store.remove(0); expect(store.items.length).toBe(0); diff --git a/app/frontend/js/api/queue.js b/app/frontend/js/api/queue.js index 5869bef2..71bc751d 100644 --- a/app/frontend/js/api/queue.js +++ b/app/frontend/js/api/queue.js @@ -225,7 +225,7 @@ export const queue = { /** * Set shuffle enabled in queue (uses Tauri command) * @param {boolean} enabled - Whether shuffle is enabled - * @returns {Promise} + * @returns {Promise} State snapshot with reordered queue */ async setShuffle(enabled) { if (invoke) { @@ -255,4 +255,103 @@ export const queue = { } console.debug('Queue setLoop (no-op in browser):', mode); }, + + /** + * Add tracks as "play next" with backend-managed offset and move semantics + * @param {number[]} trackIds - Track IDs to play next + * @returns {Promise} + */ + async addPlayNext(trackIds) { + if (invoke) { + try { + return await invoke('queue_add_play_next', { trackIds }); + } catch (error) { + console.error('[api.queue.addPlayNext] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'addPlayNext not available in browser mode'); + }, + + /** + * Play next track with full state machine (repeat-one, loop, history, audio) + * @returns {Promise} + */ + async playNextTrack() { + if (invoke) { + try { + return await invoke('queue_play_next_track'); + } catch (error) { + console.error('[api.queue.playNextTrack] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'playNextTrack not available in browser mode'); + }, + + /** + * Play previous track with full state machine (>3sec restart, history, loop wrap) + * @param {number} currentTimeMs - Current playback position in milliseconds + * @returns {Promise} + */ + async playPreviousTrack(currentTimeMs) { + if (invoke) { + try { + return await invoke('queue_play_previous_track', { currentTimeMs }); + } catch (error) { + console.error('[api.queue.playPreviousTrack] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'playPreviousTrack not available in browser mode'); + }, + + /** + * Skip to next track, overriding repeat-one mode + * @returns {Promise} + */ + async skipNext() { + if (invoke) { + try { + return await invoke('queue_skip_next'); + } catch (error) { + console.error('[api.queue.skipNext] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'skipNext not available in browser mode'); + }, + + /** + * Skip to previous track, overriding repeat-one mode + * @param {number} currentTimeMs - Current playback position in milliseconds + * @returns {Promise} + */ + async skipPrevious(currentTimeMs) { + if (invoke) { + try { + return await invoke('queue_skip_previous', { currentTimeMs }); + } catch (error) { + console.error('[api.queue.skipPrevious] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'skipPrevious not available in browser mode'); + }, + + /** + * Run queue integrity check and auto-repair + * @returns {Promise} + */ + async checkIntegrity() { + if (invoke) { + try { + return await invoke('queue_check_integrity'); + } catch (error) { + console.error('[api.queue.checkIntegrity] Tauri error:', error); + throw new ApiError(500, error.toString()); + } + } + throw new ApiError(500, 'checkIntegrity not available in browser mode'); + }, }; diff --git a/app/frontend/js/components/library-browser.js b/app/frontend/js/components/library-browser.js index be27c667..c8d249e3 100644 --- a/app/frontend/js/components/library-browser.js +++ b/app/frontend/js/components/library-browser.js @@ -456,13 +456,6 @@ export function createLibraryBrowser(Alpine) { this.library.filteredTracks, index, 'library-browser', - { - beforePlay: () => { - if (this.queue.currentIndex >= 0) { - this.queue._pushToHistory(this.queue.currentIndex); - } - }, - }, ); }, diff --git a/app/frontend/js/events.js b/app/frontend/js/events.js index 72263979..ff19dc2f 100644 --- a/app/frontend/js/events.js +++ b/app/frontend/js/events.js @@ -8,7 +8,7 @@ * Event naming convention: `domain:action` (e.g., `library:updated`) */ -import { removeFromQueue } from "./utils/library-operations.js"; +import { removeFromQueue } from './utils/library-operations.js'; const { listen } = window.__TAURI__?.event ?? { listen: () => Promise.resolve(() => {}), @@ -22,24 +22,24 @@ const listeners = []; */ export const Events = { // Library events - LIBRARY_UPDATED: "library:updated", - LIBRARY_RECONCILE: "library:reconcile", - SCAN_PROGRESS: "library:scan-progress", - SCAN_COMPLETE: "library:scan-complete", + LIBRARY_UPDATED: 'library:updated', + LIBRARY_RECONCILE: 'library:reconcile', + SCAN_PROGRESS: 'library:scan-progress', + SCAN_COMPLETE: 'library:scan-complete', // Queue events - QUEUE_UPDATED: "queue:updated", - QUEUE_STATE_CHANGED: "queue:state-changed", + QUEUE_UPDATED: 'queue:updated', + QUEUE_STATE_CHANGED: 'queue:state-changed', // Favorites events - FAVORITES_UPDATED: "favorites:updated", + FAVORITES_UPDATED: 'favorites:updated', // Playlist events - PLAYLISTS_UPDATED: "playlists:updated", + PLAYLISTS_UPDATED: 'playlists:updated', // Settings events (Tauri Store) - SETTINGS_CHANGED: "settings://changed", - SETTINGS_RESET: "settings://reset", + SETTINGS_CHANGED: 'settings://changed', + SETTINGS_RESET: 'settings://reset', }; /** @@ -71,15 +71,15 @@ function createLibraryUpdatedHandler(Alpine) { return (payload) => { const { action, track_ids } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log( `[events] Library ${action}:`, - track_ids.length ? `${track_ids.length} tracks` : "bulk update", + track_ids.length ? `${track_ids.length} tracks` : 'bulk update', ); // Deletions are handled by library:reconcile event with authoritative stats - if (action === "added" || action === "modified") { + if (action === 'added' || action === 'modified') { debouncedFetchTracks(library); } }; @@ -87,7 +87,7 @@ function createLibraryUpdatedHandler(Alpine) { function handleScanProgress(Alpine, payload) { const { job_id, status, scanned, found, errors, current_path } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); if (library.setScanProgress) { library.setScanProgress({ @@ -103,7 +103,7 @@ function handleScanProgress(Alpine, payload) { function handleScanComplete(Alpine, payload) { const { added, skipped, errors, duration_ms } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log( `[events] Scan complete: ${added} added, ${skipped} skipped, ${errors} errors (${duration_ms}ms)`, @@ -119,13 +119,13 @@ function createQueueUpdatedHandler(Alpine) { let queueReloadDebounce = null; return (payload) => { - console.log("[events] queue:updated", payload); + console.log('[events] queue:updated', payload); - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); if (queue?._initializing || queue?._updating) { console.log( - "[events] Skipping queue reload during", - queue._initializing ? "initialization" : "active update", + '[events] Skipping queue reload during', + queue._initializing ? 'initialization' : 'active update', ); return; } @@ -135,7 +135,7 @@ function createQueueUpdatedHandler(Alpine) { } queueReloadDebounce = setTimeout(() => { - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); if (queue && queue.load && !queue._initializing && !queue._updating) { queue.load(); } @@ -144,17 +144,17 @@ function createQueueUpdatedHandler(Alpine) { } function handleQueueStateChanged(Alpine, payload) { - console.log("[events] queue:state-changed", payload); + console.log('[events] queue:state-changed', payload); - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); if (queue && !queue._initializing && !queue._updating) { queue.currentIndex = payload.current_index; queue.shuffle = payload.shuffle_enabled; queue.loop = payload.loop_mode; } else if (queue?._initializing || queue?._updating) { console.log( - "[events] Skipping queue state update during", - queue._initializing ? "initialization" : "active update", + '[events] Skipping queue state update during', + queue._initializing ? 'initialization' : 'active update', ); } } @@ -168,7 +168,7 @@ function handleLibraryReconcile(Alpine, payload) { total_duration, revision, } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log( `[events] Library reconcile (${mutation}): total=${total_tracks}, removed=${removed_ids.length}`, @@ -182,7 +182,7 @@ function handleLibraryReconcile(Alpine, payload) { library._lastRevision = revision; if ( - (mutation === "delete" || mutation === "dedup") && + (mutation === 'delete' || mutation === 'dedup') && removed_ids.length > 0 ) { // Targeted removal: filter IDs from local view, queue cleanup @@ -190,9 +190,9 @@ function handleLibraryReconcile(Alpine, payload) { library._removeFromView(idSet); removeFromQueue(Alpine, idSet); } else if ( - mutation === "scan_complete" || - mutation === "delete" || - mutation === "dedup" + mutation === 'scan_complete' || + mutation === 'delete' || + mutation === 'dedup' ) { // Bulk change: full refetch library.fetchTracks(); @@ -200,8 +200,8 @@ function handleLibraryReconcile(Alpine, payload) { // Refresh if currently viewing an affected section if ( - affected_sections.includes("liked") && - library.currentSection === "liked" + affected_sections.includes('liked') && + library.currentSection === 'liked' ) { library.fetchTracks(); } @@ -209,8 +209,8 @@ function handleLibraryReconcile(Alpine, payload) { function handleFavoritesUpdated(Alpine, payload) { const { action, track_id } = payload; - const library = Alpine.store("library"); - const player = Alpine.store("player"); + const library = Alpine.store('library'); + const player = Alpine.store('player'); console.log(`[events] Favorites ${action}: track ${track_id}`); @@ -219,13 +219,13 @@ function handleFavoritesUpdated(Alpine, payload) { } if (player.currentTrack?.id === track_id) { - player.isFavorite = action === "added"; + player.isFavorite = action === 'added'; } } function handlePlaylistsUpdated(Alpine, payload) { const { action, playlist_id } = payload; - const library = Alpine.store("library"); + const library = Alpine.store('library'); console.log(`[events] Playlists ${action}: playlist ${playlist_id}`); @@ -247,23 +247,23 @@ function handleSettingsChanged(Alpine, payload) { console.log(`[events] Settings changed: ${key} =`, value); - const ui = Alpine.store("ui"); - const player = Alpine.store("player"); + const ui = Alpine.store('ui'); + const player = Alpine.store('player'); switch (key) { - case "volume": - if (player && typeof value === "number") { + case 'volume': + if (player && typeof value === 'number') { player.volume = value; } break; - case "theme": - if (ui && typeof value === "string") { + case 'theme': + if (ui && typeof value === 'string') { ui.theme = value; ui.applyTheme(); } break; - case "sidebar_width": - if (ui && typeof value === "number") { + case 'sidebar_width': + if (ui && typeof value === 'number') { ui.sidebarWidth = value; } break; @@ -279,32 +279,22 @@ function handleSettingsChanged(Alpine, payload) { * @param {object} Alpine - Alpine.js instance */ export async function initEventListeners(Alpine) { - console.log("[events] Initializing Tauri event listeners..."); + console.log('[events] Initializing Tauri event listeners...'); await subscribe(Events.LIBRARY_UPDATED, createLibraryUpdatedHandler(Alpine)); - await subscribe(Events.LIBRARY_RECONCILE, (p) => - handleLibraryReconcile(Alpine, p), - ); + await subscribe(Events.LIBRARY_RECONCILE, (p) => handleLibraryReconcile(Alpine, p)); await subscribe(Events.SCAN_PROGRESS, (p) => handleScanProgress(Alpine, p)); await subscribe(Events.SCAN_COMPLETE, (p) => handleScanComplete(Alpine, p)); await subscribe(Events.QUEUE_UPDATED, createQueueUpdatedHandler(Alpine)); - await subscribe(Events.QUEUE_STATE_CHANGED, (p) => - handleQueueStateChanged(Alpine, p), - ); - await subscribe(Events.FAVORITES_UPDATED, (p) => - handleFavoritesUpdated(Alpine, p), - ); - await subscribe(Events.PLAYLISTS_UPDATED, (p) => - handlePlaylistsUpdated(Alpine, p), - ); - await subscribe(Events.SETTINGS_CHANGED, (p) => - handleSettingsChanged(Alpine, p), - ); + await subscribe(Events.QUEUE_STATE_CHANGED, (p) => handleQueueStateChanged(Alpine, p)); + await subscribe(Events.FAVORITES_UPDATED, (p) => handleFavoritesUpdated(Alpine, p)); + await subscribe(Events.PLAYLISTS_UPDATED, (p) => handlePlaylistsUpdated(Alpine, p)); + await subscribe(Events.SETTINGS_CHANGED, (p) => handleSettingsChanged(Alpine, p)); await subscribe(Events.SETTINGS_RESET, () => { - console.log("[events] Settings reset to defaults"); + console.log('[events] Settings reset to defaults'); }); - console.log("[events] Tauri event listeners initialized"); + console.log('[events] Tauri event listeners initialized'); } /** @@ -312,7 +302,7 @@ export async function initEventListeners(Alpine) { * Call this when the app is closing */ export function cleanupEventListeners() { - console.log("[events] Cleaning up event listeners..."); + console.log('[events] Cleaning up event listeners...'); listeners.forEach((unlisten) => unlisten()); listeners.length = 0; } diff --git a/app/frontend/js/mixins/context-menu-actions.js b/app/frontend/js/mixins/context-menu-actions.js index fbd69f0a..9d95c540 100644 --- a/app/frontend/js/mixins/context-menu-actions.js +++ b/app/frontend/js/mixins/context-menu-actions.js @@ -1,5 +1,5 @@ -import { favorites } from "../api/favorites.js"; -import { playlists } from "../api/playlists.js"; +import { favorites } from '../api/favorites.js'; +import { playlists } from '../api/playlists.js'; /** * Context menu and playback actions mixin for library browser. @@ -22,22 +22,22 @@ export function contextMenuActionsMixin() { // Store track IDs globally for Tauri drop handler workaround window._mtDraggedTrackIds = trackIds; - console.log("[drag-drop]", "dragstart", { + console.log('[drag-drop]', 'dragstart', { trackCount: trackIds.length, trackIds, dataTransferData: trackIdsJson, }); - event.dataTransfer.setData("application/json", trackIdsJson); - event.dataTransfer.effectAllowed = "all"; + event.dataTransfer.setData('application/json', trackIdsJson); + event.dataTransfer.effectAllowed = 'all'; const count = trackIds.length; - const dragEl = document.createElement("div"); + const dragEl = document.createElement('div'); dragEl.className = - "fixed bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium shadow-lg pointer-events-none"; - dragEl.textContent = count === 1 ? "1 track" : `${count} tracks`; - dragEl.style.position = "absolute"; - dragEl.style.top = "-1000px"; + 'fixed bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium shadow-lg pointer-events-none'; + dragEl.textContent = count === 1 ? '1 track' : `${count} tracks`; + dragEl.style.position = 'absolute'; + dragEl.style.top = '-1000px'; document.body.appendChild(dragEl); event.dataTransfer.setDragImage(dragEl, 0, 0); setTimeout(() => dragEl.remove(), 0); @@ -49,10 +49,10 @@ export function contextMenuActionsMixin() { setTimeout(() => { window._mtDragJustEnded = false; window._mtDraggedTrackIds = null; - console.log("[drag-drop]", "dragJustEnded cleared"); + console.log('[drag-drop]', 'dragJustEnded cleared'); }, 1000); - console.log("[drag-drop]", "dragend", { + console.log('[drag-drop]', 'dragend', { dropEffect: event.dataTransfer?.dropEffect, }); }, @@ -74,43 +74,42 @@ export function contextMenuActionsMixin() { } const selectedCount = this.selectedTracks.size; - const trackLabel = - selectedCount === 1 ? "track" : `${selectedCount} tracks`; + const trackLabel = selectedCount === 1 ? 'track' : `${selectedCount} tracks`; const isInPlaylist = this.currentPlaylistId !== null; const menuItems = [ { - label: "Play Now", + label: 'Play Now', action: () => this.playSelected(), }, { label: `Add ${trackLabel} to Queue`, action: () => this.addSelectedToQueue(), }, - { type: "separator" }, + { type: 'separator' }, { - label: "Play Next", + label: 'Play Next', action: () => this.playSelectedNext(), }, { - label: "Add to Playlist", + label: 'Add to Playlist', hasSubmenu: true, action: () => { this.showPlaylistSubmenu = !this.showPlaylistSubmenu; }, }, { - label: "Add to Liked Songs", + label: 'Add to Liked Songs', action: () => this.toggleFavoriteFromMenu(track), }, { - label: "Go to Artist", + label: 'Go to Artist', action: () => this.goToArtist(track), disabled: selectedCount > 1 || (!track.artist && !track.album_artist), }, { - label: "Go to Album", + label: 'Go to Album', action: () => this.goToAlbum(track), disabled: selectedCount > 1 || !track.album, }, @@ -123,39 +122,38 @@ export function contextMenuActionsMixin() { if (!this.contextMenu) return; const favoriteItem = this.contextMenu.items.find( (i) => - i.label === "Add to Liked Songs" || - i.label === "Remove from Liked Songs", + i.label === 'Add to Liked Songs' || + i.label === 'Remove from Liked Songs', ); if (favoriteItem) { favoriteItem.label = result.is_favorite - ? "Remove from Liked Songs" - : "Add to Liked Songs"; + ? 'Remove from Liked Songs' + : 'Add to Liked Songs'; } }) .catch(() => {}); if (isInPlaylist) { - menuItems.push({ type: "separator" }); + menuItems.push({ type: 'separator' }); menuItems.push({ label: `Remove ${trackLabel} from Playlist`, action: () => this.removeFromPlaylist(), }); } - menuItems.push({ type: "separator" }); + menuItems.push({ type: 'separator' }); menuItems.push({ - label: "Show in Finder", + label: 'Show in Finder', action: () => this.showInFinder(track), disabled: selectedCount > 1, }); menuItems.push({ - label: - selectedCount > 1 - ? `Edit Metadata (${selectedCount} tracks)...` - : "Edit Metadata...", + label: selectedCount > 1 + ? `Edit Metadata (${selectedCount} tracks)...` + : 'Edit Metadata...', action: () => this.editMetadata(track), }); - menuItems.push({ type: "separator" }); + menuItems.push({ type: 'separator' }); menuItems.push({ label: `Remove ${trackLabel} from Library`, action: () => this.removeSelected(), @@ -182,14 +180,11 @@ export function contextMenuActionsMixin() { items: menuItems, }; this.showPlaylistSubmenu = false; - this.submenuOnLeft = - x + menuWidth + 45 + submenuWidth > window.innerWidth; + this.submenuOnLeft = x + menuWidth + 45 + submenuWidth > window.innerWidth; }, getSelectedTracks() { - return this.library.filteredTracks.filter((t) => - this.selectedTracks.has(t.id), - ); + return this.library.filteredTracks.filter((t) => this.selectedTracks.has(t.id)); }, async playSelected() { @@ -205,15 +200,15 @@ export function contextMenuActionsMixin() { async addSelectedToQueue() { const tracks = this.getSelectedTracks(); if (tracks.length > 0) { - console.log("[context-menu]", "add_to_queue", { + console.log('[context-menu]', 'add_to_queue', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), }); await this.queue.addTracks(tracks); this.$store.ui.toast( - `Added ${tracks.length} track${tracks.length > 1 ? "s" : ""} to queue`, - "success", + `Added ${tracks.length} track${tracks.length > 1 ? 's' : ''} to queue`, + 'success', ); } this.contextMenu = null; @@ -222,15 +217,15 @@ export function contextMenuActionsMixin() { async playSelectedNext() { const tracks = this.getSelectedTracks(); if (tracks.length > 0) { - console.log("[context-menu]", "play_next", { + console.log('[context-menu]', 'play_next', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), }); await this.queue.playNextTracks(tracks); this.$store.ui.toast( - `Playing ${tracks.length} track${tracks.length > 1 ? "s" : ""} next`, - "success", + `Playing ${tracks.length} track${tracks.length > 1 ? 's' : ''} next`, + 'success', ); } this.contextMenu = null; @@ -240,7 +235,7 @@ export function contextMenuActionsMixin() { const tracks = this.getSelectedTracks(); if (tracks.length === 0) return; - console.log("[context-menu]", "add_to_playlist", { + console.log('[context-menu]', 'add_to_playlist', { playlistId, trackCount: tracks.length, trackIds: tracks.map((t) => t.id), @@ -250,27 +245,27 @@ export function contextMenuActionsMixin() { const trackIds = tracks.map((t) => t.id); const result = await playlists.addTracks(playlistId, trackIds); const playlist = this.playlists.find((p) => p.id === playlistId); - const playlistName = playlist?.name || "playlist"; + const playlistName = playlist?.name || 'playlist'; if (result.added > 0) { this.$store.ui.toast( - `Added ${result.added} track${result.added > 1 ? "s" : ""} to "${playlistName}"`, - "success", + `Added ${result.added} track${result.added > 1 ? 's' : ''} to "${playlistName}"`, + 'success', ); } else { this.$store.ui.toast( - `Track${tracks.length > 1 ? "s" : ""} already in "${playlistName}"`, - "info", + `Track${tracks.length > 1 ? 's' : ''} already in "${playlistName}"`, + 'info', ); } - window.dispatchEvent(new CustomEvent("mt:playlists-updated")); + window.dispatchEvent(new CustomEvent('mt:playlists-updated')); } catch (error) { - console.error("[context-menu]", "add_to_playlist_error", { + console.error('[context-menu]', 'add_to_playlist_error', { playlistId, error: error.message, }); - this.$store.ui.toast("Failed to add to playlist", "error"); + this.$store.ui.toast('Failed to add to playlist', 'error'); } this.contextMenu = null; @@ -292,11 +287,11 @@ export function contextMenuActionsMixin() { } this.library.refreshIfLikedSongs(); } catch (error) { - console.error("[context-menu]", "toggle_favorite_error", { + console.error('[context-menu]', 'toggle_favorite_error', { trackId: track.id, error: error.message, }); - this.$store.ui.toast("Failed to update liked songs", "error"); + this.$store.ui.toast('Failed to update liked songs', 'error'); } }, @@ -306,7 +301,7 @@ export function contextMenuActionsMixin() { // Dispatch event - albums-browser handles view switch and album opening window.dispatchEvent( - new CustomEvent("mt:navigate-to-album", { + new CustomEvent('mt:navigate-to-album', { detail: { album: track.album, albumArtist: track.album_artist || track.artist, @@ -322,7 +317,7 @@ export function contextMenuActionsMixin() { // Dispatch event - artists-browser handles view switch and artist selection window.dispatchEvent( - new CustomEvent("mt:navigate-to-artist", { + new CustomEvent('mt:navigate-to-artist', { detail: { artist }, }), ); @@ -336,7 +331,7 @@ export function contextMenuActionsMixin() { const trackIds = tracks.map((t) => t.id); window.dispatchEvent( - new CustomEvent("mt:create-playlist-with-tracks", { + new CustomEvent('mt:create-playlist-with-tracks', { detail: { trackIds }, }), ); @@ -364,8 +359,8 @@ export function contextMenuActionsMixin() { } this.$store.ui.toast( - `Removed ${tracks.length} track${tracks.length > 1 ? "s" : ""} from playlist`, - "success", + `Removed ${tracks.length} track${tracks.length > 1 ? 's' : ''} from playlist`, + 'success', ); const playlist = await playlists.get(this.currentPlaylistId); @@ -379,10 +374,10 @@ export function contextMenuActionsMixin() { this.library.applyFilters(); this.clearSelection(); - window.dispatchEvent(new CustomEvent("mt:playlists-updated")); + window.dispatchEvent(new CustomEvent('mt:playlists-updated')); } catch (error) { - console.error("Failed to remove from playlist:", error); - this.$store.ui.toast("Failed to remove from playlist", "error"); + console.error('Failed to remove from playlist:', error); + this.$store.ui.toast('Failed to remove from playlist', 'error'); } this.contextMenu = null; @@ -392,15 +387,15 @@ export function contextMenuActionsMixin() { const trackPath = track?.filepath || track?.path; if (!trackPath) { console.error( - "Cannot show in folder: track has no filepath/path", + 'Cannot show in folder: track has no filepath/path', track, ); - this.$store.ui.toast("Cannot locate file", "error"); + this.$store.ui.toast('Cannot locate file', 'error'); this.contextMenu = null; return; } - console.log("[context-menu]", "show_in_finder", { + console.log('[context-menu]', 'show_in_finder', { trackId: track.id, trackTitle: track.title, trackPath, @@ -408,17 +403,17 @@ export function contextMenuActionsMixin() { try { if (window.__TAURI__) { - const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); + const { revealItemInDir } = await import('@tauri-apps/plugin-opener'); await revealItemInDir(trackPath); } else { - console.log("Show in folder (browser mode):", trackPath); + console.log('Show in folder (browser mode):', trackPath); } } catch (error) { - console.error("[context-menu]", "show_in_finder_error", { + console.error('[context-menu]', 'show_in_finder_error', { trackId: track.id, error: error.message, }); - this.$store.ui.toast("Failed to open folder", "error"); + this.$store.ui.toast('Failed to open folder', 'error'); } this.contextMenu = null; }, @@ -429,14 +424,14 @@ export function contextMenuActionsMixin() { tracks.push(track); } - console.log("[context-menu]", "edit_metadata", { + console.log('[context-menu]', 'edit_metadata', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), anchorTrackId: track.id, }); this.contextMenu = null; - this.$store.ui.openModal("editMetadata", { + this.$store.ui.openModal('editMetadata', { tracks, library: this.library, anchorTrackId: track.id, @@ -447,23 +442,21 @@ export function contextMenuActionsMixin() { const tracks = this.getSelectedTracks(); if (tracks.length === 0) return; - console.log("[context-menu]", "remove_from_library", { + console.log('[context-menu]', 'remove_from_library', { trackCount: tracks.length, trackIds: tracks.map((t) => t.id), }); - const confirmMsg = - tracks.length === 1 - ? `Remove "${tracks[0].title}" from library?` - : `Remove ${tracks.length} tracks from library?`; + const confirmMsg = tracks.length === 1 + ? `Remove "${tracks[0].title}" from library?` + : `Remove ${tracks.length} tracks from library?`; this.contextMenu = null; - const confirmed = - (await window.__TAURI__?.dialog?.confirm(confirmMsg, { - title: "Remove from Library", - kind: "warning", - })) ?? window.confirm(confirmMsg); + const confirmed = (await window.__TAURI__?.dialog?.confirm(confirmMsg, { + title: 'Remove from Library', + kind: 'warning', + })) ?? window.confirm(confirmMsg); if (confirmed) { const trackIds = tracks.map((t) => t.id); @@ -475,8 +468,8 @@ export function contextMenuActionsMixin() { this.library._removeFromQueue(idSet); this.selectedTracks.clear(); this.$store.ui.toast( - `Removed ${tracks.length} track${tracks.length > 1 ? "s" : ""}`, - "success", + `Removed ${tracks.length} track${tracks.length > 1 ? 's' : ''}`, + 'success', ); const { invoke } = window.__TAURI__.core; @@ -484,28 +477,26 @@ export function contextMenuActionsMixin() { try { if (isDeletingAll) { // Remove watched folders first so watcher can't re-add tracks - const folders = await invoke("watched_folders_list"); + const folders = await invoke('watched_folders_list'); await Promise.allSettled( - (folders || []).map((f) => - invoke("watched_folders_remove", { id: f.id }), - ), + (folders || []).map((f) => invoke('watched_folders_remove', { id: f.id })), ); // Single SQL wipe of library, favorites, playlist_items - await invoke("library_delete_all"); + await invoke('library_delete_all'); console.log( - "[library-browser] Deleted all tracks and removed watched folders", + '[library-browser] Deleted all tracks and removed watched folders', ); } else { // Batch delete by IDs in a single IPC call - await invoke("library_delete_tracks", { trackIds }); + await invoke('library_delete_tracks', { trackIds }); console.log( - "[library-browser] Batch deleted", + '[library-browser] Batch deleted', trackIds.length, - "tracks", + 'tracks', ); } } catch (err) { - console.error("[library-browser] Delete failed:", err); + console.error('[library-browser] Delete failed:', err); this.library.fetchTracks(); } } diff --git a/app/frontend/js/stores/library.js b/app/frontend/js/stores/library.js index 1c1d7038..eaa15fe8 100644 --- a/app/frontend/js/stores/library.js +++ b/app/frontend/js/stores/library.js @@ -9,12 +9,12 @@ * recent, playlists) load all tracks in a single fetch. */ -import { library as libraryApi } from "../api/library.js"; +import { library as libraryApi } from '../api/library.js'; import { buildCacheEntry, createCacheSaver, loadCacheFromSettings, -} from "../utils/library-cache.js"; +} from '../utils/library-cache.js'; import { applySectionData, backgroundRefreshLibrary, @@ -26,7 +26,7 @@ import { removeFromQueue, removeTracksLocallyOp, scanPaths, -} from "../utils/library-operations.js"; +} from '../utils/library-operations.js'; const { listen } = window.__TAURI__?.event ?? { listen: () => Promise.resolve(() => {}), @@ -36,11 +36,11 @@ const { listen } = window.__TAURI__?.event ?? { export { applySectionData }; export function createLibraryStore(Alpine) { - Alpine.store("library", { + Alpine.store('library', { // Search and filter state - searchQuery: "", - sortBy: "default", - sortOrder: "asc", + searchQuery: '', + sortBy: 'default', + sortOrder: 'asc', currentSection: getInitialSection(), // Loading state @@ -88,7 +88,7 @@ export function createLibraryStore(Alpine) { this.totalTracks = cached.totalTracks; this.totalDuration = cached.totalDuration; this._lastLoadedSection = this.currentSection; - console.log("[library] showing cached summary on init:", { + console.log('[library] showing cached summary on init:', { section: this.currentSection, totalTracks: cached.totalTracks, }); @@ -101,10 +101,10 @@ export function createLibraryStore(Alpine) { async _setupWatchedFolderListener() { this._watchedFolderListener = await listen( - "watched-folder:results", + 'watched-folder:results', (event) => { const { added, updated, deleted } = event.payload || {}; - console.log("[library] watched-folder:results", { + console.log('[library] watched-folder:results', { added, updated, deleted, @@ -112,7 +112,7 @@ export function createLibraryStore(Alpine) { if (added > 0 || updated > 0 || deleted > 0) { console.log( - "[library] Reloading library after watched folder scan", + '[library] Reloading library after watched folder scan', ); this._clearCache(); this.load({ forceReload: true }); @@ -150,10 +150,10 @@ export function createLibraryStore(Alpine) { _clearCache(section = null) { if (section) { delete this._sectionCache[section]; - console.log("[library] cache cleared for section:", section); + console.log('[library] cache cleared for section:', section); } else { this._sectionCache = {}; - console.log("[library] cache cleared (all sections)"); + console.log('[library] cache cleared (all sections)'); } this._persistCache(); }, @@ -188,21 +188,19 @@ export function createLibraryStore(Alpine) { try { const sortKeyMap = { - default: "artist", - index: "track_number", - dateAdded: "added_date", - lastPlayed: "last_played", - playCount: "play_count", - year: "date", - genre: "genre", - trackTotal: "track_total", - discNumber: "disc_number", + default: 'artist', + index: 'track_number', + dateAdded: 'added_date', + lastPlayed: 'last_played', + playCount: 'play_count', + year: 'date', + genre: 'genre', + trackTotal: 'track_total', + discNumber: 'disc_number', }; - const uiStore = Alpine.store("ui"); - const ignoreWords = uiStore.sortIgnoreWords - ? uiStore.sortIgnoreWordsList - : null; + const uiStore = Alpine.store('ui'); + const ignoreWords = uiStore.sortIgnoreWords ? uiStore.sortIgnoreWordsList : null; const data = await libraryApi.getTracks({ search: this.searchQuery.trim() || null, @@ -226,13 +224,13 @@ export function createLibraryStore(Alpine) { // Trigger Alpine reactivity by incrementing version this._dataVersion++; - console.log("[library] page loaded:", { + console.log('[library] page loaded:', { pageIndex, trackCount: tracks.length, totalPages: Math.ceil(this.totalTracks / this._pageSize), }); } catch (error) { - console.error("[library] page fetch failed:", { + console.error('[library] page fetch failed:', { pageIndex, error: error.message, }); @@ -326,24 +324,22 @@ export function createLibraryStore(Alpine) { _getSortParams() { const sortKeyMap = { - default: "artist", - index: "track_number", - dateAdded: "added_date", - lastPlayed: "last_played", - playCount: "play_count", - year: "date", - genre: "genre", - trackTotal: "track_total", - discNumber: "disc_number", + default: 'artist', + index: 'track_number', + dateAdded: 'added_date', + lastPlayed: 'last_played', + playCount: 'play_count', + year: 'date', + genre: 'genre', + trackTotal: 'track_total', + discNumber: 'disc_number', }; - const uiStore = Alpine.store("ui"); + const uiStore = Alpine.store('ui'); return { search: this.searchQuery.trim() || null, sort: sortKeyMap[this.sortBy] || this.sortBy, order: this.sortOrder, - ignoreWords: uiStore.sortIgnoreWords - ? uiStore.sortIgnoreWordsList - : null, + ignoreWords: uiStore.sortIgnoreWords ? uiStore.sortIgnoreWordsList : null, }; }, @@ -383,41 +379,40 @@ export function createLibraryStore(Alpine) { }, loadFavorites() { - return this._loadSection("liked", null); + return this._loadSection('liked', null); }, _backgroundRefreshFavorites() { // Preserve original matching: currentSection === 'liked' OR _lastLoadedSection === 'liked' - const section = - this.currentSection === "liked" || this._lastLoadedSection === "liked" - ? "liked" - : null; + const section = this.currentSection === 'liked' || this._lastLoadedSection === 'liked' + ? 'liked' + : null; if (!section) return; - return this._backgroundRefreshSection("liked", null); + return this._backgroundRefreshSection('liked', null); }, loadRecentlyPlayed(days = 14) { - return this._loadSection("recent", null, { days }); + return this._loadSection('recent', null, { days }); }, _backgroundRefreshRecentlyPlayed(days = 14) { - return this._backgroundRefreshSection("recent", null, { days }); + return this._backgroundRefreshSection('recent', null, { days }); }, loadRecentlyAdded(days = 14) { - return this._loadSection("added", null, { days }); + return this._loadSection('added', null, { days }); }, _backgroundRefreshRecentlyAdded(days = 14) { - return this._backgroundRefreshSection("added", null, { days }); + return this._backgroundRefreshSection('added', null, { days }); }, loadTop25() { - return this._loadSection("top25", null); + return this._loadSection('top25', null); }, _backgroundRefreshTop25() { - return this._backgroundRefreshSection("top25", null); + return this._backgroundRefreshSection('top25', null); }, loadPlaylist(playlistId) { @@ -432,19 +427,19 @@ export function createLibraryStore(Alpine) { }; this._persistCache(); - console.log("[navigation]", "load_playlist_complete", { + console.log('[navigation]', 'load_playlist_complete', { playlistId, trackCount: this.filteredTracks.length, }); return data; }; - console.log("[navigation]", "load_playlist", { playlistId }); + console.log('[navigation]', 'load_playlist', { playlistId }); // The unified endpoint returns flat Track objects for playlists return this._loadSection(section, null, { onSuccess: cachePlaylist, - logTag: "navigation", + logTag: 'navigation', }); }, @@ -469,19 +464,19 @@ export function createLibraryStore(Alpine) { // ----------------------------------------------------------------------- setSection(section) { - console.log("[navigation]", "switch_section", { + console.log('[navigation]', 'switch_section', { previousSection: this.currentSection, newSection: section, }); this.currentSection = section; window.dispatchEvent( - new CustomEvent("mt:section-change", { detail: { section } }), + new CustomEvent('mt:section-change', { detail: { section } }), ); }, refreshIfLikedSongs() { - if (this.currentSection === "liked") { + if (this.currentSection === 'liked') { this.loadFavorites(); } }, @@ -505,13 +500,13 @@ export function createLibraryStore(Alpine) { }, setSortBy(field) { - console.log("[library]", "setSortBy", { field }); + console.log('[library]', 'setSortBy', { field }); if (this.sortBy === field) { - this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc"; + this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; } else { this.sortBy = field; - this.sortOrder = "asc"; + this.sortOrder = 'asc'; } this.load({ forceReload: true }); @@ -566,7 +561,7 @@ export function createLibraryStore(Alpine) { this._removeFromQueue(new Set([trackId])); await libraryApi.deleteTrack(trackId); } catch (error) { - console.error("Failed to remove track:", error); + console.error('Failed to remove track:', error); // Rollback: re-fetch from backend this.fetchTracks(); throw error; @@ -591,7 +586,7 @@ export function createLibraryStore(Alpine) { try { return await libraryApi.getTrack(trackId); } catch (error) { - console.error("[library] getTrackAsync failed:", error); + console.error('[library] getTrackAsync failed:', error); return null; } }, @@ -615,7 +610,7 @@ export function createLibraryStore(Alpine) { return offset; } catch (error) { - console.error("[library] _jumpToPrefix failed:", error); + console.error('[library] _jumpToPrefix failed:', error); return null; } }, @@ -625,7 +620,7 @@ export function createLibraryStore(Alpine) { // ----------------------------------------------------------------------- async addToQueue(track, playNow = false) { - await Alpine.store("queue").add(track, playNow); + await Alpine.store('queue').add(track, playNow); }, async addAllToQueue(playNow = false) { @@ -633,11 +628,11 @@ export function createLibraryStore(Alpine) { if (this._isPaginated() && !this._allPagesLoaded) { await this._loadAllPages(); } - await Alpine.store("queue").add(this.filteredTracks, playNow); + await Alpine.store('queue').add(this.filteredTracks, playNow); }, async playNow(track) { - const queue = Alpine.store("queue"); + const queue = Alpine.store('queue'); await queue.clear(); await queue.add(track, true); }, @@ -697,7 +692,7 @@ export function createLibraryStore(Alpine) { } } } catch (error) { - console.error("[library] Failed to rescan track:", error); + console.error('[library] Failed to rescan track:', error); } }, @@ -712,7 +707,7 @@ export function createLibraryStore(Alpine) { this.scanProgress = Math.min(99, scanned); } - console.log("[library] scan progress:", { + console.log('[library] scan progress:', { jobId, status, scanned, diff --git a/app/frontend/js/stores/queue.js b/app/frontend/js/stores/queue.js index e5b908cd..c7dcc6af 100644 --- a/app/frontend/js/stores/queue.js +++ b/app/frontend/js/stores/queue.js @@ -4,6 +4,12 @@ * The queue maintains tracks in PLAY ORDER - the order shown in the Now Playing * view is always the order tracks will be played. When shuffle is enabled, * the items array is physically reordered. + * + * State machine logic (shuffle, navigation, play-next, history, integrity) + * lives in the Rust backend. This store is a thin reactive layer that: + * 1. Calls backend commands for state transitions + * 2. Applies returned state snapshots + * 3. Exposes computed UI properties */ import { queue as queueApi } from '../api/queue.js'; @@ -19,28 +25,15 @@ export function createQueueStore(Alpine) { loop: 'none', // 'none', 'all', 'one' stopAfterCurrent: false, // Stop playback when current track ends - // Repeat-one "play once more" state - _repeatOnePending: false, - // Loading state loading: false, - // Original order preserved for unshuffle - _originalOrder: [], - - // Play history for prev button navigation - _playHistory: [], - _maxHistorySize: 100, - // Flag to prevent event listener from overriding during initialization _initializing: false, // Flag to prevent event listener from overriding during queue operations _updating: false, - // Track IDs added via "Play Next" - these are pinned after the current track during shuffle - _playNextTrackIds: new Set(), - /** * Initialize queue from backend */ @@ -66,10 +59,6 @@ export function createQueueStore(Alpine) { this.currentIndex = -1; this.shuffle = false; this.loop = 'none'; - this._originalOrder = [...this.items]; - this._repeatOnePending = false; - this._playHistory = []; - this._playNextTrackIds = new Set(); // Persist the reset state to backend try { @@ -88,9 +77,6 @@ export function createQueueStore(Alpine) { const rawItems = data.items || []; this.items = rawItems.map((item) => item.track || item); this.currentIndex = data.currentIndex ?? -1; - if (!this.shuffle) { - this._originalOrder = [...this.items]; - } } catch (error) { console.error('Failed to load queue:', error); } finally { @@ -132,9 +118,6 @@ export function createQueueStore(Alpine) { const data = await queueApi.get(); const rawItems = data.items || []; this.items = rawItems.map((item) => item.track || item); - if (!this.shuffle) { - this._originalOrder = [...this.items]; - } // Restore currentIndex by finding the currently playing track if (currentTrackId !== null) { @@ -156,35 +139,32 @@ export function createQueueStore(Alpine) { }, /** - * Save queue state to backend + * Apply a state snapshot from the backend + * @param {Object} snapshot - QueueStateSnapshot from backend */ - async save() { - try { - await queueApi.save({ - items: this.items, - currentIndex: this.currentIndex, - shuffle: this.shuffle, - loop: this.loop, - }); - } catch (error) { - console.error('Failed to save queue:', error); - } + _applySnapshot(snapshot) { + if (!snapshot) return; + this.items = (snapshot.items || []).map((item) => item.track || item); + this.currentIndex = snapshot.current_index ?? this.currentIndex; + this.shuffle = snapshot.shuffle_enabled ?? this.shuffle; + this.loop = snapshot.loop_mode ?? this.loop; }, /** - * Sync full queue state to backend (clear and rebuild) - * Used when queue order changes in ways that can't be expressed as incremental operations + * Apply a navigation result from the backend + * @param {Object} result - QueueNavigationResult from backend + * @returns {string} The action taken: 'play', 'stop', or 'seek_zero' */ - async _syncQueueToBackend() { - try { - await queueApi.clear(); - if (this.items.length > 0) { - const trackIds = this.items.map((t) => t.id); - await queueApi.add(trackIds); - } - } catch (error) { - console.error('[queue] Failed to sync to backend:', error); + _applyNavigationResult(result) { + if (!result) return 'stop'; + + this._applySnapshot(result.snapshot); + + if (result.action === 'play' && result.track) { + Alpine.store('player').updateTrackState(result.track, result.duration_ms); } + + return result.action; }, /** @@ -205,7 +185,6 @@ export function createQueueStore(Alpine) { // Update local state this.items.push(...tracksArray); - this._originalOrder.push(...tracksArray); // Persist to backend try { @@ -272,54 +251,31 @@ export function createQueueStore(Alpine) { }, /** - * Insert tracks to play next (after currently playing track) + * Insert tracks to play next (after currently playing track). + * Backend handles move semantics, offset tracking, and play-next ID management. * @param {Array|Object} tracks - Track(s) to insert */ async playNextTracks(tracks) { const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; if (tracksArray.length === 0) return; - // Move semantics: remove existing copies from queue before re-inserting at play-next position. - // This handles the case where the full library is in the queue and the user - // wants to move an existing track to play next. - // Skip tracks that are currently playing (can't move the current track). - const currentTrackId = this.currentIndex >= 0 ? this.items[this.currentIndex]?.id : null; - const tracksToInsert = []; - for (const t of tracksArray) { - if (t.id === currentTrackId) continue; // skip currently playing track - const existingIdx = this.items.findIndex((item) => item.id === t.id); - if (existingIdx >= 0) { - this.items.splice(existingIdx, 1); - const origIdx = this._originalOrder.findIndex((item) => item.id === t.id); - if (origIdx >= 0) this._originalOrder.splice(origIdx, 1); - if (existingIdx < this.currentIndex) { - this.currentIndex--; - } - } - tracksToInsert.push(t); - } - if (tracksToInsert.length === 0) return; - - // Append after any previously queued-next tracks (not before them) - if (!this._playNextOffset) this._playNextOffset = 0; - const insertIndex = (this.currentIndex >= 0 ? this.currentIndex + 1 : 0) + - this._playNextOffset; - console.log('[queue]', 'play_next_tracks', { - count: tracksToInsert.length, - trackIds: tracksToInsert.map((t) => t.id), - insertIndex, - playNextOffset: this._playNextOffset, + count: tracksArray.length, + trackIds: tracksArray.map((t) => t.id), }); - this._playNextOffset += tracksToInsert.length; - - // Track these as pinned play-next tracks (preserved during shuffle) - for (const t of tracksToInsert) { - this._playNextTrackIds.add(t.id); + this._updating = true; + try { + const trackIds = tracksArray.map((t) => t.id); + const snapshot = await queueApi.addPlayNext(trackIds); + this._applySnapshot(snapshot); + } catch (error) { + console.error('[queue] Failed to add play-next:', error); + } finally { + setTimeout(() => { + this._updating = false; + }, 50); } - - await this.insert(insertIndex, tracksToInsert); }, /** @@ -345,15 +301,6 @@ export function createQueueStore(Alpine) { // Update local state this.items.splice(index, 1); - // Also remove from _originalOrder so unshuffle stays consistent - if (removedTrack) { - const origIdx = this._originalOrder.findIndex((t) => t.id === removedTrack.id); - if (origIdx >= 0) { - this._originalOrder.splice(origIdx, 1); - } - this._playNextTrackIds.delete(removedTrack.id); - } - // Adjust current index if (index < this.currentIndex) { this.currentIndex--; @@ -390,9 +337,6 @@ export function createQueueStore(Alpine) { // Update local state this.items = []; this.currentIndex = -1; - this._originalOrder = []; - this._playNextTrackIds = new Set(); - // _playHistory intentionally preserved - persists across queue rebuilds Alpine.store('player')?.stop(); @@ -447,75 +391,42 @@ export function createQueueStore(Alpine) { /** * Play track at specific index * @param {number} index - Index to play - * @param {boolean} fromNavigation - If true, this is from playNext/playPrevious and history shouldn't be cleared + * @param {boolean} fromNavigation - If true, this is from backend navigation (history already handled) */ - async playIndex(index, fromNavigation = false) { + async playIndex(index, _fromNavigation = false) { if (index < 0 || index >= this.items.length) return; - // Push current track to history on manual jumps (not from prev/next navigation) - if (!fromNavigation && this.currentIndex >= 0 && this.currentIndex !== index) { - this._pushToHistory(this.currentIndex); - } - - // Reset play-next insertion offset when track changes - this._playNextOffset = 0; - this.currentIndex = index; const track = this.items[index]; - // If this was a play-next track, it's been consumed - this._playNextTrackIds.delete(track.id); - await Alpine.store('player').playTrack(track); await queueApi.setCurrentIndex(this.currentIndex); }, + /** + * Play next track. Backend handles repeat-one two-phase, loop modes, + * history push, reshuffle on loop restart, and audio playback. + */ async playNext() { if (this.items.length === 0) return; - // Stop after current track if flag is set + // Stop after current track if flag is set (frontend-only concern) if (this.stopAfterCurrent) { this.stopAfterCurrent = false; Alpine.store('player').isPlaying = false; return; } - // Prevent QUEUE_STATE_CHANGED event from overwriting state during playNext this._updating = true; try { - if (this._repeatOnePending) { - // Second call after repeat-one replay — clear flag and advance normally - this._repeatOnePending = false; - } else if (this.loop === 'one') { - // First call with loop-one — replay track and untoggle icon immediately - this._repeatOnePending = true; - this.loop = 'none'; - await queueApi.setLoop(this.loop); - await this.playIndex(this.currentIndex, true); - return; - } + const result = await queueApi.playNextTrack(); + const action = this._applyNavigationResult(result); - // Push current track to history before advancing - if (this.currentIndex >= 0) { - this._pushToHistory(this.currentIndex); + if (action === 'stop') { + Alpine.store('player').isPlaying = false; } - - let nextIndex = this.currentIndex + 1; - - if (nextIndex >= this.items.length) { - if (this.loop === 'all') { - if (this.shuffle) { - // Use special reshuffle that puts just-played track at END (task-222) - this._reshuffleForLoopRestart(); - } - nextIndex = 0; - } else { - Alpine.store('player').isPlaying = false; - return; - } - } - - await this.playIndex(nextIndex, true); + } catch (error) { + console.error('[queue] playNext failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -523,54 +434,47 @@ export function createQueueStore(Alpine) { } }, + /** + * Play previous track. Backend handles >3sec restart, history pop, + * fallback decrement, and loop wraparound. + */ async playPrevious() { if (this.items.length === 0) return; - const player = Alpine.store('player'); - - // If > 3 seconds into track, restart current track instead - if (player.currentTime > 3000) { - await player.seek(0); - return; - } - - // Try to use play history first (tracks matched by ID, survives queue rebuilds) - if (this._playHistory.length > 0) { - const historyIndex = this._popFromHistory(); - if (historyIndex >= 0) { - await this.playIndex(historyIndex, true); - return; - } - } - - // Fallback: navigate backward in queue array - let prevIndex = this.currentIndex - 1; + this._updating = true; + try { + const player = Alpine.store('player'); + const currentTimeMs = player.currentTime || 0; + const result = await queueApi.playPreviousTrack(currentTimeMs); + const action = this._applyNavigationResult(result); - if (prevIndex < 0) { - if (this.loop === 'all') { - prevIndex = this.items.length - 1; - } else { - prevIndex = 0; + if (action === 'seek_zero') { + await player.seek(0); } + } catch (error) { + console.error('[queue] playPrevious failed:', error); + } finally { + setTimeout(() => { + this._updating = false; + }, 50); } - - await this.playIndex(prevIndex, true); }, /** - * Manual skip to next track (user-initiated) - * If in repeat-one mode, reverts to 'all' and skips + * Manual skip to next track (user-initiated). + * Backend overrides repeat-one mode before advancing. */ async skipNext() { - if (this.loop === 'one') { - this.loop = 'all'; - this._repeatOnePending = false; - // Loop state is session-only, no persistence needed - } - // Prevent QUEUE_STATE_CHANGED event from overwriting state during skip this._updating = true; try { - await this._doSkipNext(); + const result = await queueApi.skipNext(); + const action = this._applyNavigationResult(result); + + if (action === 'stop') { + Alpine.store('player').isPlaying = false; + } + } catch (error) { + console.error('[queue] skipNext failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -579,19 +483,22 @@ export function createQueueStore(Alpine) { }, /** - * Manual skip to previous track (user-initiated) - * If in repeat-one mode, reverts to 'all' and skips + * Manual skip to previous track (user-initiated). + * Backend overrides repeat-one mode before going back. */ async skipPrevious() { - if (this.loop === 'one') { - this.loop = 'all'; - this._repeatOnePending = false; - // Loop state is session-only, no persistence needed - } - // Prevent QUEUE_STATE_CHANGED event from overwriting state during skip this._updating = true; try { - await this.playPrevious(); + const player = Alpine.store('player'); + const currentTimeMs = player.currentTime || 0; + const result = await queueApi.skipPrevious(currentTimeMs); + const action = this._applyNavigationResult(result); + + if (action === 'seek_zero') { + await player.seek(0); + } + } catch (error) { + console.error('[queue] skipPrevious failed:', error); } finally { setTimeout(() => { this._updating = false; @@ -599,52 +506,20 @@ export function createQueueStore(Alpine) { } }, - async _doSkipNext() { - if (this.items.length === 0) return; - - // Push current track to history before advancing (preserves prev button navigation) - if (this.currentIndex >= 0) { - this._pushToHistory(this.currentIndex); - } - - let nextIndex = this.currentIndex + 1; - if (nextIndex >= this.items.length) { - nextIndex = 0; - } - - await this.playIndex(nextIndex, true); // fromNavigation=true preserves history - }, - + /** + * Toggle shuffle on/off. Backend handles Fisher-Yates, original order + * save/restore, play-next pinning, and current-track-at-index-0. + */ async toggleShuffle() { - // CRITICAL: Prevent QUEUE_STATE_CHANGED event from overwriting state during operation this._updating = true; - + const prev = this.shuffle; + this.shuffle = !prev; try { - this.shuffle = !this.shuffle; - - if (this.shuffle) { - // Save original order before shuffling - this._originalOrder = [...this.items]; - this._shuffleItems(); - // Don't clear history - user can still go back to previously played tracks - // Don't call setCurrentIndex - same track is playing, just at index 0 now - } else { - // Restore original order - const currentTrack = this.items[this.currentIndex]; - this.items = [...this._originalOrder]; - this.currentIndex = this.items.findIndex((t) => t.id === currentTrack?.id); - if (this.currentIndex < 0) { - this.currentIndex = this.items.length > 0 ? 0 : -1; - } - // History preserved - track objects are matched by ID, survives reorder - } - - // Persist shuffle state to backend - await queueApi.setShuffle(this.shuffle); - - // Sync queue order to backend, then set currentIndex AFTER so clear() doesn't leave stale state - await this._syncQueueToBackend(); - await queueApi.setCurrentIndex(this.currentIndex); + const snapshot = await queueApi.setShuffle(this.shuffle); + this._applySnapshot(snapshot); + } catch (error) { + console.error('[queue] toggleShuffle failed:', error); + this.shuffle = prev; } finally { setTimeout(() => { this._updating = false; @@ -652,110 +527,6 @@ export function createQueueStore(Alpine) { } }, - _shuffleItems() { - if (this.items.length < 2) return; - - const currentTrack = this.currentIndex >= 0 ? this.items[this.currentIndex] : null; - - // Separate play-next tracks (pinned) from regular tracks - const pinnedTracks = []; - const regularTracks = []; - - for (let i = 0; i < this.items.length; i++) { - if (i === this.currentIndex) continue; - if (this._playNextTrackIds.has(this.items[i].id)) { - pinnedTracks.push(this.items[i]); - } else { - regularTracks.push(this.items[i]); - } - } - - // Fisher-Yates shuffle only the regular tracks - for (let i = regularTracks.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [regularTracks[i], regularTracks[j]] = [regularTracks[j], regularTracks[i]]; - } - - if (currentTrack) { - // Current track at 0, pinned play-next tracks next, then shuffled rest (task-213) - this.items = [currentTrack, ...pinnedTracks, ...regularTracks]; - this.currentIndex = 0; - } else { - this.items = [...pinnedTracks, ...regularTracks]; - } - - this._validateQueueIntegrity(); - }, - - /** - * Reshuffle for loop restart - ensures just-played track is NOT at index 0 (task-222) - * Called when loop=all and reaching end of queue with shuffle enabled. - */ - _reshuffleForLoopRestart() { - if (this.items.length < 2) return; - - const justPlayedTrack = this.items[this.currentIndex]; - const otherTracks = this.items.filter((_, i) => i !== this.currentIndex); - - // Fisher-Yates shuffle of other tracks - for (let i = otherTracks.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [otherTracks[i], otherTracks[j]] = [otherTracks[j], otherTracks[i]]; - } - - // Put just-played track at END (plays last in next cycle) - this.items = [...otherTracks, justPlayedTrack]; - this._originalOrder = [...this.items]; - - this._validateQueueIntegrity(); - }, - - /** - * Validate queue integrity - check for duplicate track IDs - */ - _validateQueueIntegrity() { - const ids = this.items.map((t) => t.id); - const uniqueIds = new Set(ids); - if (uniqueIds.size !== ids.length) { - console.error('[queue] Duplicate tracks detected!', { - total: ids.length, - unique: uniqueIds.size, - }); - return false; - } - return true; - }, - - /** - * Push track at index to play history (stores track object, not index) - * @param {number} index - Index of track to push to history - */ - _pushToHistory(index) { - const track = this.items[index]; - if (!track) return; - this._playHistory.push(track); - - // Limit history size to prevent memory issues - if (this._playHistory.length > this._maxHistorySize) { - this._playHistory.shift(); - } - }, - - /** - * Pop track from history and find its current index in the queue. - * Tracks are matched by ID so history survives queue rebuilds. - * @returns {number} Current index of the historical track, or -1 if not found - */ - _popFromHistory() { - while (this._playHistory.length > 0) { - const track = this._playHistory.pop(); - const idx = this.items.findIndex((t) => t.id === track.id); - if (idx >= 0) return idx; - // Track no longer in queue - keep draining until we find one that is - } - return -1; - }, - async shuffleQueue() { if (this.items.length < 2) return; @@ -764,12 +535,12 @@ export function createQueueStore(Alpine) { currentIndex: this.currentIndex, }); - // Update local state - this._shuffleItems(); - this._originalOrder = [...this.items]; - - // Sync shuffled order to backend - await this._syncQueueToBackend(); + try { + await queueApi.shuffle(true); + await this.load(); + } catch (error) { + console.error('[queue] shuffleQueue failed:', error); + } }, async cycleLoop() { @@ -783,7 +554,6 @@ export function createQueueStore(Alpine) { }); this.loop = newMode; - this._repeatOnePending = false; await queueApi.setLoop(this.loop); }, @@ -799,11 +569,27 @@ export function createQueueStore(Alpine) { }); this.loop = mode; - this._repeatOnePending = false; await queueApi.setLoop(this.loop); } }, + /** + * Run integrity check via backend + */ + async checkIntegrity() { + try { + const report = await queueApi.checkIntegrity(); + if (report.repaired) { + console.log('[queue] Integrity check repaired issues:', report); + await this.load(); + } + return report; + } catch (error) { + console.error('[queue] Integrity check failed:', error); + return null; + } + }, + /** * Get tracks (alias for items, used by UI templates) */ diff --git a/app/frontend/js/utils/library-operations.js b/app/frontend/js/utils/library-operations.js index df3036ed..259f7b38 100644 --- a/app/frontend/js/utils/library-operations.js +++ b/app/frontend/js/utils/library-operations.js @@ -535,9 +535,7 @@ export function removeFromQueue(Alpine, idSet) { } } } - if (queue._originalOrder) { - queue._originalOrder = queue._originalOrder.filter((t) => !idSet.has(t.id)); - } + // Original order is now managed by the backend } /** @@ -565,7 +563,6 @@ export function removeTracksLocallyOp(store, Alpine, trackIds) { const queue = Alpine.store('queue'); queue.items = []; - queue._originalOrder = []; queue.currentIndex = -1; Alpine.store('player').stop(); return; diff --git a/app/frontend/js/utils/queue-builder.js b/app/frontend/js/utils/queue-builder.js index ebc03c1b..baa7c93c 100644 --- a/app/frontend/js/utils/queue-builder.js +++ b/app/frontend/js/utils/queue-builder.js @@ -30,10 +30,7 @@ export async function handleDoubleClickPlay(ctx, track, allTracks, index, logPre // Apply response to queue store ctx.queue.items = result.items.map((item) => item.track || item); ctx.queue.currentIndex = result.current_index; - ctx.queue._originalOrder = [...ctx.queue.items]; - ctx.queue._playHistory = []; - ctx.queue._playNextOffset = 0; - ctx.queue._playNextTrackIds = new Set(); + ctx.queue.shuffle = result.shuffle_enabled ?? ctx.queue.shuffle; // Update player state from the track returned by backend ctx.player.updateTrackState(result.track, result.duration_ms); diff --git a/app/frontend/views/footer.html b/app/frontend/views/footer.html index e9f6fa77..e299a5c4 100644 --- a/app/frontend/views/footer.html +++ b/app/frontend/views/footer.html @@ -151,6 +151,7 @@