web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage

This commit is contained in:
2026-09-24 04:59:28 +02:00
parent 3e546d4a1e
commit 906f38140a
7 changed files with 120 additions and 38 deletions
+4 -1
View File
@@ -66,7 +66,10 @@ class LikedStore {
}); });
if (!res.ok) throw new Error('Failed to toggle like'); if (!res.ok) throw new Error('Failed to toggle like');
} catch { } catch {
this.entries = before; const reverted = { ...this.entries };
if (wasLiked) reverted[id] = before[id];
else delete reverted[id];
this.entries = reverted;
} }
} }
} }
+33 -9
View File
@@ -4,7 +4,8 @@ import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections'; import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte'; import { dialog } from '$lib/state/dialog.svelte';
import { ListenTracker } from './listenTracker'; 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 { export interface PlayerTrack {
id: string | number; id: string | number;
@@ -95,6 +96,20 @@ async function readErrorMessage(res: Response): Promise<string> {
} }
const RECENT_LIMIT = 15; 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 = { const EMPTY: PlayerTrack = {
id: '', id: '',
@@ -120,8 +135,8 @@ class PlayerState {
currentId = $state<string | number | null>(null); currentId = $state<string | number | null>(null);
playing = $state(false); playing = $state(false);
progress = $state(0); progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false); muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
loading = $state(false); loading = $state(false);
recentlyPlayed = $state<PlayerTrack[]>([]); recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]); playlists = $state<Playlist[]>([]);
@@ -183,6 +198,10 @@ class PlayerState {
audio.play().catch(() => {}); audio.play().catch(() => {});
return; return;
} }
if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) {
this.#stopAtEnd(audio);
return;
}
this.next(); this.next();
}); });
audio.addEventListener('error', () => { audio.addEventListener('error', () => {
@@ -200,6 +219,13 @@ class PlayerState {
return audio; return audio;
} }
#stopAtEnd(audio: HTMLAudioElement) {
audio.currentTime = 0;
this.progress = 0;
this.playing = false;
this.#syncMediaSessionPlaybackState();
}
#setupMediaSession(audio: HTMLAudioElement) { #setupMediaSession(audio: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {})); navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
@@ -453,7 +479,7 @@ class PlayerState {
this.muted = value; this.muted = value;
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) audio.muted = value; if (audio) audio.muted = value;
if (browser) localStorage.setItem('player.muted', String(value)); if (browser) writeStorage(MUTED_KEY, String(value));
} }
next() { next() {
@@ -511,13 +537,11 @@ class PlayerState {
} }
setVolume(value: number) { 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); if (this.muted && this.volume > 0) this.#applyMuted(false);
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) { if (audio) audio.volume = this.volume / 100;
audio.volume = this.volume / 100; if (browser) writeStorage(VOLUME_KEY, String(this.volume));
localStorage.setItem('player.volume', String(this.volume));
}
} }
} }
+34 -9
View File
@@ -1,20 +1,38 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('insertNext', () => {
it('inserts right after the given index without mutating', () => { it('inserts right after the current track without mutating', () => {
const queue = ['a', 'b', 'c']; const queue = ['a', 'b', 'c'].map(t);
expect(insertNext(queue, 0, ['x', 'y'])).toEqual(['a', 'x', 'y', 'b', 'c']); expect(ids(insertNext(queue, 0, ['x', 'y'].map(t)))).toEqual(['a', 'x', 'y', 'b', 'c']);
expect(queue).toEqual(['a', 'b', 'c']); expect(ids(queue)).toEqual(['a', 'b', 'c']);
}); });
it('inserts at the start when index is -1', () => { it('inserts at the start when there is no current track', () => {
expect(insertNext(['a'], -1, ['x'])).toEqual(['x', 'a']); 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', () => { describe('append', () => {
it('adds items at the end', () => { it('adds new items at the end', () => {
expect(append(['a'], ['b', 'c'])).toEqual(['a', 'b', 'c']); 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); 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);
});
});
+27 -6
View File
@@ -1,11 +1,28 @@
export function insertNext<T>(queue: T[], afterIndex: number, items: T[]): T[] { interface Identified {
const result = [...queue]; id: string | number;
result.splice(afterIndex + 1, 0, ...items);
return result;
} }
export function append<T>(queue: T[], items: T[]): T[] { export function insertNext<T extends Identified>(
return [...queue, ...items]; 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<T extends Identified>(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<T>(queue: T[], from: number, insertAt: number): T[] { export function move<T>(queue: T[], from: number, insertAt: number): T[] {
@@ -18,6 +35,10 @@ export function move<T>(queue: T[], from: number, insertAt: number): T[] {
return result; return result;
} }
export function isLastIndex(length: number, index: number): boolean {
return index >= length - 1;
}
export function nextIndex( export function nextIndex(
length: number, length: number,
index: number, index: number,
+3 -10
View File
@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { readStorage, writeStorage } from '$lib/utils/storage';
export type ThemeName = 'dark' | 'light'; export type ThemeName = 'dark' | 'light';
@@ -6,11 +7,7 @@ const STORAGE_KEY = 'musify.theme';
function loadInitial(): ThemeName { function loadInitial(): ThemeName {
if (!browser) return 'dark'; if (!browser) return 'dark';
try { return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark';
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
} }
function applyTheme(theme: ThemeName) { function applyTheme(theme: ThemeName) {
@@ -29,11 +26,7 @@ class ThemeModeStore {
this.current = this.current === 'dark' ? 'light' : 'dark'; this.current = this.current === 'dark' ? 'light' : 'dark';
applyTheme(this.current); applyTheme(this.current);
if (!browser) return; if (!browser) return;
try { writeStorage(STORAGE_KEY, this.current);
localStorage.setItem(STORAGE_KEY, this.current);
} catch {
/* localStorage unavailable (private mode, quota) — preference just won't persist */
}
} }
} }
+15
View File
@@ -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 */
}
}
+4 -3
View File
@@ -41,10 +41,11 @@
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
let wasPlaying = false; let queueAutoOpened = false;
$effect(() => { $effect(() => {
if (player.playing && !wasPlaying) queuePanel.show(); if (!player.playing || queueAutoOpened) return;
wasPlaying = player.playing; queueAutoOpened = true;
queuePanel.show();
}); });
function isTypingTarget(target: EventTarget | null): boolean { function isTypingTarget(target: EventTarget | null): boolean {