diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 122fb3ec..3c24cf76 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -24,6 +24,12 @@ import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; +import { + normalizeAnalysisTrackId, + queuesStructuralEqual, + sameQueueTrackId, + shallowCloneQueueTracks, +} from '../utils/queueIdentity'; import { getWindowKind } from '../app/windowKind'; // Re-export for backward compatibility with the ~30 call sites that still @@ -370,10 +376,6 @@ export function consumePendingQueueListScrollTop(): number | undefined { return v; } -function shallowCloneQueueTracks(queue: Track[]): Track[] { - return queue.map(t => ({ ...t })); -} - function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot { const scrollTop = readQueueListScrollTopForUndo(); return { @@ -476,28 +478,6 @@ function emitNormalizationDebug(step: string, details?: Record) }).catch(() => {}); } -function normalizeAnalysisTrackId(trackId?: string | null): string | null { - if (!trackId) return null; - if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length); - return trackId; -} - -/** Compare track ids across `stream:` / bare Subsonic forms. */ -function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean { - if (a == null || b == null) return false; - const na = normalizeAnalysisTrackId(a) ?? a; - const nb = normalizeAnalysisTrackId(b) ?? b; - return na === nb; -} - -function queuesStructuralEqual(a: Track[], b: Track[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false; - } - return true; -} - function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean { if (a == null && b == null) return true; if (a == null || b == null) return false; diff --git a/src/utils/queueIdentity.test.ts b/src/utils/queueIdentity.test.ts new file mode 100644 index 00000000..68ef72da --- /dev/null +++ b/src/utils/queueIdentity.test.ts @@ -0,0 +1,107 @@ +/** + * Pure helpers extracted from playerStore. The interesting behaviour is the + * `stream:` prefix normalization (Rust events sometimes wrap track ids when + * routing through the HTTP source) and the no-op detection in + * `queuesStructuralEqual` that prevents unnecessary store rewrites. + */ +import { describe, expect, it } from 'vitest'; +import type { Track } from '../store/playerStore'; +import { + normalizeAnalysisTrackId, + queuesStructuralEqual, + sameQueueTrackId, + shallowCloneQueueTracks, +} from './queueIdentity'; + +function track(id: string, overrides: Partial = {}): Track { + return { + id, + title: `Title ${id}`, + artist: 'Artist', + album: 'Album', + albumId: 'A', + duration: 180, + ...overrides, + }; +} + +describe('normalizeAnalysisTrackId', () => { + it('strips the stream: prefix', () => { + expect(normalizeAnalysisTrackId('stream:abc123')).toBe('abc123'); + }); + + it('returns bare ids unchanged', () => { + expect(normalizeAnalysisTrackId('abc123')).toBe('abc123'); + }); + + it('returns null for null / undefined / empty', () => { + expect(normalizeAnalysisTrackId(null)).toBeNull(); + expect(normalizeAnalysisTrackId(undefined)).toBeNull(); + expect(normalizeAnalysisTrackId('')).toBeNull(); + }); +}); + +describe('sameQueueTrackId', () => { + it('matches bare ids', () => { + expect(sameQueueTrackId('a', 'a')).toBe(true); + expect(sameQueueTrackId('a', 'b')).toBe(false); + }); + + it('matches across stream: prefix mismatch', () => { + expect(sameQueueTrackId('stream:a', 'a')).toBe(true); + expect(sameQueueTrackId('a', 'stream:a')).toBe(true); + expect(sameQueueTrackId('stream:a', 'stream:a')).toBe(true); + }); + + it('returns false when either side is null', () => { + expect(sameQueueTrackId(null, 'a')).toBe(false); + expect(sameQueueTrackId('a', null)).toBe(false); + expect(sameQueueTrackId(null, null)).toBe(false); + }); +}); + +describe('queuesStructuralEqual', () => { + it('returns true for same ids in same order', () => { + expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('b')])).toBe(true); + }); + + it('returns true when one side wraps ids with stream:', () => { + expect(queuesStructuralEqual( + [track('a'), track('b')], + [track('stream:a'), track('stream:b')], + )).toBe(true); + }); + + it('returns false for different lengths', () => { + expect(queuesStructuralEqual([track('a')], [track('a'), track('b')])).toBe(false); + }); + + it('returns false for any id mismatch', () => { + expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('c')])).toBe(false); + }); + + it('treats empty queues as equal', () => { + expect(queuesStructuralEqual([], [])).toBe(true); + }); +}); + +describe('shallowCloneQueueTracks', () => { + it('returns a new array with new objects (callers can mutate freely)', () => { + const original = [track('a'), track('b')]; + const cloned = shallowCloneQueueTracks(original); + expect(cloned).not.toBe(original); + expect(cloned[0]).not.toBe(original[0]); + expect(cloned[1]).not.toBe(original[1]); + expect(cloned).toEqual(original); + }); + + it('preserves all fields', () => { + const original = [track('a', { coverArt: 'cover', userRating: 5, autoAdded: true })]; + const cloned = shallowCloneQueueTracks(original); + expect(cloned[0]).toEqual(original[0]); + }); + + it('handles empty queues', () => { + expect(shallowCloneQueueTracks([])).toEqual([]); + }); +}); diff --git a/src/utils/queueIdentity.ts b/src/utils/queueIdentity.ts new file mode 100644 index 00000000..af39612b --- /dev/null +++ b/src/utils/queueIdentity.ts @@ -0,0 +1,37 @@ +import type { Track } from '../store/playerStore'; + +/** + * Strip the `stream:` prefix that some Rust events attach to track ids when + * they're routed through the HTTP source. Both forms identify the same track, + * so equality and structural-diff checks need to normalize first. + */ +export function normalizeAnalysisTrackId(trackId?: string | null): string | null { + if (!trackId) return null; + if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length); + return trackId; +} + +/** Compare track ids across `stream:` / bare Subsonic forms. */ +export function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean { + if (a == null || b == null) return false; + const na = normalizeAnalysisTrackId(a) ?? a; + const nb = normalizeAnalysisTrackId(b) ?? b; + return na === nb; +} + +/** + * Same-length + same-ids check. Used to skip no-op queue rewrites that would + * otherwise reset selection / scroll / drag-source state in subscribers. + */ +export function queuesStructuralEqual(a: Track[], b: Track[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false; + } + return true; +} + +/** One-level clone so callers can mutate per-track fields without aliasing state. */ +export function shallowCloneQueueTracks(queue: Track[]): Track[] { + return queue.map(t => ({ ...t })); +}