feat(web): level playback by the user's normalization preference (M464 #4999)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m26s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Cuts go through element.volume. Boosts route the element through a Web Audio GainNode and a DynamicsCompressor (a -1 dBFS limiter in limiter mode, a pass-through otherwise), built only when a track wants a boost and only once an AudioContext is confirmed running; iOS never gets the graph. Auto mode takes album gain when a queue neighbour is from the same album in track order. Gains are fetched for the next 50 tracks as the queue moves, with a 10s deadline. The prefetch element is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { api } from './client';
|
||||
import { api, apiFetch } from './client';
|
||||
|
||||
// Loudness normalization preference (M464 #4998). One per user, stored on
|
||||
// the server so the web player, the Android app and casts agree.
|
||||
@@ -40,3 +40,23 @@ export function getNormalization(): Promise<NormalizationPrefs> {
|
||||
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
|
||||
return api.put<NormalizationPrefs>('/api/me/normalization', p);
|
||||
}
|
||||
|
||||
// Per-track gains (#4997): dB to the -18 LUFS ReplayGain reference and linear
|
||||
// peaks. A field is absent until the server has measured it.
|
||||
export type ReplayGain = {
|
||||
track_gain?: number;
|
||||
track_peak?: number;
|
||||
album_gain?: number;
|
||||
album_peak?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gains for up to 200 tracks; an id missing from items has none yet. Given
|
||||
* a deadline: the player waits on it, and a request that never answers
|
||||
* would leave those tracks unleveled for the rest of the session.
|
||||
*/
|
||||
export function getReplayGain(ids: string[]): Promise<{ items: Record<string, ReplayGain> }> {
|
||||
return apiFetch(`/api/tracks/replay-gain?ids=${ids.map(encodeURIComponent).join(',')}`, {
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
}) as Promise<{ items: Record<string, ReplayGain> }>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { dbToLinear, gainDb, playingAsAlbum, MAX_BOOST_DB } from './gain';
|
||||
import type { NormalizationPrefs } from '$lib/api/normalization';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { makeTrack } from '$test-utils/fixtures/track';
|
||||
|
||||
const prefs = (over: Partial<NormalizationPrefs> = {}): NormalizationPrefs => ({
|
||||
mode: 'auto',
|
||||
target_lufs: -18,
|
||||
boost: 'headroom',
|
||||
...over
|
||||
});
|
||||
|
||||
const track = (id: string, album: string, n?: number, disc?: number): TrackRef =>
|
||||
makeTrack({ id, album_id: album, track_number: n, disc_number: disc });
|
||||
|
||||
describe('gainDb', () => {
|
||||
const g = { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 };
|
||||
|
||||
test('off and unmeasured tracks play as mastered', () => {
|
||||
expect(gainDb(prefs({ mode: 'off' }), g, false)).toBe(0);
|
||||
expect(gainDb(prefs(), undefined, false)).toBe(0);
|
||||
expect(gainDb(prefs(), {}, false)).toBe(0);
|
||||
});
|
||||
|
||||
test('mode picks the gain; auto follows album play', () => {
|
||||
expect(gainDb(prefs({ mode: 'track' }), g, true)).toBe(-6);
|
||||
expect(gainDb(prefs({ mode: 'album' }), g, false)).toBe(-4);
|
||||
expect(gainDb(prefs({ mode: 'auto' }), g, true)).toBe(-4);
|
||||
expect(gainDb(prefs({ mode: 'auto' }), g, false)).toBe(-6);
|
||||
});
|
||||
|
||||
test('album gain falls back to track gain until the album is measured', () => {
|
||||
expect(gainDb(prefs({ mode: 'album' }), { track_gain: -6, track_peak: 1 }, true)).toBe(-6);
|
||||
});
|
||||
|
||||
test('a louder target raises every gain by the difference', () => {
|
||||
expect(gainDb(prefs({ mode: 'track', target_lufs: -14 }), g, false)).toBe(-2);
|
||||
});
|
||||
|
||||
test('headroom stops a boost 1 dB under the true peak; the limiter lets it through', () => {
|
||||
// +8 dB wanted; the peak at 0.5 (about -6 dBTP) leaves 5 dB of room.
|
||||
const quiet = { track_gain: 8, track_peak: 0.5 };
|
||||
expect(gainDb(prefs({ mode: 'track' }), quiet, false)).toBeCloseTo(-1 - 20 * Math.log10(0.5), 5);
|
||||
expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), quiet, false)).toBe(8);
|
||||
});
|
||||
|
||||
test('no boost passes the cap, limiter or not', () => {
|
||||
const silent = { track_gain: 30, track_peak: 0.001 };
|
||||
expect(gainDb(prefs({ mode: 'track', boost: 'limiter' }), silent, false)).toBe(MAX_BOOST_DB);
|
||||
});
|
||||
|
||||
test('cuts are never limited by the peak', () => {
|
||||
expect(gainDb(prefs({ mode: 'track' }), { track_gain: -9, track_peak: 1.4 }, false)).toBe(-9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playingAsAlbum', () => {
|
||||
test('in-order neighbours from the same album', () => {
|
||||
const q = [track('1', 'x', 1), track('2', 'x', 2), track('3', 'x', 3)];
|
||||
expect(playingAsAlbum(q, 0)).toBe(true);
|
||||
expect(playingAsAlbum(q, 1)).toBe(true);
|
||||
expect(playingAsAlbum(q, 2)).toBe(true);
|
||||
});
|
||||
|
||||
test('a shuffled album, a mix and a lone track are not album play', () => {
|
||||
expect(playingAsAlbum([track('3', 'x', 3), track('1', 'x', 1)], 0)).toBe(false);
|
||||
expect(playingAsAlbum([track('1', 'x', 1), track('2', 'y', 2)], 0)).toBe(false);
|
||||
expect(playingAsAlbum([track('1', 'x', 1)], 0)).toBe(false);
|
||||
});
|
||||
|
||||
test('disc order counts: disc 2 track 1 follows disc 1 track 12', () => {
|
||||
const q = [track('a', 'x', 12, 1), track('b', 'x', 1, 2)];
|
||||
expect(playingAsAlbum(q, 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('neighbours without track numbers are not evidence of order', () => {
|
||||
expect(playingAsAlbum([track('1', 'x'), track('2', 'x')], 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('dbToLinear', () => {
|
||||
expect(dbToLinear(0)).toBe(1);
|
||||
expect(dbToLinear(-20)).toBeCloseTo(0.1, 6);
|
||||
expect(dbToLinear(6)).toBeCloseTo(1.995, 3);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Loudness normalization math for the web player (M464 #4999). Pure, so the
|
||||
// rules are tested without an <audio> element or an AudioContext.
|
||||
|
||||
import type { NormalizationPrefs, ReplayGain } from '$lib/api/normalization';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
// The server's gains are to the ReplayGain 2.0 reference.
|
||||
const REFERENCE_LUFS = -18;
|
||||
// Headroom mode raises a quiet track only until its true peak reaches this.
|
||||
export const PEAK_CEILING_DBTP = -1;
|
||||
// No track is raised more than this, whatever its measurement says: a
|
||||
// near-silent track would otherwise come out as amplified noise.
|
||||
export const MAX_BOOST_DB = 12;
|
||||
|
||||
/**
|
||||
* Whether the track at `index` is being played as part of its album, in
|
||||
* order: a neighbour in the queue is from the same album and sits on the
|
||||
* right side of it. That is when album gain keeps the album's own dynamics
|
||||
* (a quiet intro stays quiet); anywhere else track gain levels the mix.
|
||||
*/
|
||||
export function playingAsAlbum(queue: TrackRef[], index: number): boolean {
|
||||
const cur = queue[index];
|
||||
if (!cur) return false;
|
||||
const prev = queue[index - 1];
|
||||
const next = queue[index + 1];
|
||||
return (
|
||||
(prev !== undefined && prev.album_id === cur.album_id && order(prev) < order(cur)) ||
|
||||
(next !== undefined && next.album_id === cur.album_id && order(next) > order(cur))
|
||||
);
|
||||
}
|
||||
|
||||
// Disc-major track order. Tracks without numbers compare as equal and so
|
||||
// never count as in order; a same-album neighbour with no numbers is not
|
||||
// evidence of album play.
|
||||
function order(t: TrackRef): number {
|
||||
return (t.disc_number ?? 1) * 1000 + (t.track_number ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The gain to apply, in dB, for one track. 0 when leveling is off or the
|
||||
* track has not been measured yet: an unmeasured track plays as mastered.
|
||||
*/
|
||||
export function gainDb(
|
||||
prefs: NormalizationPrefs,
|
||||
g: ReplayGain | undefined,
|
||||
asAlbum: boolean
|
||||
): number {
|
||||
if (prefs.mode === 'off' || !g) return 0;
|
||||
const wantAlbum = prefs.mode === 'album' || (prefs.mode === 'auto' && asAlbum);
|
||||
// Album gain falls back to track gain while the album is still being
|
||||
// measured; track gain never falls back to album gain.
|
||||
const useAlbum = wantAlbum && g.album_gain !== undefined;
|
||||
const gain = useAlbum ? g.album_gain : g.track_gain;
|
||||
const peak = useAlbum ? g.album_peak : g.track_peak;
|
||||
if (gain === undefined) return 0;
|
||||
|
||||
let db = gain + (prefs.target_lufs - REFERENCE_LUFS);
|
||||
if (prefs.boost === 'headroom' && peak !== undefined && peak > 0) {
|
||||
db = Math.min(db, PEAK_CEILING_DBTP - 20 * Math.log10(peak));
|
||||
}
|
||||
return Math.min(db, MAX_BOOST_DB);
|
||||
}
|
||||
|
||||
export function dbToLinear(db: number): number {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { makeTrack } from '$test-utils/fixtures/track';
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
user: {
|
||||
get value() {
|
||||
return { id: 'test-user' };
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const getReplayGain = vi.fn();
|
||||
vi.mock('$lib/api/normalization', async (orig) => ({
|
||||
...(await orig<typeof import('$lib/api/normalization')>()),
|
||||
getReplayGain: (ids: string[]) => getReplayGain(ids)
|
||||
}));
|
||||
|
||||
const { playQueue } = await import('./store.svelte');
|
||||
const { applyGain, ensureGains, gainDbAt, hasGraph } = await import('./gainStage.svelte');
|
||||
|
||||
const track = (id: string, n: number): TrackRef =>
|
||||
makeTrack({ id, album_id: 'alb', track_number: n, disc_number: 1 });
|
||||
|
||||
beforeEach(() => {
|
||||
getReplayGain.mockReset();
|
||||
});
|
||||
|
||||
describe('gain stage', () => {
|
||||
test('fetches each queued gain once; an unmeasured track stays at 0 dB', async () => {
|
||||
getReplayGain.mockResolvedValue({
|
||||
items: { a: { track_gain: -6, track_peak: 1, album_gain: -4, album_peak: 1 } }
|
||||
});
|
||||
playQueue([track('a', 1), track('b', 2)]);
|
||||
await ensureGains();
|
||||
await ensureGains();
|
||||
expect(getReplayGain).toHaveBeenCalledTimes(1);
|
||||
expect(getReplayGain).toHaveBeenCalledWith(['a', 'b']);
|
||||
// In album order, so auto mode takes the album gain.
|
||||
expect(gainDbAt(0)).toBe(-4);
|
||||
expect(gainDbAt(1)).toBe(0);
|
||||
});
|
||||
|
||||
test('a failed fetch is asked again on the next call', async () => {
|
||||
getReplayGain.mockRejectedValueOnce(new Error('offline'));
|
||||
playQueue([track('c', 1)]);
|
||||
await ensureGains();
|
||||
expect(gainDbAt(0)).toBe(0);
|
||||
getReplayGain.mockResolvedValueOnce({ items: { c: { track_gain: -3, track_peak: 1 } } });
|
||||
await ensureGains();
|
||||
expect(gainDbAt(0)).toBe(-3);
|
||||
});
|
||||
|
||||
test('without the graph a cut scales the element and a boost plays at unity', () => {
|
||||
expect(hasGraph()).toBe(false);
|
||||
const el = { volume: 1 } as HTMLAudioElement;
|
||||
applyGain(el, 0.8, -6);
|
||||
expect(el.volume).toBeCloseTo(0.8 * 0.501, 3);
|
||||
applyGain(el, 0.8, 6);
|
||||
expect(el.volume).toBe(0.8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// The web player's gain stage (M464 #4999). A cut is applied through the
|
||||
// element's own volume. A boost needs Web Audio, because element.volume
|
||||
// stops at 1: the element is routed through a GainNode and a
|
||||
// DynamicsCompressor, which acts as a limiter when the user picked one.
|
||||
//
|
||||
// Routing is permanent once made (createMediaElementSource cannot be
|
||||
// undone), and a context that is not running plays nothing. So the graph is
|
||||
// built only when a track actually wants a boost, and only once a context
|
||||
// has been confirmed running. Until then a boost plays at unity, which is
|
||||
// what the track sounded like before leveling existed.
|
||||
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { getReplayGain, type ReplayGain } from '$lib/api/normalization';
|
||||
import { normalization } from '$lib/stores/normalization.svelte';
|
||||
import { player } from './store.svelte';
|
||||
import { dbToLinear, gainDb, playingAsAlbum } from './gain';
|
||||
|
||||
// Gains fetched so far; null means the server has none for that track yet.
|
||||
// Kept for the page's life: a track measured later is picked up on reload.
|
||||
const gains = new SvelteMap<string, ReplayGain | null>();
|
||||
const inflight = new Set<string>();
|
||||
// How far ahead of the current track gains are fetched, so a skip or the
|
||||
// next track has its gain before it starts playing.
|
||||
const LOOKAHEAD = 50;
|
||||
const BATCH = 200; // the endpoint's limit
|
||||
|
||||
const stage = $state({ graph: false });
|
||||
let ctx: AudioContext | null = null;
|
||||
let gainNode: GainNode | null = null;
|
||||
let limiter: DynamicsCompressorNode | null = null;
|
||||
let building = false;
|
||||
|
||||
/** Fetches gains for the tracks from the current one through the lookahead. */
|
||||
export async function ensureGains(): Promise<void> {
|
||||
const ids = player.queue
|
||||
.slice(Math.max(0, player.index), player.index + LOOKAHEAD)
|
||||
.map((t) => t.id)
|
||||
.filter((id) => !gains.has(id) && !inflight.has(id));
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const batch = ids.slice(i, i + BATCH);
|
||||
batch.forEach((id) => inflight.add(id));
|
||||
try {
|
||||
const res = await getReplayGain(batch);
|
||||
for (const id of batch) gains.set(id, res.items[id] ?? null);
|
||||
} catch {
|
||||
// Left unset, so the next queue change asks again. Meanwhile the
|
||||
// track plays as mastered.
|
||||
} finally {
|
||||
batch.forEach((id) => inflight.delete(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The gain for the queue item at `index`, in dB; 0 until its gain is known. */
|
||||
export function gainDbAt(index: number): number {
|
||||
const t = player.queue[index];
|
||||
if (!t) return 0;
|
||||
return gainDb(normalization.value, gains.get(t.id) ?? undefined, playingAsAlbum(player.queue, index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the element's volume to the user's volume times `fade`, leveled by
|
||||
* `db`. Called from the layout's volume effect on every position update.
|
||||
*/
|
||||
export function applyGain(el: HTMLAudioElement, base: number, db: number): void {
|
||||
const linear = dbToLinear(db);
|
||||
if (!stage.graph || !ctx || !gainNode) {
|
||||
el.volume = Math.min(1, base * Math.min(linear, 1));
|
||||
return;
|
||||
}
|
||||
el.volume = base;
|
||||
if (Math.abs(gainNode.gain.value - linear) > 1e-4) {
|
||||
// A short ramp: a step in gain mid-signal is an audible click.
|
||||
gainNode.gain.setTargetAtTime(linear, ctx.currentTime, 0.02);
|
||||
}
|
||||
configureLimiter();
|
||||
}
|
||||
|
||||
export function hasGraph(): boolean {
|
||||
return stage.graph;
|
||||
}
|
||||
|
||||
// The limiter holds true peaks under -1 dBFS. In headroom mode the gain
|
||||
// already keeps them there, so the compressor is set to pass audio through
|
||||
// unchanged (ratio 1) rather than removed: the graph stays fixed.
|
||||
function configureLimiter(): void {
|
||||
if (!limiter || !ctx) return;
|
||||
const on = normalization.value.boost === 'limiter';
|
||||
const ratio = on ? 20 : 1;
|
||||
if (limiter.ratio.value !== ratio) {
|
||||
limiter.threshold.setValueAtTime(-1, ctx.currentTime);
|
||||
limiter.knee.setValueAtTime(0, ctx.currentTime);
|
||||
limiter.ratio.setValueAtTime(ratio, ctx.currentTime);
|
||||
limiter.attack.setValueAtTime(0.003, ctx.currentTime);
|
||||
limiter.release.setValueAtTime(0.25, ctx.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes `el` through the gain graph, if that has not happened yet and a
|
||||
* running AudioContext can be had. Browsers start a context suspended until
|
||||
* the page has had a user gesture; the element is rerouted only after the
|
||||
* context is confirmed running, so a refused context costs nothing and the
|
||||
* next gesture tries again.
|
||||
*/
|
||||
export async function ensureGraph(el: HTMLAudioElement): Promise<void> {
|
||||
if (stage.graph || building || isIOS()) return;
|
||||
const AC: typeof AudioContext | undefined =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return;
|
||||
building = true;
|
||||
const c = new AC();
|
||||
try {
|
||||
if (c.state !== 'running') await c.resume().catch(() => {});
|
||||
if (c.state !== 'running') {
|
||||
await c.close().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const source = c.createMediaElementSource(el);
|
||||
const g = c.createGain();
|
||||
const lim = c.createDynamicsCompressor();
|
||||
source.connect(g).connect(lim).connect(c.destination);
|
||||
ctx = c;
|
||||
gainNode = g;
|
||||
limiter = lim;
|
||||
// Start at what the element was playing at, so the switch is silent.
|
||||
g.gain.value = 1;
|
||||
configureLimiter();
|
||||
stage.graph = true;
|
||||
} catch {
|
||||
await c.close().catch(() => {});
|
||||
} finally {
|
||||
building = false;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS suspends Web Audio when the screen locks, and a routed element goes
|
||||
// silent with it, so iOS never gets the graph. It ignores element.volume
|
||||
// too, so leveling there waits for the server's leveled stream (#5001).
|
||||
function isIOS(): boolean {
|
||||
const n = navigator;
|
||||
return /iPad|iPhone|iPod/.test(n.userAgent) || (n.platform === 'MacIntel' && n.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes the context if the browser suspended it (a backgrounded tab, an
|
||||
* interruption). Routed audio plays only while the context runs.
|
||||
*/
|
||||
export function resumeGraph(): void {
|
||||
if (ctx && ctx.state !== 'running') void ctx.resume().catch(() => {});
|
||||
}
|
||||
@@ -28,6 +28,14 @@
|
||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||
import { audioLoader } from '$lib/player/audioLoader';
|
||||
import { attachStallRetry } from '$lib/player/stallRetry';
|
||||
import {
|
||||
applyGain,
|
||||
ensureGains,
|
||||
ensureGraph,
|
||||
gainDbAt,
|
||||
resumeGraph
|
||||
} from '$lib/player/gainStage.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
let audioEl: HTMLAudioElement | undefined = $state();
|
||||
@@ -95,8 +103,39 @@
|
||||
// Per-position fade scalar: 1 normally, ramping in/out of the
|
||||
// crossfade window at track boundaries. Pure function of position
|
||||
// + duration + the operator's chosen crossfade duration.
|
||||
// Leveling (#4999) multiplies in last; the prefetch element is muted
|
||||
// and never leveled.
|
||||
const scalar = deriveFadeScalar(player.position, player.duration, player.crossfadeSec);
|
||||
audioEl.volume = player.volume * scalar;
|
||||
applyGain(audioEl, player.volume * scalar, gainDbAt(player.index));
|
||||
});
|
||||
|
||||
// Gains for the current track and those after it, fetched as the queue
|
||||
// moves so each track's gain is in hand before it starts.
|
||||
$effect(() => {
|
||||
if (user.value === null) return;
|
||||
player.queue;
|
||||
player.index;
|
||||
untrack(() => void ensureGains());
|
||||
});
|
||||
|
||||
// A boost needs the Web Audio graph. It is built when the current or next
|
||||
// track wants one, and from a user gesture if the browser refused a
|
||||
// context without one.
|
||||
const wantsBoost = $derived(gainDbAt(player.index) > 0 || gainDbAt(player.index + 1) > 0);
|
||||
$effect(() => {
|
||||
if (!audioEl || !wantsBoost) return;
|
||||
const el = audioEl;
|
||||
untrack(() => void ensureGraph(el));
|
||||
const onGesture = () => {
|
||||
resumeGraph();
|
||||
void ensureGraph(el);
|
||||
};
|
||||
document.addEventListener('pointerdown', onGesture);
|
||||
document.addEventListener('keydown', onGesture);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onGesture);
|
||||
document.removeEventListener('keydown', onGesture);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -105,6 +144,7 @@
|
||||
// emitted 'playing' yet, but we still need to call play() to get there.
|
||||
const intent = player.state === 'playing' || player.state === 'loading';
|
||||
if (intent) {
|
||||
resumeGraph();
|
||||
audioEl.play().catch(() => { /* interrupted / blocked — store gets the event */ });
|
||||
} else {
|
||||
audioEl.pause();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { bootstrap, user } from '$lib/auth/store.svelte';
|
||||
import { restoreQueue } from '$lib/player/store.svelte';
|
||||
import { loadNormalization } from '$lib/stores/normalization.svelte';
|
||||
|
||||
export const ssr = false; // adapter-static fallback; we're SPA-only
|
||||
export const prerender = false;
|
||||
@@ -8,6 +9,10 @@ export const prerender = false;
|
||||
export const load: LayoutLoad = async () => {
|
||||
await bootstrap();
|
||||
const userId = user.value?.id;
|
||||
if (userId) restoreQueue(userId);
|
||||
if (userId) {
|
||||
restoreQueue(userId);
|
||||
// Not awaited: the cached preference levels playback until it lands.
|
||||
void loadNormalization();
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user