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:
@@ -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}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user