fix(player): show live track metadata, not the stale queue snapshot
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:
@@ -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)' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user