Skip to content

Commit 4c94c5f

Browse files
committed
fix: keep recent metadata when replaying cached tracks
Stop wiping history-seeded titles and music covers on click-to-play when the audio cache resolves with id-only metadata.
1 parent 3c63450 commit 4c94c5f

5 files changed

Lines changed: 88 additions & 24 deletions

File tree

src/components/HistoryList.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import MediaResultRow from "@/components/MediaResultRow";
1717
import { useAppContext } from "@/context/AppContext";
1818
import type { HistoryItem } from "@/interfaces";
1919
import { historyItemSourceUrl, resolveCachedMedia } from "@/lib/playFromCache";
20-
import { timeAgo } from "@/utils";
20+
import { stashSearchMeta } from "@/lib/searchMeta";
21+
import { getYouTubeId, timeAgo } from "@/utils";
2122

2223
interface HistoryListProps {
2324
onPlay?: () => void;
@@ -38,6 +39,9 @@ export default function HistoryList({
3839
onPlay?.();
3940

4041
const url = historyItemSourceUrl(item);
42+
const ytId = getYouTubeId(url ?? item.sourceUrl) ?? item.metadata?.id;
43+
if (ytId) stashSearchMeta(String(ytId), item.metadata);
44+
4145
const cached = await resolveCachedMedia(item);
4246
if (cached) {
4347
// Pass url for YouTube so embed id resolves; IDB stays audio-only.
@@ -50,7 +54,17 @@ export default function HistoryList({
5054
}
5155

5256
if (!url) return;
53-
openPlayer({ url, expand: true });
57+
// Seed titles/cover so extract/cache cannot flash Unknown + YT hqdefault.
58+
openPlayer({
59+
url,
60+
media: {
61+
fileUrl: "",
62+
sourceUrl: url,
63+
metadata: { ...item.metadata },
64+
...(item.isAudioTrackVideo ? { isAudioTrackVideo: true } : {}),
65+
},
66+
expand: true,
67+
});
5468
};
5569

5670
const sorted = [...history].sort((a, b) => b.playedAt - a.playedAt);

src/components/Player.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,12 @@ export function Player({
229229
const prop = sameSource(propMedia);
230230

231231
// Prefer a playable fileUrl; keep context/provisional as metadata shell.
232-
// Previously `url` made us drop contextMedia, so re-extract after a long idle
233-
// showed "Unknown" even when last-session/context still had real titles.
232+
// Prefer context over provisional so history-seeded titles/covers win the merge primary.
234233
const playable =
235234
(prop?.fileUrl ? prop : null) ||
236235
(extracted?.fileUrl ? extracted : null) ||
237236
(context?.fileUrl ? context : null);
238-
const shell = playable || prop || extracted || provisionalMedia || context;
237+
const shell = playable || prop || extracted || context || provisionalMedia;
239238
if (!shell) return null;
240239

241240
const ytId = getYouTubeId(shell.sourceUrl || url || "");
@@ -345,8 +344,17 @@ export function Player({
345344
setExtractedMedia(null);
346345
setPlaybackError(null);
347346
setStreamState({ status: "idle" });
348-
// Drop stale context media so old cover/progress cannot linger
349-
if (url) setMedia(null);
347+
// Keep media already seeded for this URL (history replay). Only drop unrelated tracks.
348+
if (url) {
349+
setMedia((prev) => {
350+
if (!prev) return null;
351+
if (prev.sourceUrl === url) return prev;
352+
const prevId = getYouTubeId(prev.sourceUrl) ?? prev.metadata.id;
353+
const nextId = getYouTubeId(url);
354+
if (prevId && nextId && prevId === nextId) return prev;
355+
return null;
356+
});
357+
}
350358
}, [url, setMedia]);
351359

352360
// Reset autoplay gate whenever the playable file changes
@@ -359,15 +367,32 @@ export function Player({
359367
if (extractedMedia || streamStarted.current) return;
360368
if (!url) return;
361369
streamStarted.current = true;
370+
371+
// History/cache open already seeded a playable blob — reuse it instead of
372+
// re-resolving the cache with id-only metadata (which becomes Unknown).
373+
const seeded =
374+
contextMedia?.fileUrl &&
375+
(contextMedia.sourceUrl === url ||
376+
(getYouTubeId(contextMedia.sourceUrl) &&
377+
getYouTubeId(contextMedia.sourceUrl) === getYouTubeId(url)))
378+
? contextMedia
379+
: null;
380+
362381
// Defer so we don't sync-setState inside the effect body (React Compiler lint).
363-
const timer = window.setTimeout(() => startStream(), 0);
382+
const timer = window.setTimeout(() => {
383+
if (seeded) {
384+
setExtractedMedia(seeded);
385+
return;
386+
}
387+
startStream();
388+
}, 0);
364389
return () => {
365390
window.clearTimeout(timer);
366391
extractAbortRef.current?.abort();
367392
extractAbortRef.current = null;
368393
streamStarted.current = false;
369394
};
370-
}, [url, extractedMedia, startStream]);
395+
}, [url, extractedMedia, startStream, contextMedia]);
371396

372397
// Measure bottom chrome for mini-player clip height
373398
useEffect(() => {
@@ -395,21 +420,14 @@ export function Player({
395420
}, [isMini, barHeight]);
396421

397422
// Sync playable extracted media into app context (last-session + shared state).
398-
// Keep a ref of context so we can merge prior titles without depending on it
399-
// (depending would re-fire this effect after every setMedia).
400-
const contextMediaRef = useRef(contextMedia);
401-
useEffect(() => {
402-
contextMediaRef.current = contextMedia;
403-
}, [contextMedia]);
423+
// Titles/covers come from extract + stashed search/history meta (peekSearchMeta).
404424
useEffect(() => {
405425
if (!extractedMedia?.fileUrl) return;
406-
const prior = contextMediaRef.current;
407426
const ytId = getYouTubeId(extractedMedia.sourceUrl);
408427
setMedia({
409428
...extractedMedia,
410429
metadata: mergeTrackMetadata(
411430
extractedMedia.metadata,
412-
prior?.sourceUrl === extractedMedia.sourceUrl ? prior.metadata : undefined,
413431
ytId ? peekSearchMeta(ytId) : undefined,
414432
),
415433
});

src/context/AppContext.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export interface OpenPlayerOptions {
5151
interface AppContextValue {
5252
// Media state
5353
media: Media | null;
54-
setMedia: (media: Media | null) => void;
54+
setMedia: React.Dispatch<React.SetStateAction<Media | null>>;
5555

5656
// Persistent player shell
5757
playerMode: PlayerMode;
@@ -156,9 +156,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
156156

157157
const isHistoryWriteAllowed = useCallback(() => !skipHistoryWritesRef.current, []);
158158

159-
const setMedia = useCallback((next: Media | null) => {
160-
setMediaState(next);
161-
}, []);
159+
const setMedia: React.Dispatch<React.SetStateAction<Media | null>> = useCallback(
160+
(action) => {
161+
setMediaState(action);
162+
},
163+
[],
164+
);
162165

163166
const openPlayer = useCallback((options: OpenPlayerOptions = {}) => {
164167
if (options.recordHistory === false) {

src/lib/searchMeta.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,31 @@ function isKnown(value: string | undefined | null): value is string {
7171
return isKnownMetaValue(value);
7272
}
7373

74+
/** YouTube page thumbs (hqdefault etc.) — prefer music/search covers over these. */
75+
export function isWeakCoverUrl(url: string | undefined | null): boolean {
76+
if (!url) return true;
77+
let decoded = url;
78+
try {
79+
decoded = decodeURIComponent(url);
80+
} catch {
81+
// keep raw
82+
}
83+
return /i\.ytimg\.com\/vi\/[^/?#]+\/(default|mqdefault|hqdefault|sddefault|hq720)\.jpg/i.test(
84+
decoded,
85+
);
86+
}
87+
88+
function pickCover(
89+
current: string | undefined,
90+
candidate: string | undefined,
91+
): string | undefined {
92+
if (!candidate) return current;
93+
if (!current || isWeakCoverUrl(current)) {
94+
if (!isWeakCoverUrl(candidate) || !current) return candidate;
95+
}
96+
return current;
97+
}
98+
7499
/** Prefer real titles/authors from earlier sources over cache/extract placeholders. */
75100
export function mergeTrackMetadata(
76101
primary: Partial<Media["metadata"]> | undefined,
@@ -83,7 +108,7 @@ export function mergeTrackMetadata(
83108
if (!isKnown(merged.author) && isKnown(fb.author)) merged.author = fb.author;
84109
if (merged.artist == null && fb.artist != null) merged.artist = fb.artist;
85110
if (merged.album == null && fb.album != null) merged.album = fb.album;
86-
if (!merged.coverUrl && fb.coverUrl) merged.coverUrl = fb.coverUrl;
111+
merged.coverUrl = pickCover(merged.coverUrl, fb.coverUrl);
87112
if (merged.id == null && fb.id != null) merged.id = fb.id;
88113
}
89114

src/utils/streamer.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Media } from "@/interfaces";
22
import { parseApiErrorBody } from "@/lib/apiError";
33
import { cookieRequestHeaders, getCookiesToUse } from "@/lib/cookies";
44
import { mediaFromLocalCache } from "@/lib/playFromCache";
5-
import { mergeTrackMetadata } from "@/lib/searchMeta";
5+
import { mergeTrackMetadata, peekSearchMeta } from "@/lib/searchMeta";
66
import { isMarkedAudioTrackVideo, markAudioTrackVideo } from "@/lib/trackFlags";
77
import { getYouTubeId, isDirectMediaURL, isYoutubeURL } from "@/utils";
88

@@ -122,7 +122,11 @@ export async function streamWithProgress(
122122
}
123123

124124
const id = getYouTubeId(url);
125-
const cached = await mediaFromLocalCache(url, id ? { id } : undefined);
125+
const priorMeta = id ? peekSearchMeta(id) : undefined;
126+
const cached = await mediaFromLocalCache(
127+
url,
128+
mergeTrackMetadata(priorMeta, id ? { id } : undefined),
129+
);
126130

127131
// Audio cache hit: skip extract. Show video uses YouTube embed by video id.
128132
if (cached) {

0 commit comments

Comments
 (0)