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');
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'))}}`;
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user