Files
muzick/frontend/src/components/AudioEngine.tsx
T
kami dea08f9c47 feat(mobile): install Muzick to the home screen, and get there quickly
Adds the manifest, icons and service worker that make the app
installable, and offers it as a toast once Chrome says it qualifies.
Declining snoozes the offer for a month; installing ends it.

A waiting service worker never activates on its own. Reloading the page
under a listener to swap in a new build would cut the song they are in
the middle of, so updates land on the next cold start instead. Audio is
kept out of the cache entirely: range requests and multi-megabyte bodies
do not belong in a shell cache. Artwork is cached, and the SPA
navigation fallback denies /api so it cannot swallow the event stream.

Installed on Android the app paints edge to edge, so the transport pads
itself past the gesture bar. MediaSession gains setPositionState, which
is what gives the notification shade a seek bar that moves.

Three things kept the bundle from ever being compressed, each hiding the
next: the nginx image ships with gzip off, gzip_proxied defaults to off
and skips anything carrying a Via header, and gzip_http_version defaults
to 1.1 while the host proxy speaks 1.0. With those fixed and the pages
split per route, the first load goes from 555KB to 60KB of app code plus
a vendor chunk that survives redeploys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:19 +04:00

637 lines
24 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { trackService } from '../services/trackService';
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
import { PREFETCH_LEAD_SECONDS } from '../lib/playbackPrefs';
import type { Track } from '../types';
// Threshold (seconds) above which a store position change is treated as a user
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
// effect loop from fighting itself.
const SEEK_THRESHOLD = 1;
// If a track reaches this fraction of its duration, treat it as "effectively
// completed" even if the user clicks Next before the very end.
const COMPLETION_THRESHOLD = 0.95;
// Relative seek increment (seconds) for MediaSession seekforward/seekbackward.
const SEEK_INCREMENT = 10;
// Fade granularity. Fine enough to be inaudible, coarse enough to be cheap.
const FADE_TICK_MS = 40;
// With crossfade off there is no overlap to hide Vibe's replan round-trip, so
// the handover still starts this early — the next element just waits, silent,
// until the current one actually ends.
const HANDOFF_LEAD_MS = 1200;
// If the outgoing element never reports `ended` (a stalled or broken stream),
// start the waiting track anyway this long after its lead began.
const JOIN_TIMEOUT_MS = HANDOFF_LEAD_MS + 2000;
/** Build the artwork URLs for MediaSession metadata (OS media controls). */
function buildArtwork(track: Track): MediaImage[] {
const sizes = [96, 128, 192, 256, 384, 512];
const artwork = track.artwork_id;
if (!artwork) return [];
// artwork_id is either a full URL (external) or a relative path served by us.
const url = artwork.startsWith('http')
? artwork
: `${window.location.origin}${artwork}`;
return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' }));
}
/**
* The id of the track ordinary queue navigation will play next, or null when it
* cannot be known ahead of time (shuffle) or there is nothing after this one.
* Vibe's next track comes from the server, so this is only a prediction there —
* used to warm the stream, never to decide what actually plays.
*/
function predictNextTrackId(): string | null {
const { queue, currentTrack, currentIndex, shuffle, repeat } = usePlaybackStore.getState();
if (shuffle || repeat === 'one' || queue.length === 0) return null;
const idx =
currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id
? currentIndex
: currentTrack
? queue.findIndex((t) => t.id === currentTrack.id)
: -1;
if (idx < 0) return null;
if (idx + 1 < queue.length) return queue[idx + 1].id;
if (repeat === 'all') return queue[0].id;
return null;
}
// Headless audio engine: two <audio> elements driven by the playback store, so
// the next track can buffer (and fade in) while the current one is still
// playing. State -> DOM via store subscriptions; DOM -> state via media events.
export const AudioEngine = () => {
const audioARef = useRef<HTMLAudioElement>(null);
const audioBRef = useRef<HTMLAudioElement>(null);
// Index of the element the store's currentTrack is playing on; the other one
// is idle, prefetching, or fading out after being retired.
const activeIdxRef = useRef(0);
// Per-element fade multiplier applied on top of the store's master volume.
const gainRef = useRef([1, 1]);
const fadeTimerRef = useRef<Array<ReturnType<typeof setInterval> | null>>([null, null]);
// The idle element holds this track id, loaded but never yet played.
const preparedRef = useRef<{ id: string; idx: number } | null>(null);
// Set when the handover window triggered the track change before `ended`, so
// the ending element's own event does not advance a second time.
const earlyAdvanceRef = useRef(false);
// Cancels a pending gapless join (next track loaded, waiting for the tail).
const cancelJoinRef = useRef<(() => void) | null>(null);
// Track which id is currently loaded into the active element, and whether it
// ended naturally (so we record COMPLETED, not skip, on the track change).
const loadedIdRef = useRef<string | null>(null);
const endedNaturallyRef = useRef(false);
// Track whether the current track has crossed the completion threshold.
const crossedThresholdRef = useRef(false);
const lastProgressSecondRef = useRef(-1);
const streamErrorTrackIdRef = useRef<string | null>(null);
const elements = useCallback((): HTMLAudioElement[] => {
const a = audioARef.current;
const b = audioBRef.current;
return a && b ? [a, b] : [];
}, []);
const applyGain = useCallback((idx: number) => {
const els = elements();
if (!els[idx]) return;
const master = usePlaybackStore.getState().volume;
els[idx].volume = Math.min(1, Math.max(0, master * gainRef.current[idx]));
}, [elements]);
const stopFade = useCallback((idx: number) => {
const timer = fadeTimerRef.current[idx];
if (timer !== null) {
clearInterval(timer);
fadeTimerRef.current[idx] = null;
}
}, []);
const setGain = useCallback((idx: number, gain: number) => {
stopFade(idx);
gainRef.current[idx] = Math.min(1, Math.max(0, gain));
applyGain(idx);
}, [applyGain, stopFade]);
/** Ramp one element's gain, then run `onDone`. Instant when ms <= 0. */
const fadeTo = useCallback((idx: number, target: number, ms: number, onDone?: () => void) => {
stopFade(idx);
if (ms <= 0) {
setGain(idx, target);
onDone?.();
return;
}
const from = gainRef.current[idx];
const steps = Math.max(1, Math.round(ms / FADE_TICK_MS));
let step = 0;
fadeTimerRef.current[idx] = setInterval(() => {
step += 1;
gainRef.current[idx] = from + ((target - from) * step) / steps;
applyGain(idx);
if (step >= steps) {
stopFade(idx);
setGain(idx, target);
onDone?.();
}
}, FADE_TICK_MS);
}, [applyGain, setGain, stopFade]);
/** Take an element out of service: silence it and drop its stream. */
const retire = useCallback((idx: number) => {
const els = elements();
const el = els[idx];
if (!el) return;
stopFade(idx);
el.pause();
el.removeAttribute('src');
el.load();
setGain(idx, 1);
}, [elements, setGain, stopFade]);
const cancelJoin = useCallback(() => {
const cancel = cancelJoinRef.current;
cancelJoinRef.current = null;
cancel?.();
}, []);
/**
* Crossfade-off handover: the next track is already loaded and the store has
* already moved on, but the outgoing element still has its tail to play.
* Start the new one the moment that tail ends, so the join has no gap and no
* overlap.
*/
const scheduleJoin = useCallback((oldIdx: number, nextIdx: number) => {
const els = elements();
if (els.length === 0) return;
const oldEl = els[oldIdx];
let settled = false;
const cleanup = () => {
clearTimeout(timer);
oldEl.removeEventListener('ended', join);
oldEl.removeEventListener('error', join);
};
function join() {
if (settled) return;
settled = true;
cleanup();
cancelJoinRef.current = null;
retire(oldIdx);
// A newer track change may already own the pipeline.
if (activeIdxRef.current !== nextIdx) return;
if (!usePlaybackStore.getState().isPlaying) return;
setGain(nextIdx, 1);
void els[nextIdx].play().catch(() => {});
}
const timer = setTimeout(join, JOIN_TIMEOUT_MS);
oldEl.addEventListener('ended', join);
oldEl.addEventListener('error', join);
cancelJoinRef.current = () => {
if (settled) return;
settled = true;
cleanup();
retire(oldIdx);
};
}, [elements, retire, setGain]);
// --- DOM -> store: media events -----------------------------------------
useEffect(() => {
const els = elements();
if (els.length === 0) return;
const store = usePlaybackStore.getState;
/** Warm the predicted next track into the idle element. */
const prefetch = () => {
const playback = store();
if (!playback.prefetchNext || preparedRef.current) return;
const nextId = predictNextTrackId();
if (!nextId || nextId === loadedIdRef.current) return;
const idleIdx = 1 - activeIdxRef.current;
const idle = els[idleIdx];
setGain(idleIdx, 1);
idle.preload = 'auto';
idle.src = trackService.getStreamUrl(nextId);
idle.load();
preparedRef.current = { id: nextId, idx: idleIdx };
};
/**
* Hand over to the next track before `ended`, so Vibe's feedback/replan
* round-trip happens while the tail of this one is still playing. With a
* crossfade the tail fades under the new track; without one the new track
* waits, silent, for the tail to finish.
*/
const maybeAdvanceEarly = (audio: HTMLAudioElement) => {
const playback = store();
const leadMs = playback.crossfadeMs > 0 ? playback.crossfadeMs : HANDOFF_LEAD_MS;
if (earlyAdvanceRef.current || !playback.isPlaying) return;
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
if (audio.duration * 1000 <= leadMs * 2) return;
if (audio.duration - audio.currentTime > leadMs / 1000) return;
const vibeOwned = playback.queueOwner === 'vibe' && !!playback.vibeAdvanceHandler;
// Ordinary playback must have somewhere to go; otherwise let the element
// finish on its own so end-of-queue still stops cleanly.
if (!vibeOwned && !predictNextTrackId()) return;
earlyAdvanceRef.current = true;
endedNaturallyRef.current = true;
store().nextWithReason('completed');
};
const onTimeUpdate = (idx: number, audio: HTMLAudioElement) => {
if (idx !== activeIdxRef.current) return;
store().setPosition(audio.currentTime);
// Mark as effectively completed if we cross the threshold.
if (
!crossedThresholdRef.current &&
audio.duration &&
audio.currentTime / audio.duration >= COMPLETION_THRESHOLD
) {
crossedThresholdRef.current = true;
}
const vibe = useVibeStore.getState();
const track = store().currentTrack;
const elapsed = Math.floor(audio.currentTime);
if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
lastProgressSecondRef.current = elapsed;
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
}
if (Number.isFinite(audio.duration) && audio.duration - audio.currentTime <= PREFETCH_LEAD_SECONDS) {
prefetch();
}
maybeAdvanceEarly(audio);
};
const onLoadedMetadata = (idx: number, audio: HTMLAudioElement) => {
// The idle element's metadata says nothing about what is playing.
if (idx !== activeIdxRef.current) return;
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
};
const onPlay = (idx: number) => {
if (idx !== activeIdxRef.current) return;
if (!store().isPlaying) store().play();
};
const onPause = (idx: number, audio: HTMLAudioElement) => {
if (idx !== activeIdxRef.current) return;
// Ignore the pause that fires as part of ending a track.
if (audio.ended) return;
if (store().isPlaying) store().pause();
};
const onEnded = (idx: number, audio: HTMLAudioElement) => {
// A retired element reaching its end is expected during a crossfade.
if (idx !== activeIdxRef.current) return;
// The crossfade window already handed over; do not advance twice.
if (earlyAdvanceRef.current) {
audio.pause();
return;
}
// Just flag it — applyTrack (below) is the single place that sends
// feedback, on the resulting track-change, so completion is recorded
// exactly once per track.
endedNaturallyRef.current = true;
// Vibe's durable feedback/replan handshake can take longer than the
// browser's end transition. Explicitly pause the ended element so it
// cannot auto-resume from its final buffered samples while that work is
// in flight. applyTrack will start the next source when ready.
audio.pause();
store().nextWithReason('completed');
};
const onError = (idx: number) => {
if (idx !== activeIdxRef.current) return;
const playback = store();
const track = playback.currentTrack;
const vibe = useVibeStore.getState();
// Metadata can be available while the stream itself is no longer
// readable. Vibe must advance that exact durable cursor, not fall back
// to ordinary queue navigation or feedback-driven replanning.
if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
streamErrorTrackIdRef.current = track.id;
void advancePastUnplayableVibeTrack(track.id)
.catch(() => undefined)
.finally(() => { streamErrorTrackIdRef.current = null; });
};
const teardown = els.map((audio, idx) => {
const listeners: Array<[string, () => void]> = [
['timeupdate', () => onTimeUpdate(idx, audio)],
['loadedmetadata', () => onLoadedMetadata(idx, audio)],
['play', () => onPlay(idx)],
['pause', () => onPause(idx, audio)],
['ended', () => onEnded(idx, audio)],
['error', () => onError(idx)],
];
for (const [event, handler] of listeners) audio.addEventListener(event, handler);
return () => {
for (const [event, handler] of listeners) audio.removeEventListener(event, handler);
};
});
return () => { for (const off of teardown) off(); };
}, [elements, setGain]);
// --- store -> DOM: react to currentTrack changes ------------------------
useEffect(() => {
const els = elements();
if (els.length === 0) return;
const applyTrack = (id: string | null) => {
if (id === loadedIdRef.current) return;
// A newer track change supersedes any tail still waiting to hand over.
cancelJoin();
// The durable Vibe controller owns normal next/ended navigation. It
// records the outcome, receives a new plan revision, then calls the raw
// advance method. Do not emit a second event here after that transition.
const prevId = loadedIdRef.current;
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
const playback = usePlaybackStore.getState();
const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
}
const wasEarlyAdvance = earlyAdvanceRef.current;
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
earlyAdvanceRef.current = false;
lastProgressSecondRef.current = -1;
loadedIdRef.current = id;
const oldIdx = activeIdxRef.current;
const oldEl = els[oldIdx];
if (!id) {
retire(oldIdx);
return;
}
// Reuse the prefetched element when it holds exactly this track; that
// stream is already buffered, so playback starts without a fetch. With
// nothing playing yet there is no tail to preserve, so load in place.
const prepared = preparedRef.current;
const reusePrefetched = prepared?.id === id && prepared.idx !== oldIdx;
const nextIdx = reusePrefetched ? prepared!.idx : prevId ? 1 - oldIdx : oldIdx;
const nextEl = els[nextIdx];
preparedRef.current = null;
if (!reusePrefetched) {
nextEl.src = trackService.getStreamUrl(id);
nextEl.load();
} else if (nextEl.currentTime > 0) {
nextEl.currentTime = 0;
}
activeIdxRef.current = nextIdx;
// Only an early handover leaves a tail to deal with; an element that
// already ended has nothing left to play.
const crossfadeMs = playback.crossfadeMs;
const hasTail = wasEarlyAdvance && nextIdx !== oldIdx && !oldEl.paused && !oldEl.ended;
const fadeMs = hasTail ? crossfadeMs : 0;
// Tail with no crossfade: hold the new track until the tail is done.
const deferStart = hasTail && crossfadeMs <= 0;
if (nextIdx !== oldIdx) {
if (fadeMs > 0) fadeTo(oldIdx, 0, fadeMs, () => retire(oldIdx));
else if (deferStart) scheduleJoin(oldIdx, nextIdx);
else retire(oldIdx);
}
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
setGain(nextIdx, fadeMs > 0 ? 0 : 1);
if (deferStart) return;
if (usePlaybackStore.getState().isPlaying) {
void nextEl.play().catch(() => {});
if (fadeMs > 0) fadeTo(nextIdx, 1, fadeMs);
} else if (fadeMs > 0) {
setGain(nextIdx, 1);
}
};
// Apply the current value immediately, then subscribe to future changes.
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
const unsub = usePlaybackStore.subscribe((state) => {
applyTrack(state.currentTrack?.id ?? null);
});
return unsub;
}, [cancelJoin, elements, fadeTo, retire, scheduleJoin, setGain]);
// --- store -> DOM: isPlaying ------------------------------------------------
useEffect(() => {
const els = elements();
if (els.length === 0) return;
const apply = (isPlaying: boolean) => {
const audio = els[activeIdxRef.current];
if (isPlaying) {
// Never resume a finished element. Between `ended` and the next source
// being loaded, isPlaying is still true, and resuming here replays the
// final buffered milliseconds of the track that just finished.
if (audio.ended) return;
if (audio.paused) void audio.play().catch(() => {});
} else {
// Pausing ends the handover: the tail is dropped and the waiting track
// becomes the one that resumes.
cancelJoin();
for (const el of els) if (!el.paused) el.pause();
}
};
// Gate on an actual change: the subscription is unselected, so it fires on
// every store write, including the queue/plan writes that happen while
// Vibe's advance handshake is in flight.
let lastIsPlaying = usePlaybackStore.getState().isPlaying;
apply(lastIsPlaying);
return usePlaybackStore.subscribe((state) => {
if (state.isPlaying === lastIsPlaying) return;
lastIsPlaying = state.isPlaying;
apply(lastIsPlaying);
});
}, [cancelJoin, elements]);
// --- store -> DOM: volume --------------------------------------------------
useEffect(() => {
const els = elements();
if (els.length === 0) return;
// Master volume is scaled by each element's fade gain.
const apply = () => els.forEach((_, idx) => applyGain(idx));
let lastVolume = usePlaybackStore.getState().volume;
apply();
return usePlaybackStore.subscribe((state) => {
if (state.volume === lastVolume) return;
lastVolume = state.volume;
apply();
});
}, [applyGain, elements]);
// --- store -> DOM: external seeks (user scrubbing) -------------------------
useEffect(() => {
const els = elements();
if (els.length === 0) return;
const apply = (position: number) => {
const audio = els[activeIdxRef.current];
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
audio.currentTime = position;
}
};
// Only real position changes are seeks; other store writes must not move
// the playhead of a track that is mid-transition.
let lastPosition = usePlaybackStore.getState().position;
return usePlaybackStore.subscribe((state) => {
if (state.position === lastPosition) return;
lastPosition = state.position;
apply(lastPosition);
});
}, [elements]);
// --- MediaSession: hardware media keys + OS media controls ------------------
//
// Without this, the browser's default media-key handler toggles the <audio>
// element directly, bypassing the store — causing the UI and audio to
// desync. By registering action handlers we route all media-key input
// through the store, so isPlaying stays consistent. We also publish track
// metadata so the OS "now playing" widget shows title/artist/artwork.
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const store = usePlaybackStore.getState;
const active = () => elements()[activeIdxRef.current] ?? null;
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
play: () => store().play(),
pause: () => store().pause(),
previoustrack: () => store().prev(),
nexttrack: () => store().next(),
seekbackward: (details) => {
const audio = active();
if (!audio) return;
const delta = details.seekOffset ?? SEEK_INCREMENT;
audio.currentTime = Math.max(0, audio.currentTime - delta);
},
seekforward: (details) => {
const audio = active();
if (!audio) return;
const delta = details.seekOffset ?? SEEK_INCREMENT;
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
},
seekto: (details) => {
const audio = active();
if (!audio || details.seekTime == null) return;
audio.currentTime = details.seekTime;
},
stop: () => {
store().pause();
store().setPosition(0);
},
};
for (const [action, handler] of Object.entries(handlers)) {
try {
navigator.mediaSession.setActionHandler(
action as MediaSessionAction,
handler ?? null,
);
} catch {
// Some actions aren't supported on every browser/OS — ignore.
}
}
// Clean up handlers on unmount so they don't outlive the engine.
return () => {
for (const action of Object.keys(handlers)) {
try {
navigator.mediaSession.setActionHandler(
action as MediaSessionAction,
null,
);
} catch {
/* ignore */
}
}
};
}, [elements]);
// --- MediaSession: publish metadata + playback state ------------------------
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const update = (track: Track | null, isPlaying: boolean) => {
if (track) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || 'Unknown',
artist: track.artist || 'Unknown',
album: '',
artwork: buildArtwork(track),
});
}
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
};
// Publish immediately for the current state.
update(usePlaybackStore.getState().currentTrack, usePlaybackStore.getState().isPlaying);
// Subscribe to future changes of currentTrack or isPlaying.
return usePlaybackStore.subscribe((state) => {
update(state.currentTrack, state.isPlaying);
});
}, []);
// --- MediaSession: publish position -----------------------------------------
//
// Without a position state the Android notification renders a scrubber that is
// stuck at zero. setPositionState throws if position exceeds duration, which
// happens transiently at a track handover, so both are clamped.
useEffect(() => {
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
const publish = (position: number, duration: number) => {
if (!Number.isFinite(duration) || duration <= 0) return;
try {
navigator.mediaSession.setPositionState({
duration,
position: Math.min(Math.max(position, 0), duration),
playbackRate: 1,
});
} catch {
// Stale position against a just-changed duration — the next tick fixes it.
}
};
let lastPosition = -1;
let lastDuration = -1;
return usePlaybackStore.subscribe((state) => {
// timeupdate fires ~4x a second; only republish on a whole-second change.
const second = Math.floor(state.position);
if (second === lastPosition && state.duration === lastDuration) return;
lastPosition = second;
lastDuration = state.duration;
publish(state.position, state.duration);
});
}, []);
// Drop in-flight fades and any pending handover when the engine unmounts.
useEffect(() => () => {
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
cancelJoinRef.current?.();
cancelJoinRef.current = null;
}, []);
return (
<>
<audio ref={audioARef} hidden />
<audio ref={audioBRef} hidden />
</>
);
};