diff --git a/web/src/lib/player/persisted.test.ts b/web/src/lib/player/persisted.test.ts new file mode 100644 index 00000000..032631c3 --- /dev/null +++ b/web/src/lib/player/persisted.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readPersistedQueue, writePersistedQueue, clearPersistedQueue } from './persisted'; +import type { TrackRef } from '$lib/api/types'; + +const sampleTrack: TrackRef = { + id: 't1', + title: 'Foo', + artist_name: 'Bar', + album_name: 'Baz', + duration_ms: 200000, + stream_url: '/stream/t1' +} as TrackRef; + +describe('persisted queue', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('write-then-read round-trips queue / index / position', () => { + writePersistedQueue('user-1', { queue: [sampleTrack], index: 0, position: 42.5 }); + const out = readPersistedQueue('user-1'); + expect(out).not.toBeNull(); + expect(out!.v).toBe(1); + expect(out!.queue).toEqual([sampleTrack]); + expect(out!.index).toBe(0); + expect(out!.position).toBe(42.5); + expect(typeof out!.savedAt).toBe('number'); + }); + + it('read returns null when no entry is set', () => { + expect(readPersistedQueue('user-1')).toBeNull(); + }); + + it('read returns null on malformed JSON', () => { + localStorage.setItem('minstrel.queue.user-1', '{not-json'); + expect(readPersistedQueue('user-1')).toBeNull(); + }); + + it('read returns null when version is not 1', () => { + localStorage.setItem( + 'minstrel.queue.user-1', + JSON.stringify({ v: 2, queue: [], index: 0, position: 0, savedAt: 0 }) + ); + expect(readPersistedQueue('user-1')).toBeNull(); + }); + + it('clearPersistedQueue removes only the named user key', () => { + writePersistedQueue('user-a', { queue: [sampleTrack], index: 0, position: 0 }); + writePersistedQueue('user-b', { queue: [sampleTrack], index: 0, position: 0 }); + clearPersistedQueue('user-a'); + expect(readPersistedQueue('user-a')).toBeNull(); + expect(readPersistedQueue('user-b')).not.toBeNull(); + }); + + it('write swallows QuotaExceededError without throwing', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + expect(() => + writePersistedQueue('user-1', { queue: [sampleTrack], index: 0, position: 0 }) + ).not.toThrow(); + setItem.mockRestore(); + }); +}); diff --git a/web/src/lib/player/persisted.ts b/web/src/lib/player/persisted.ts new file mode 100644 index 00000000..eb1e6466 --- /dev/null +++ b/web/src/lib/player/persisted.ts @@ -0,0 +1,55 @@ +import type { TrackRef } from '$lib/api/types'; + +export type PersistedPlayerState = { + v: 1; + queue: TrackRef[]; + index: number; + position: number; + savedAt: number; +}; + +const persistKey = (userId: string) => `minstrel.queue.${userId}`; + +export function readPersistedQueue(userId: string): PersistedPlayerState | null { + if (typeof localStorage === 'undefined') return null; + try { + const raw = localStorage.getItem(persistKey(userId)); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (parsed.v !== 1) return null; + if (!Array.isArray(parsed.queue)) return null; + if (typeof parsed.index !== 'number') return null; + if (typeof parsed.position !== 'number') return null; + return parsed as PersistedPlayerState; + } catch { + return null; + } +} + +export function writePersistedQueue( + userId: string, + state: { queue: TrackRef[]; index: number; position: number } +): void { + if (typeof localStorage === 'undefined') return; + try { + const payload: PersistedPlayerState = { + v: 1, + queue: state.queue, + index: state.index, + position: state.position, + savedAt: Date.now() + }; + localStorage.setItem(persistKey(userId), JSON.stringify(payload)); + } catch { + // QuotaExceededError, security errors, etc. Swallow; persistence is best-effort. + } +} + +export function clearPersistedQueue(userId: string): void { + if (typeof localStorage === 'undefined') return; + try { + localStorage.removeItem(persistKey(userId)); + } catch { + /* ignore */ + } +}