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');
} 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 { 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<string> {
}
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<string | number | null>(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<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
@@ -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));
}
}
+34 -9
View File
@@ -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);
});
});
+27 -6
View File
@@ -1,11 +1,28 @@
export function insertNext<T>(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<T>(queue: T[], items: T[]): T[] {
return [...queue, ...items];
export function insertNext<T extends Identified>(
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[] {
@@ -18,6 +35,10 @@ export function move<T>(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,
+3 -10
View File
@@ -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);
}
}
+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'))}}`;
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 {