music (web): resume currently-playing on reload/return

The web player kept no durable state, so a reload (and a return to /music,
whose cwd resets) lost your place. Wire it to the platform's per-user
/api/music/now-playing (same endpoints the app uses):

- MusicPlayerHost persists a snapshot (track + position) on play/pause + track
  change + a 10s heartbeat (position read via ref so the heartbeat stays live).
- On first load with an empty queue it restores that snapshot: rebuilds the
  album queue (sortTracks), loads it PAUSED (browsers block autoplay on reload),
  and seeks to the saved position once metadata is in. A restore guard stops the
  load from clobbering the saved position with 0.
- MusicDetail auto-opens the currently-playing album once on mount (when it has
  no location yet), so /music lands on the track — without yanking you back
  after you navigate away.

Adds loadQueue() (paused) to useMusicPlayer + a NowPlaying type. tsgo clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 12:24:47 +00:00
co-authored by Claude Opus 4.8
parent 759dcb8b5b
commit 0995aaa8af
4 changed files with 110 additions and 8 deletions
@@ -3,7 +3,7 @@ import { useClient } from 'hooks/useClient';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
import { useSeekBar, SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
import { trackHomePath } from '../apps/Music/shared';
import { sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
// Mounted once in the persistent DashboardLayout (outside <Routes>), so it owns the single <audio>
@@ -14,8 +14,8 @@ const fmt = (s: number): string =>
Number.isFinite(s) && s >= 0 ? `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` : '0:00';
export const MusicPlayerHost = () => {
const { token } = useClient();
const { current, index, queue, playing, toggle, next, prev, setPlaying, close } = useMusicPlayer();
const { token, get, put } = useClient();
const { current, index, queue, playing, toggle, next, prev, setPlaying, close, loadQueue } = useMusicPlayer();
const audioRef = useRef<HTMLAudioElement>(null);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
@@ -26,13 +26,74 @@ export const MusicPlayerHost = () => {
const [muted, setMuted] = useState(false);
const { barRef, onSeekDown } = useSeekBar(audioRef, duration);
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
// Latest position/duration in refs so persist reads fresh values without re-subscribing. seekToRef
// holds a pending seek (restore); isRestoringRef suppresses persist during the restore load so it
// doesn't clobber the saved position with 0; restoredRef makes restore run once.
const positionRef = useRef(0);
positionRef.current = position;
const durationRef = useRef(0);
durationRef.current = duration;
const restoredRef = useRef(false);
const isRestoringRef = useRef(false);
const seekToRef = useRef<number | null>(null);
const persist = () => {
if (!current) return;
put('/music/now-playing', {
homePath: trackHomePath(current.albumRel, current.file),
dir: `Music/${current.albumRel}`,
title: current.title ?? '',
artist: current.artist ?? '',
album: current.albumRel.split('/').pop() ?? '',
durationSec: Math.round(durationRef.current) || 0,
positionSec: Math.round(positionRef.current) || 0,
}).catch(() => {});
};
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
if (queue.length) return;
(async () => {
const snap = await get<NowPlaying | null>('/music/now-playing').catch(() => null);
if (!snap?.homePath) return;
const albumRel = (snap.dir || snap.homePath.split('/').slice(0, -1).join('/')).replace(/^Music\//, '');
const file = snap.homePath.split('/').pop() ?? '';
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`).catch(() => null);
if (!meta) return;
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
const idx = Math.max(0, q.findIndex((t) => t.file === file));
isRestoringRef.current = true;
seekToRef.current = snap.positionSec > 0 ? snap.positionSec : null;
loadQueue(q, idx);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist on play/pause + track change (not during the restore load).
useEffect(() => {
if (isRestoringRef.current) return;
persist();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trackKey, playing]);
// Heartbeat while playing, so the saved position keeps up.
useEffect(() => {
if (!playing || !current) return;
const id = setInterval(persist, 10000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, trackKey]);
const withToken = (u: string) => (token ? `${u}${u.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : u);
const streamUrl = (t: PlayerTrack) =>
withToken(`${MUSIC_API}/stream?path=${encodeURIComponent(`Music/${t.albumRel}/${t.file}`)}`);
const coverUrl = (rel: string) => withToken(`${MUSIC_API}/cover?path=${encodeURIComponent(rel)}`);
const trackKey = current ? `${current.albumRel}/${current.file}` : '';
// Load the current track when it changes.
useEffect(() => {
const a = audioRef.current;
@@ -73,7 +134,16 @@ export const MusicPlayerHost = () => {
ref={audioRef}
preload="metadata"
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onLoadedMetadata={(e) => {
setDuration(e.currentTarget.duration);
// Apply a pending restore seek once the track's duration is known, then re-enable persist.
if (seekToRef.current != null) {
e.currentTarget.currentTime = seekToRef.current;
setPosition(seekToRef.current);
seekToRef.current = null;
}
isRestoringRef.current = false;
}}
onEnded={() => next()}
/>
{current && (
@@ -27,6 +27,10 @@ export function useMusicPlayer() {
const playQueue = (queue: PlayerTrack[], index = 0) =>
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: true });
// Like playQueue but paused — for restoring a saved "currently playing" on load without auto-playing
// (browsers block autoplay on reload anyway; the user resumes with a click).
const loadQueue = (queue: PlayerTrack[], index = 0) =>
setState({ queue, index: Math.max(0, Math.min(index, Math.max(0, queue.length - 1))), playing: false });
const toggle = () => setState((s) => ({ ...s, playing: !s.playing }));
const setPlaying = (playing: boolean) => setState((s) => ({ ...s, playing }));
const jump = (index: number) =>
@@ -37,5 +41,5 @@ export function useMusicPlayer() {
const close = () => setState(INITIAL);
const current = state.queue[state.index];
return { ...state, current, playQueue, toggle, setPlaying, jump, next, prev, close };
return { ...state, current, playQueue, loadQueue, toggle, setPlaying, jump, next, prev, close };
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, ChevronLeft } from 'lucide-react';
@@ -37,6 +37,22 @@ export const MusicDetail = () => {
const [disco, setDisco] = useState<Discography | null>(null);
const [loading, setLoading] = useState(false);
// On first mount with no location yet, open the currently-playing album — so a reload/return lands on
// the track you were listening to (the player itself restores via the saved now-playing snapshot).
// Once only, so it never yanks you back after you navigate away (e.g. up to the library root).
const autoNavRef = useRef(false);
useEffect(() => {
if (autoNavRef.current) return;
if (cwd) {
autoNavRef.current = true;
return;
}
if (player.current) {
autoNavRef.current = true;
setCwd(`${MUSIC_ROOT}/${player.current.albumRel}`);
}
}, [player.current, cwd, setCwd]);
useEffect(() => {
get<Manifest>('/music/manifest')
.then((m) => setManifest(m.albums))
@@ -40,6 +40,18 @@ export type Discography = { artist: string; albums: Record<string, string> };
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
/** Per-user "currently playing" snapshot (GET/PUT /api/music/now-playing). */
export type NowPlaying = {
homePath: string;
dir: string;
title: string;
artist: string;
album: string;
durationSec: number;
positionSec: number;
updatedAt: string;
};
/** homePath ("Music/<rel>/<file>") for a track — its favorite key + /stream path. */
export const trackHomePath = (rel: string, file: string) => `${MUSIC_ROOT}/${rel}/${file}`;