web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user