v3: youtube playlists get a tracklist too — the embed keeps playing the list, so the rows come from two halves: /api/youtube/playlist scrapes titles and lengths off the playlist page (current lockup rows and the older renderer, cached 6h), while the IFrame API attached to the iframe reports the position and takes row clicks and ⏮/⏭; the header names the playing video with the dump's title below it, like a bandcamp track; either half can fail — no scrape leaves the bare embed, no API leaves the rows as a table of contents — and the pause an API-enabled embed gives itself when the tab goes to the background is undone within a short window, so a later pause still stands
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 21:51:10 +00:00
parent 084ebcb627
commit d70761ecae
11 changed files with 727 additions and 101 deletions

View File

@@ -0,0 +1,259 @@
import {
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
embedListId,
fetchPlaylistTracks,
type YouTubeTrack,
} from "../utils/youtube.ts";
/**
* Tracklist and transport for a YouTube playlist embed.
*
* A Bandcamp album becomes a real queue of streams, but a YouTube playlist
* stays inside the iframe: YouTube holds the list and advances through it. So
* the two halves come from two places — the titles and lengths from the server
* (the embed exposes neither), the current position and the controls from the
* IFrame Player API attached to the iframe we already render.
*
* Both halves are optional. Without the server's titles there is no list; with
* a list but no API (script blocked, embedding restricted) the rows are shown
* but inert, since nothing can be told to play.
*/
interface YTPlayer {
playVideo(): void;
playVideoAt(index: number): void;
nextVideo(): void;
previousVideo(): void;
getPlaylist(): string[] | null;
getPlaylistIndex(): number;
destroy(): void;
}
interface YTStateEvent {
/** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */
data: number;
}
interface YTNamespace {
Player: new (
el: HTMLIFrameElement,
opts: {
events: {
onReady?: () => void;
onStateChange?: (event: YTStateEvent) => void;
};
},
) => YTPlayer;
}
const PAUSED = 2;
/**
* An embed that has `enablejsapi` on pauses itself when the page goes to the
* background — something a plain embed does not do, and nothing on our side
* asked for: it breaks listening while working in another tab. A pause landing
* in the moment the tab is hidden is therefore undone. The window is narrow
* and the retries few on purpose, so a pause that comes later (a media key, a
* phone call, the user coming back and hitting space) stands.
*/
const RESUME_WINDOW_MS = 4000;
const MAX_RESUMES = 3;
interface YTGlobal {
YT?: YTNamespace;
onYouTubeIframeAPIReady?: () => void;
}
const API_SRC = "https://www.youtube.com/iframe_api";
let apiPromise: Promise<YTNamespace> | null = null;
/** Load YouTube's IFrame API once per document, whoever asks first. */
function loadPlayerApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
const w = globalThis as unknown as YTGlobal;
if (w.YT?.Player) {
resolve(w.YT);
return;
}
// The API calls this global when it finishes loading; chain any existing
// one rather than clobbering it.
const previous = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
previous?.();
if (w.YT?.Player) resolve(w.YT);
else reject(new Error("YouTube IFrame API loaded without a Player"));
};
const script = document.createElement("script");
script.src = API_SRC;
script.async = true;
script.onerror = () => reject(new Error("YouTube IFrame API failed"));
document.head.appendChild(script);
});
return apiPromise;
}
export interface PlaylistRow {
videoId: string;
title?: string;
duration?: number;
}
export interface YouTubePlaylistControls {
rows: PlaylistRow[];
/** Position in `rows`, or -1 while the player hasn't reported one. */
index: number;
/** Whether the embed can be driven — rows are clickable only then. */
ready: boolean;
playAt(index: number): void;
next(): void;
previous(): void;
}
export function useYouTubePlaylist(
embedUrl: string | null,
iframeRef: RefObject<HTMLIFrameElement | null>,
): YouTubePlaylistControls {
const listId = embedUrl ? embedListId(embedUrl) : null;
const [tracks, setTracks] = useState<YouTubeTrack[]>([]);
// Video IDs as the player itself sees them: the authority on order and
// length, and the only source for a playlist the server couldn't read.
const [playerIds, setPlayerIds] = useState<string[] | null>(null);
const [index, setIndex] = useState(-1);
const [ready, setReady] = useState(false);
const playerRef = useRef<YTPlayer | null>(null);
const hiddenAt = useRef(0);
const resumes = useRef(0);
useEffect(() => {
const onVisibility = () => {
hiddenAt.current = document.hidden ? Date.now() : 0;
if (!document.hidden) resumes.current = 0;
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
// Everything below is about one embed; when that changes, drop it all in the
// same render rather than letting a stale list flash under the new video.
const [shownUrl, setShownUrl] = useState(embedUrl);
if (shownUrl !== embedUrl) {
setShownUrl(embedUrl);
setTracks([]);
setPlayerIds(null);
setIndex(-1);
setReady(false);
}
useEffect(() => {
if (!listId) return;
let cancelled = false;
fetchPlaylistTracks(listId)
.then((t) => {
if (!cancelled) setTracks(t);
})
.catch((err) => {
console.warn("youtube: no tracklist for this playlist", err);
});
return () => {
cancelled = true;
};
}, [listId]);
useEffect(() => {
if (!listId || !embedUrl) return;
let cancelled = false;
let player: YTPlayer | null = null;
loadPlayerApi()
.then((YT) => {
const iframe = iframeRef.current;
if (cancelled || !iframe) return;
const sync = () => {
if (!player) return;
const list = player.getPlaylist?.();
setPlayerIds(Array.isArray(list) && list.length > 0 ? list : null);
const at = player.getPlaylistIndex?.();
setIndex(typeof at === "number" ? at : -1);
};
player = new YT.Player(iframe, {
// onStateChange is what reports the list advancing on its own.
events: {
onReady: () => {
if (cancelled) return;
setReady(true);
sync();
},
onStateChange: (event) => {
if (cancelled) return;
sync();
if (
event.data === PAUSED && document.hidden &&
Date.now() - hiddenAt.current < RESUME_WINDOW_MS &&
resumes.current < MAX_RESUMES
) {
resumes.current += 1;
player?.playVideo();
}
},
},
});
playerRef.current = player;
})
.catch((err) => {
console.warn("youtube: player API unavailable", err);
});
return () => {
cancelled = true;
playerRef.current = null;
// destroy() takes the iframe with it, which is why the element is keyed
// on the embed URL: React only ever removes its wrapper, never a node
// that has gone out from under it.
try {
player?.destroy();
} catch {
// already gone with the unmounted subtree
}
};
}, [listId, embedUrl, iframeRef]);
const rows = useMemo<PlaylistRow[]>(() => {
if (!playerIds) {
return tracks.map((t) => ({
videoId: t.videoId,
title: t.title,
duration: t.duration,
}));
}
// Positions usually line up with the scrape; fall back to matching by ID
// so a list that gained or lost an entry since still reads correctly.
const byId = new Map(tracks.map((t) => [t.videoId, t]));
return playerIds.map((videoId, i) => {
const track = tracks[i]?.videoId === videoId
? tracks[i]
: byId.get(videoId);
return { videoId, title: track?.title, duration: track?.duration };
});
}, [playerIds, tracks]);
const playAt = useCallback((i: number) => {
playerRef.current?.playVideoAt(i);
}, []);
const next = useCallback(() => playerRef.current?.nextVideo(), []);
const previous = useCallback(() => playerRef.current?.previousVideo(), []);
return { rows, index, ready, playAt, next, previous };
}