Skip to content

Commit 63f1934

Browse files
committed
fix: recover pasted-link titles and harden cover loading
Fetch oembed metadata for pasted/deep-linked YouTube URLs, recover titles on cache hits, proxy search thumbs through /api/cover, and shimmer the dock cover while audio loads.
1 parent 57ec9a9 commit 63f1934

7 files changed

Lines changed: 209 additions & 44 deletions

File tree

src/app/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { youtubeErrorTitle } from "@/lib/apiError";
5252
import { cookieRequestHeaders } from "@/lib/cookies";
5353
import { stashSearchMeta } from "@/lib/searchMeta";
5454
import { SEARCH_ACCENT_VAR } from "@/lib/theme";
55+
import { ensureYouTubeLinkMeta } from "@/lib/youtubeOembed";
5556
import { getYouTubeId, isDirectMediaURL, isYoutubeURL } from "@/utils";
5657
import { setMediaCache } from "@/utils/cache";
5758

@@ -359,8 +360,11 @@ function SearchPanel({
359360
if (isYoutubeURL(clean)) {
360361
const id = getYouTubeId(clean);
361362
if (id) {
363+
const playUrl = `https://www.youtube.com/watch?v=${id}`;
364+
// Stash oembed titles before open so paste doesn't flash / stick on Unknown.
365+
await ensureYouTubeLinkMeta(id);
362366
resetSearchUi();
363-
openPlayer({ url: `https://www.youtube.com/watch?v=${id}`, expand: true });
367+
openPlayer({ url: playUrl, expand: true });
364368
}
365369
return;
366370
}

src/components/MediaResultRow.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
} from "@mantine/core";
1414
import { IconMusic } from "@tabler/icons-react";
1515
import ShimmerImage from "@/components/ShimmerImage";
16+
import { toDisplayCoverUrl } from "@/lib/mergePlayerMedia";
1617
import { SEARCH_ACCENT_VAR } from "@/lib/theme";
18+
import { getYouTubeId } from "@/utils";
1719

1820
export interface MediaResultItem {
1921
id: string;
@@ -61,6 +63,8 @@ export default function MediaResultRow({
6163
: item.lengthSeconds
6264
? formatDuration(item.lengthSeconds)
6365
: "";
66+
const ytId = getYouTubeId(item.id) ?? (/^[\w-]{11}$/.test(item.id) ? item.id : null);
67+
const thumbSrc = toDisplayCoverUrl(item.thumbnail || undefined, ytId);
6468

6569
return (
6670
<Paper
@@ -87,9 +91,9 @@ export default function MediaResultRow({
8791
>
8892
<Flex gap="sm" align="center">
8993
<Box pos="relative" style={{ flexShrink: 0 }}>
90-
{item.thumbnail ? (
94+
{thumbSrc ? (
9195
<ShimmerImage
92-
src={item.thumbnail}
96+
src={thumbSrc}
9397
alt=""
9498
w={thumbW}
9599
h={thumbH}

src/components/Player.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,15 +1801,14 @@ export function Player({
18011801
radius="sm"
18021802
h={38}
18031803
w={38}
1804-
visibleFrom="xs"
18051804
alt="cover image"
1805+
forceShimmer={isLoading || !isMediaReady}
18061806
wrapperStyle={{ width: 38, height: 38, flexShrink: 0 }}
18071807
style={{
18081808
userSelect: "none",
18091809
WebkitUserSelect: "none",
18101810
pointerEvents: "none",
18111811
flexShrink: 0,
1812-
opacity: isMediaReady ? 1 : 0.55,
18131812
}}
18141813
/>
18151814
<Box ml="sm" style={{ minWidth: 0, flex: 1 }}>

src/components/PlayerRouteBridge.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { useRouter } from "next/navigation";
55
import LoadingOverlay from "@/components/LoadingOverlay";
66
import { useAppContext } from "@/context/AppContext";
77
import { playerPathForMedia, softReplaceUrl } from "@/lib/playerNavigation";
8-
import { isDirectMediaURL, isYoutubeURL } from "@/utils";
8+
import { ensureYouTubeLinkMeta } from "@/lib/youtubeOembed";
9+
import { getYouTubeId, isDirectMediaURL, isYoutubeURL } from "@/utils";
910

1011
/**
1112
* Deep-link / rewrite entry for /player and /watch.
@@ -21,25 +22,31 @@ export default function PlayerRouteBridge({ url }: { url?: string }) {
2122
if (started.current) return;
2223
started.current = true;
2324

24-
if (url && (isDirectMediaURL(url) || isYoutubeURL(url))) {
25-
// syncUrl false — router.replace("/") would stomp a soft /watch path.
26-
openPlayer({ url, expand: true, syncUrl: false });
27-
router.replace("/");
28-
// Re-apply shareable URL after Next lands on Home.
29-
const path = playerPathForMedia(url, null);
30-
requestAnimationFrame(() => softReplaceUrl(path));
31-
return;
32-
}
25+
void (async () => {
26+
if (url && (isDirectMediaURL(url) || isYoutubeURL(url))) {
27+
if (isYoutubeURL(url)) {
28+
const id = getYouTubeId(url);
29+
if (id) await ensureYouTubeLinkMeta(id);
30+
}
31+
// syncUrl false — router.replace("/") would stomp a soft /watch path.
32+
openPlayer({ url, expand: true, syncUrl: false });
33+
router.replace("/");
34+
// Re-apply shareable URL after Next lands on Home.
35+
const path = playerPathForMedia(url, null);
36+
requestAnimationFrame(() => softReplaceUrl(path));
37+
return;
38+
}
3339

34-
if (media) {
35-
openPlayer({ media, expand: true, syncUrl: false });
36-
const path = playerPathForMedia(null, media);
37-
router.replace("/");
38-
requestAnimationFrame(() => softReplaceUrl(path));
39-
return;
40-
}
40+
if (media) {
41+
openPlayer({ media, expand: true, syncUrl: false });
42+
const path = playerPathForMedia(null, media);
43+
router.replace("/");
44+
requestAnimationFrame(() => softReplaceUrl(path));
45+
return;
46+
}
4147

42-
router.replace("/");
48+
router.replace("/");
49+
})();
4350
}, [url, openPlayer, router, media]);
4451

4552
return (

src/components/ShimmerImage.tsx

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type ShimmerImageProps = {
2525
visibleFrom?: MantineBreakpoint;
2626
hiddenFrom?: MantineBreakpoint;
2727
fallbackSrc?: string;
28+
/** Keep the shimmer visible even after the bitmap has painted (e.g. audio loading). */
29+
forceShimmer?: boolean;
2830
onLoad?: (event: SyntheticEvent<HTMLImageElement>) => void;
2931
onError?: (event: SyntheticEvent<HTMLImageElement>) => void;
3032
};
@@ -51,16 +53,24 @@ export default function ShimmerImage({
5153
style,
5254
wrapperStyle,
5355
radius,
56+
forceShimmer = false,
5457
onLoad,
5558
onError,
5659
visibleFrom,
5760
hiddenFrom,
61+
w,
62+
h,
63+
width,
64+
height,
5865
...rest
5966
}: ShimmerImageProps) {
6067
const theme = useMantineTheme();
6168
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
6269
const loaded = Boolean(src) && loadedSrc === src;
70+
const showShimmer = forceShimmer || !loaded;
6371
const borderRadius = resolveRadius(radius, theme);
72+
const boxW = w ?? width ?? wrapperStyle?.width;
73+
const boxH = h ?? height ?? wrapperStyle?.height;
6474

6575
const handleLoad = useCallback(
6676
(event: SyntheticEvent<HTMLImageElement>) => {
@@ -79,15 +89,40 @@ export default function ShimmerImage({
7989
);
8090

8191
if (!src) {
92+
if (!forceShimmer) {
93+
return (
94+
<Image
95+
src={src}
96+
alt={alt}
97+
radius={radius}
98+
style={style}
99+
visibleFrom={visibleFrom}
100+
hiddenFrom={hiddenFrom}
101+
w={w}
102+
h={h}
103+
width={width}
104+
height={height}
105+
{...rest}
106+
/>
107+
);
108+
}
109+
82110
return (
83-
<Image
84-
src={src}
85-
alt={alt}
86-
radius={radius}
87-
style={style}
111+
<Box
112+
pos="relative"
88113
visibleFrom={visibleFrom}
89114
hiddenFrom={hiddenFrom}
90-
{...rest}
115+
className="moonlit-shimmer"
116+
aria-hidden
117+
style={{
118+
overflow: "hidden",
119+
borderRadius,
120+
width: boxW,
121+
height: boxH,
122+
flexShrink: 0,
123+
backgroundColor: rgba(theme.colors.dark[5], 0.55),
124+
...wrapperStyle,
125+
}}
91126
/>
92127
);
93128
}
@@ -107,10 +142,12 @@ export default function ShimmerImage({
107142
style={{
108143
overflow: "hidden",
109144
borderRadius,
145+
width: boxW,
146+
height: boxH,
110147
...wrapperStyle,
111148
}}
112149
>
113-
{!loaded ? (
150+
{showShimmer ? (
114151
<Box
115152
className="moonlit-shimmer"
116153
aria-hidden
@@ -129,9 +166,18 @@ export default function ShimmerImage({
129166
src={src}
130167
alt={alt}
131168
radius={radius}
169+
w={w}
170+
h={h}
171+
width={width}
172+
height={height}
132173
style={{
133174
...style,
134-
opacity: loaded ? (Number.isFinite(targetOpacity) ? targetOpacity : 1) : 0,
175+
opacity:
176+
loaded && !forceShimmer
177+
? Number.isFinite(targetOpacity)
178+
? targetOpacity
179+
: 1
180+
: 0,
135181
transition: style?.transition ?? "opacity 0.2s ease-out",
136182
}}
137183
onLoad={handleLoad}

src/lib/youtubeOembed.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Media } from "@/interfaces";
2+
import { isKnownMetaValue, peekSearchMeta, stashSearchMeta } from "@/lib/searchMeta";
3+
4+
type OembedResponse = {
5+
title?: string;
6+
author_name?: string;
7+
thumbnail_url?: string;
8+
};
9+
10+
/** Fetch public YouTube oembed metadata (title / author / thumb). */
11+
export async function fetchYouTubeOembedMeta(
12+
videoId: string,
13+
signal?: AbortSignal,
14+
): Promise<Partial<Media["metadata"]> | null> {
15+
if (!videoId) return null;
16+
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
17+
const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`;
18+
try {
19+
const res = await fetch(endpoint, { signal });
20+
if (!res.ok) return null;
21+
const data = (await res.json()) as OembedResponse;
22+
if (!data.title && !data.author_name) return null;
23+
return {
24+
id: videoId,
25+
...(data.title ? { title: data.title } : {}),
26+
...(data.author_name ? { author: data.author_name } : {}),
27+
...(data.thumbnail_url ? { coverUrl: data.thumbnail_url } : {}),
28+
};
29+
} catch {
30+
return null;
31+
}
32+
}
33+
34+
/**
35+
* Stash oembed titles for a pasted / deep-linked YouTube id when the session
36+
* stash does not already have known title+author.
37+
*/
38+
export async function ensureYouTubeLinkMeta(
39+
videoId: string,
40+
options?: { timeoutMs?: number },
41+
): Promise<Partial<Media["metadata"]> | undefined> {
42+
if (!videoId) return undefined;
43+
const existing = peekSearchMeta(videoId);
44+
if (isKnownMetaValue(existing?.title) && isKnownMetaValue(existing?.author)) {
45+
return existing;
46+
}
47+
48+
const timeoutMs = options?.timeoutMs ?? 4000;
49+
const controller = new AbortController();
50+
const timer = setTimeout(() => controller.abort(), timeoutMs);
51+
try {
52+
const meta = await fetchYouTubeOembedMeta(videoId, controller.signal);
53+
if (!meta) return existing;
54+
stashSearchMeta(videoId, meta);
55+
return peekSearchMeta(videoId) ?? meta;
56+
} finally {
57+
clearTimeout(timer);
58+
}
59+
}

0 commit comments

Comments
 (0)