fix(player): show live track metadata, not the stale queue snapshot
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

The queue slice stores denormalized display fields captured at play-time, so the
player and queue panel kept showing pre-enrichment title/artist after a track's
metadata was updated — the library (RTKQ cache) and the player disagreed.

Add useResolvedQueueEntry: read through to the RTKQ Track cache and prefer its
fresh values, keeping the snapshot only as instant/offline fallback. Wire it into
PersistentPlayer (now-playing + cover) and QueuePanel (now-playing + up-next
rows), so enrichment updates reach the player through the same Track tags that
refresh the library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-13 13:37:34 +03:00
parent 42080b37ea
commit 9c344b98c4
3 changed files with 97 additions and 32 deletions
+14 -5
View File
@@ -15,8 +15,9 @@ import {
} from '../../store/slices/player'; } from '../../store/slices/player';
import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { useStreamCached } from '../../hooks/useStreamCached'; import { useStreamCached } from '../../hooks/useStreamCached';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() { export function PersistentPlayer() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,7 +25,11 @@ export function PersistentPlayer() {
const { seek, playNext, playPrev } = useAudioPlayer(); const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player); const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const token = useAppSelector((s) => s.auth.accessToken);
const currentEntry = queue.entries[queue.currentIndex]; const currentEntry = queue.entries[queue.currentIndex];
// Read through to the live Track cache so enrichment updates reach the player,
// not just the play-time snapshot frozen in the queue slice.
const current = useResolvedQueueEntry(currentEntry);
// Source indicator: cached → playing locally, otherwise streaming. // Source indicator: cached → playing locally, otherwise streaming.
const cached = useStreamCached(currentEntry?.trackId); const cached = useStreamCached(currentEntry?.trackId);
@@ -32,8 +37,12 @@ export function PersistentPlayer() {
return <div className="player empty">{t('player.nothingPlaying')}</div>; return <div className="player empty">{t('player.nothingPlaying')}</div>;
} }
const artUrl = getCoverUrl(currentEntry?.albumArtUrl); const artUrl =
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? ''; getCoverUrl(currentEntry?.albumArtUrl) ??
(token && current?.hasCover
? getTrackCoverUrl(current.trackId, token, true)
: undefined);
const seedLabel = current?.albumTitle ?? current?.title ?? '';
const onStream = !cached; const onStream = !cached;
return ( return (
@@ -45,8 +54,8 @@ export function PersistentPlayer() {
> >
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} /> <ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
<div className="pl-now-tt"> <div className="pl-now-tt">
<div className="t">{currentEntry?.title ?? '—'}</div> <div className="t">{current?.title ?? '—'}</div>
<div className="a">{currentEntry?.artistName ?? ''}</div> <div className="a">{current?.artistName ?? ''}</div>
<div <div
className="pl-srcbadge" className="pl-srcbadge"
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }} style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
+46 -27
View File
@@ -7,8 +7,10 @@ import {
goToIndex, goToIndex,
removeFromQueue, removeFromQueue,
clearQueue, clearQueue,
type QueueEntry,
} from '../../store/slices/queue'; } from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player'; import { toggleQueue } from '../../store/slices/player';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
export function QueuePanel() { export function QueuePanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,8 +18,9 @@ export function QueuePanel() {
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen); const isOpen = useAppSelector((s) => s.player.isQueueOpen);
const now = const nowEntry =
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined; queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const now = useResolvedQueueEntry(nowEntry);
const upNext = queue.entries const upNext = queue.entries
.map((entry, index) => ({ entry, index })) .map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex); .filter(({ index }) => index > queue.currentIndex);
@@ -120,33 +123,12 @@ export function QueuePanel() {
<div className="qd-empty">{t('queue.nothingNext')}</div> <div className="qd-empty">{t('queue.nothingNext')}</div>
) : ( ) : (
upNext.map(({ entry, index }) => ( upNext.map(({ entry, index }) => (
<div <QueueRow
key={`${entry.trackId}-${index}`} key={`${entry.trackId}-${index}`}
className="qrow" entry={entry}
onDoubleClick={() => dispatch(goToIndex(index))} onPlay={() => dispatch(goToIndex(index))}
title={t('queue.doubleClickPlay')} onRemove={() => dispatch(removeFromQueue(index))}
> />
<span className="grip">
<Icon name="dots-six-vertical" />
</span>
<ArtTile
seed={entry.albumTitle}
size={36}
label={entry.albumTitle}
/>
<div className="qt">
<div className="t">{entry.title}</div>
<div className="r">{entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
</div>
)) ))
)} )}
@@ -162,3 +144,40 @@ export function QueuePanel() {
</aside> </aside>
); );
} }
/** An "up next" row, resolving its display fields against the live Track cache
* (same read-through as the now-playing entry) so enrichment updates show. */
function QueueRow({
entry,
onPlay,
onRemove,
}: {
entry: QueueEntry;
onPlay: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const resolved = useResolvedQueueEntry(entry);
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
return (
<div className="qrow" onDoubleClick={onPlay} title={t('queue.doubleClickPlay')}>
<span className="grip">
<Icon name="dots-six-vertical" />
</span>
<ArtTile seed={albumTitle} size={36} label={albumTitle} />
<div className="qt">
<div className="t">{resolved?.title ?? entry.title}</div>
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={onRemove}
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useGetTrackQuery } from '../api/endpoints/library';
import type { QueueEntry } from '../store/slices/queue';
export interface ResolvedQueueEntry {
trackId: string;
title: string;
artistName: string;
albumTitle: string;
durationMs: number;
hasCover: boolean;
}
/**
* Merge a queue entry's play-time snapshot with the live `Track` cache.
*
* The queue slice stores denormalized display fields (title/artist/…) captured
* when a track was queued, so they go stale after metadata enrichment updates
* the track. This reads through to the RTKQ `Track` cache — invalidated by the
* same tags that refresh the library — and prefers its fresh values, falling
* back to the snapshot for instant render and offline. Returns undefined when
* there is no current entry.
*/
export function useResolvedQueueEntry(
entry: QueueEntry | undefined,
): ResolvedQueueEntry | undefined {
const { data } = useGetTrackQuery(entry?.trackId ?? skipToken);
if (!entry) return undefined;
return {
trackId: entry.trackId,
title: data?.title ?? entry.title,
artistName: data?.artistName ?? entry.artistName,
albumTitle: data?.albumTitle ?? entry.albumTitle,
durationMs: data?.durationMs ?? entry.durationMs,
hasCover: data?.hasCover ?? false,
};
}