From 906f38140a9d3aa63cf8bd11f533f023428b2ba1 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 04:59:28 +0200 Subject: [PATCH] web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage --- web-player/src/lib/player/liked.svelte.ts | 5 ++- web-player/src/lib/player/player.svelte.ts | 42 ++++++++++++++++----- web-player/src/lib/player/queue.test.ts | 43 +++++++++++++++++----- web-player/src/lib/player/queue.ts | 33 ++++++++++++++--- web-player/src/lib/theme/mode.svelte.ts | 13 ++----- web-player/src/lib/utils/storage.ts | 15 ++++++++ web-player/src/routes/+layout.svelte | 7 ++-- 7 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 web-player/src/lib/utils/storage.ts diff --git a/web-player/src/lib/player/liked.svelte.ts b/web-player/src/lib/player/liked.svelte.ts index 54a2f00..29e1b8c 100644 --- a/web-player/src/lib/player/liked.svelte.ts +++ b/web-player/src/lib/player/liked.svelte.ts @@ -66,7 +66,10 @@ class LikedStore { }); if (!res.ok) throw new Error('Failed to toggle like'); } catch { - this.entries = before; + const reverted = { ...this.entries }; + if (wasLiked) reverted[id] = before[id]; + else delete reverted[id]; + this.entries = reverted; } } } diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 6b34c37..bb71d35 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -4,7 +4,8 @@ import { extractAccent, type Accent } from '$lib/theme/palette'; import { shuffle } from '$lib/data/collections'; import { dialog } from '$lib/state/dialog.svelte'; import { ListenTracker } from './listenTracker'; -import { append, insertNext, move, nextIndex, previousIndex } from './queue'; +import { readStorage, writeStorage } from '$lib/utils/storage'; +import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue'; export interface PlayerTrack { id: string | number; @@ -95,6 +96,20 @@ async function readErrorMessage(res: Response): Promise { } const RECENT_LIMIT = 15; +const DEFAULT_VOLUME = 100; +const VOLUME_KEY = 'musify.player.volume'; +const MUTED_KEY = 'musify.player.muted'; + +function clampVolume(value: number): number { + return Math.min(100, Math.max(0, Math.round(value))); +} + +function loadVolume(): number { + const stored = readStorage(VOLUME_KEY); + if (stored === null || stored.trim() === '') return DEFAULT_VOLUME; + const parsed = Number(stored); + return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME; +} const EMPTY: PlayerTrack = { id: '', @@ -120,8 +135,8 @@ class PlayerState { currentId = $state(null); playing = $state(false); progress = $state(0); - volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); - muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false); + volume = $state(browser ? loadVolume() : DEFAULT_VOLUME); + muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false); loading = $state(false); recentlyPlayed = $state([]); playlists = $state([]); @@ -183,6 +198,10 @@ class PlayerState { audio.play().catch(() => {}); return; } + if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) { + this.#stopAtEnd(audio); + return; + } this.next(); }); audio.addEventListener('error', () => { @@ -200,6 +219,13 @@ class PlayerState { return audio; } + #stopAtEnd(audio: HTMLAudioElement) { + audio.currentTime = 0; + this.progress = 0; + this.playing = false; + this.#syncMediaSessionPlaybackState(); + } + #setupMediaSession(audio: HTMLAudioElement) { if (!('mediaSession' in navigator)) return; navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {})); @@ -453,7 +479,7 @@ class PlayerState { this.muted = value; const audio = this.#audioEl(); if (audio) audio.muted = value; - if (browser) localStorage.setItem('player.muted', String(value)); + if (browser) writeStorage(MUTED_KEY, String(value)); } next() { @@ -511,13 +537,11 @@ class PlayerState { } setVolume(value: number) { - this.volume = Math.min(100, Math.max(0, Math.round(value))); + this.volume = Number.isFinite(value) ? clampVolume(value) : this.volume; if (this.muted && this.volume > 0) this.#applyMuted(false); const audio = this.#audioEl(); - if (audio) { - audio.volume = this.volume / 100; - localStorage.setItem('player.volume', String(this.volume)); - } + if (audio) audio.volume = this.volume / 100; + if (browser) writeStorage(VOLUME_KEY, String(this.volume)); } } diff --git a/web-player/src/lib/player/queue.test.ts b/web-player/src/lib/player/queue.test.ts index b6ac598..85b1169 100644 --- a/web-player/src/lib/player/queue.test.ts +++ b/web-player/src/lib/player/queue.test.ts @@ -1,20 +1,38 @@ import { describe, expect, it } from 'vitest'; -import { append, insertNext, move, nextIndex, previousIndex } from './queue'; +import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue'; + +const t = (id: string) => ({ id }); +const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id); describe('insertNext', () => { - it('inserts right after the given index without mutating', () => { - const queue = ['a', 'b', 'c']; - expect(insertNext(queue, 0, ['x', 'y'])).toEqual(['a', 'x', 'y', 'b', 'c']); - expect(queue).toEqual(['a', 'b', 'c']); + it('inserts right after the current track without mutating', () => { + const queue = ['a', 'b', 'c'].map(t); + expect(ids(insertNext(queue, 0, ['x', 'y'].map(t)))).toEqual(['a', 'x', 'y', 'b', 'c']); + expect(ids(queue)).toEqual(['a', 'b', 'c']); }); - it('inserts at the start when index is -1', () => { - expect(insertNext(['a'], -1, ['x'])).toEqual(['x', 'a']); + it('inserts at the start when there is no current track', () => { + expect(ids(insertNext([t('a')], -1, [t('x')]))).toEqual(['x', 'a']); + }); + it('moves tracks already queued instead of duplicating them', () => { + const queue = ['a', 'b', 'c', 'd'].map(t); + expect(ids(insertNext(queue, 1, [t('d'), t('a')]))).toEqual(['b', 'd', 'a', 'c']); + }); + it('ignores the current track', () => { + const queue = ['a', 'b'].map(t); + expect(ids(insertNext(queue, 0, [t('a')]))).toEqual(['a', 'b']); }); }); describe('append', () => { - it('adds items at the end', () => { - expect(append(['a'], ['b', 'c'])).toEqual(['a', 'b', 'c']); + it('adds new items at the end', () => { + expect(ids(append([t('a')], ['b', 'c'].map(t)))).toEqual(['a', 'b', 'c']); + }); + it('skips tracks already in the queue and repeated ones', () => { + expect(ids(append(['a', 'b'].map(t), ['b', 'c', 'c'].map(t)))).toEqual(['a', 'b', 'c']); + }); + it('returns the same queue when nothing is added', () => { + const queue = [t('a')]; + expect(append(queue, [t('a')])).toBe(queue); }); }); @@ -58,3 +76,10 @@ describe('previousIndex', () => { expect(previousIndex(3, 0)).toBe(2); }); }); + +describe('isLastIndex', () => { + it('detects the end of the queue', () => { + expect(isLastIndex(3, 2)).toBe(true); + expect(isLastIndex(3, 1)).toBe(false); + }); +}); diff --git a/web-player/src/lib/player/queue.ts b/web-player/src/lib/player/queue.ts index cee8b9a..b83c933 100644 --- a/web-player/src/lib/player/queue.ts +++ b/web-player/src/lib/player/queue.ts @@ -1,11 +1,28 @@ -export function insertNext(queue: T[], afterIndex: number, items: T[]): T[] { - const result = [...queue]; - result.splice(afterIndex + 1, 0, ...items); - return result; +interface Identified { + id: string | number; } -export function append(queue: T[], items: T[]): T[] { - return [...queue, ...items]; +export function insertNext( + queue: T[], + currentIndex: number, + items: T[] +): T[] { + const currentId = queue[currentIndex]?.id; + const incoming = items.filter((item) => item.id !== currentId); + const moving = new Set(incoming.map((item) => item.id)); + const rest = queue.filter((item) => !moving.has(item.id)); + const anchor = currentId === undefined ? -1 : rest.findIndex((item) => item.id === currentId); + return [...rest.slice(0, anchor + 1), ...incoming, ...rest.slice(anchor + 1)]; +} + +export function append(queue: T[], items: T[]): T[] { + const present = new Set(queue.map((item) => item.id)); + const added = items.filter((item) => { + if (present.has(item.id)) return false; + present.add(item.id); + return true; + }); + return added.length > 0 ? [...queue, ...added] : queue; } export function move(queue: T[], from: number, insertAt: number): T[] { @@ -18,6 +35,10 @@ export function move(queue: T[], from: number, insertAt: number): T[] { return result; } +export function isLastIndex(length: number, index: number): boolean { + return index >= length - 1; +} + export function nextIndex( length: number, index: number, diff --git a/web-player/src/lib/theme/mode.svelte.ts b/web-player/src/lib/theme/mode.svelte.ts index fc76101..9104034 100644 --- a/web-player/src/lib/theme/mode.svelte.ts +++ b/web-player/src/lib/theme/mode.svelte.ts @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import { readStorage, writeStorage } from '$lib/utils/storage'; export type ThemeName = 'dark' | 'light'; @@ -6,11 +7,7 @@ const STORAGE_KEY = 'musify.theme'; function loadInitial(): ThemeName { if (!browser) return 'dark'; - try { - return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark'; - } catch { - return 'dark'; - } + return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark'; } function applyTheme(theme: ThemeName) { @@ -29,11 +26,7 @@ class ThemeModeStore { this.current = this.current === 'dark' ? 'light' : 'dark'; applyTheme(this.current); if (!browser) return; - try { - localStorage.setItem(STORAGE_KEY, this.current); - } catch { - /* localStorage unavailable (private mode, quota) — preference just won't persist */ - } + writeStorage(STORAGE_KEY, this.current); } } diff --git a/web-player/src/lib/utils/storage.ts b/web-player/src/lib/utils/storage.ts new file mode 100644 index 0000000..a4a5624 --- /dev/null +++ b/web-player/src/lib/utils/storage.ts @@ -0,0 +1,15 @@ +export function readStorage(key: string, fallback: string | null = null): string | null { + try { + return localStorage.getItem(key) ?? fallback; + } catch { + return fallback; + } +} + +export function writeStorage(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + /* localStorage unavailable (private mode, quota) — the value just won't persist */ + } +} diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index a39df73..71250f5 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -41,10 +41,11 @@ const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; - let wasPlaying = false; + let queueAutoOpened = false; $effect(() => { - if (player.playing && !wasPlaying) queuePanel.show(); - wasPlaying = player.playing; + if (!player.playing || queueAutoOpened) return; + queueAutoOpened = true; + queuePanel.show(); }); function isTypingTarget(target: EventTarget | null): boolean {