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

@@ -10,6 +10,7 @@ import avatarsRouter from "./routes/avatars.ts";
import wsRouter from "./routes/ws.ts";
import previewRouter from "./routes/preview.ts";
import bandcampRouter from "./routes/bandcamp.ts";
import youtubeRouter from "./routes/youtube.ts";
import playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts";
@@ -76,6 +77,10 @@ app.use(
bandcampRouter.routes(),
bandcampRouter.allowedMethods(),
);
app.use(
youtubeRouter.routes(),
youtubeRouter.allowedMethods(),
);
app.use(
playlistsRouter.routes(),
playlistsRouter.allowedMethods(),

20
api/routes/youtube.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Router } from "@oak/oak";
import { resolveYouTubePlaylist } from "../services/youtube-playlist-service.ts";
const youtubeRouter = new Router();
/**
* Resolve a YouTube playlist ID to its tracklist (titles and lengths).
*
* Public, like /api/bandcamp/tracks: it only reads a public page. The client
* treats any failure as "show the embed without a tracklist".
*/
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
const listId = ctx.request.url.searchParams.get("list") ?? "";
const playlist = await resolveYouTubePlaylist(listId);
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
ctx.response.body = { success: true, data: playlist };
});
export default youtubeRouter;

View File

@@ -0,0 +1,199 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
/**
* Resolves a YouTube playlist to its tracklist, at play time.
*
* Playback itself stays in the iframe embed — YouTube plays the list and
* advances through it on its own. This only supplies what the embed doesn't
* expose: every entry's title and length, so the global player can draw a
* tracklist the way it does for a Bandcamp album. The client syncs the
* highlighted row and row clicks through the IFrame Player API.
*
* The data comes from the `ytInitialData` blob on the public playlist page. Its
* shape is undocumented and changes, so the parse is best-effort and accepts
* both the current `lockupViewModel` rows and the older `playlistVideoRenderer`
* ones. Only the first page (100 entries) is read; the client appends any
* further IDs the player reports, untitled.
*/
export interface YouTubePlaylistTrack {
videoId: string;
title: string;
/** Seconds. */
duration?: number;
}
export interface YouTubePlaylist {
listId: string;
title?: string;
tracks: YouTubePlaylistTrack[];
}
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
const TTL_MS = 6 * 60 * 60 * 1000;
const MAX_ENTRIES = 200;
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
/** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */
const LIST_ID_RE = /^[\w-]{2,64}$/;
const HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"Accept": "text/html",
"Accept-Language": "en-US,en;q=0.8",
// Pre-answered consent: without it EU requests are redirected to
// consent.youtube.com and never see the playlist.
"Cookie": "SOCS=CAI",
};
/** "3:55", "1:02:03" → seconds. */
function parseClock(text: string): number | undefined {
if (!/^\d+(?::\d{1,2}){1,2}$/.test(text)) return undefined;
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
}
/** Depth-first walk collecting every value stored under `key`. */
function collect(node: unknown, key: string, out: unknown[]): void {
if (Array.isArray(node)) {
for (const child of node) collect(child, key, out);
} else if (node && typeof node === "object") {
for (const [k, v] of Object.entries(node)) {
if (k === key) out.push(v);
else collect(v, key, out);
}
}
}
function firstString(node: unknown, key: string): string | undefined {
const found: unknown[] = [];
collect(node, key, found);
return found.find((v): v is string => typeof v === "string");
}
// deno-lint-ignore no-explicit-any
type Json = any;
function fromLockup(lockup: Json): YouTubePlaylistTrack | null {
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
const videoId = lockup.contentId;
const title = lockup.metadata?.lockupMetadataViewModel?.title?.content;
if (typeof videoId !== "string" || typeof title !== "string") return null;
const badges: unknown[] = [];
collect(lockup.contentImage, "thumbnailBadgeViewModel", badges);
const duration = badges
.map((b) => (b as Json)?.text)
.filter((t): t is string => typeof t === "string")
.map(parseClock)
.find((d) => d !== undefined);
return { videoId, title, duration };
}
function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null {
const videoId = r?.videoId;
const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText;
if (typeof videoId !== "string" || typeof title !== "string") return null;
const secs = Number(r.lengthSeconds);
return {
videoId,
title,
duration: Number.isFinite(secs) && secs > 0 ? secs : undefined,
};
}
export function parsePlaylistPage(
html: string,
listId: string,
): YouTubePlaylist | null {
const start = html.indexOf("var ytInitialData = ");
if (start < 0) return null;
const end = html.indexOf(";</script>", start);
if (end < 0) return null;
let data: Json;
try {
data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end));
} catch {
return null;
}
const lockups: unknown[] = [];
collect(data?.contents, "lockupViewModel", lockups);
const legacy: unknown[] = [];
collect(data?.contents, "playlistVideoRenderer", legacy);
// A video can appear in a playlist twice; both rows are kept, so the list
// stays positional and matches what the embed plays.
const tracks = [
...lockups.map(fromLockup),
...legacy.map(fromLegacyRenderer),
].filter((t): t is YouTubePlaylistTrack => t !== null);
if (tracks.length === 0) return null;
const title = data?.metadata?.playlistMetadataRenderer?.title ??
firstString(data?.header, "content");
return { listId, title, tracks };
}
export async function resolveYouTubePlaylist(
listId: string,
): Promise<YouTubePlaylist> {
if (!LIST_ID_RE.test(listId)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid playlist ID",
);
}
const hit = cache.get(listId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
if (hit) cache.delete(listId);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
let html: string;
try {
const res = await fetch(
`https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`,
{ headers: HEADERS, signal: controller.signal },
);
if (!res.ok) {
await res.body?.cancel();
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
`YouTube returned ${res.status}`,
);
}
html = await res.text();
} catch (err) {
if (err instanceof APIException) throw err;
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Could not reach YouTube",
);
} finally {
clearTimeout(timer);
}
const parsed = parsePlaylistPage(html, listId);
if (!parsed) {
// Private, deleted, or auto-generated mixes (RD…) that have no playlist
// page — the client just shows the embed without a tracklist.
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"No tracks on this playlist",
);
}
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(listId, { value: parsed, at: Date.now() });
return parsed;
}

View File

@@ -1407,6 +1407,10 @@ a.global-player-title:hover {
color: var(--color-text-muted);
}
/* The video already claims most of the viewport, so its list stays shorter. */
.global-player--youtube .global-player-tracks {
max-height: 30vh;
}
.global-player-tracks {
list-style: none;
margin: 0;
@@ -1434,6 +1438,14 @@ a.global-player-title:hover {
.global-player-track > button:hover {
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
}
/* A YouTube tracklist is only clickable once its player API answers; until
then the rows are a table of contents. */
.global-player-track > button:disabled {
cursor: default;
}
.global-player-track > button:disabled:hover {
background: none;
}
/* An accent border rather than a filled pill, so no theme has to un-round it. */
.global-player-track.is-current > button {
border-left-color: var(--color-accent);

View File

@@ -10,9 +10,40 @@ import {
import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../utils/duration.ts";
import Thumbnail from "./Thumbnail.tsx";
import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../utils/youtube.ts";
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
/** One row of the tracklist, whether it comes from the queue or from a
* YouTube playlist the embed is holding. */
function TrackRow(
{ num, title, duration, isCurrent, disabled, onPlay, rowRef }: {
num: number | string;
title: string;
duration?: number;
isCurrent: boolean;
disabled?: boolean;
onPlay: () => void;
rowRef?: React.Ref<HTMLLIElement>;
},
) {
return (
<li
ref={rowRef}
className={`global-player-track${isCurrent ? " is-current" : ""}`}
>
<button type="button" onClick={onPlay} disabled={disabled}>
<span className="global-player-track-num">{num}</span>
<span className="global-player-track-title">{title}</span>
{duration !== undefined && (
<span className="global-player-track-dur">{fmt(duration)}</span>
)}
</button>
</li>
);
}
// The stored embedUrl is the canonical, non-playing form — it is also rendered
// outside the player, so the autoplay parameter is added here at playback time
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
@@ -21,16 +52,20 @@ type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
function playbackUrl(item: EmbedItem, autoplay: boolean) {
if (!autoplay) return item.embedUrl;
// A playlist embed is also driven from the outside — see useYouTubePlaylist.
const base = item.type === "youtube" && embedListId(item.embedUrl)
? withJsApi(item.embedUrl)
: item.embedUrl;
if (!autoplay) return base;
try {
const url = new URL(item.embedUrl);
const url = new URL(base);
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
else if (item.type === "soundcloud") {
url.searchParams.set("auto_play", "true");
} else return item.embedUrl;
} else return base;
return url.toString();
} catch {
return item.embedUrl; // malformed stored URL — hand it to the iframe as-is
return base; // malformed stored URL — hand it to the iframe as-is
}
}
@@ -56,9 +91,19 @@ export function GlobalPlayer() {
onError,
} = useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const currentRowRef = useRef<HTMLLIElement>(null);
const [reduced, setReduced] = useState(false);
const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
const [prevKey, setPrevKey] = useState(
current ? playerItemKey(current) : null,
);
// A YouTube playlist keeps its own list inside the iframe, so it is asked
// for one rather than being expanded into the queue.
const ytEmbedUrl = current?.kind === "embed" && current.type === "youtube"
? current.embedUrl
: null;
const yt = useYouTubePlaylist(ytEmbedUrl, iframeRef);
const currentKey = current ? playerItemKey(current) : null;
if (prevKey !== currentKey) {
@@ -93,10 +138,10 @@ export function GlobalPlayer() {
};
}, [current]);
// Keep the playing row visible as the queue advances on its own.
// Keep the playing row visible as the list advances on its own.
useEffect(() => {
currentRowRef.current?.scrollIntoView({ block: "nearest" });
}, [queueIndex]);
}, [queueIndex, yt.index]);
if (!current) return null;
@@ -106,14 +151,23 @@ export function GlobalPlayer() {
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
: current.type;
const title = current.title ??
const showQueue = queue.length > 1;
const ytTracks = !showQueue && yt.rows.length > 1 ? yt.rows : null;
const showTransport = showQueue || ytTracks !== null;
// A playing playlist names the video in the header, the way a Bandcamp queue
// names the track, and demotes the dump's own title to the line below.
const ytRow = ytTracks && yt.index >= 0 ? ytTracks[yt.index] : null;
const itemTitle = current.title ??
(current.kind === "embed"
? current.embedUrl
: current.kind === "file"
? current.fileUrl
: current.streamUrl);
const showQueue = queue.length > 1;
const title = ytRow?.title ?? itemTitle;
const subtitle = ytRow?.title && ytRow.title !== itemTitle
? itemTitle
: current.subtitle;
return (
<div
@@ -140,29 +194,33 @@ export function GlobalPlayer() {
</Link>
)
: <span className="global-player-title">{title}</span>}
{current.subtitle && (
<span className="global-player-subtitle">{current.subtitle}</span>
{subtitle && (
<span className="global-player-subtitle">{subtitle}</span>
)}
</div>
{showQueue && (
{showTransport && (
<div className="global-player-transport">
<button
type="button"
className="btn btn--ghost"
onClick={previous}
disabled={!hasPrevious}
onClick={ytTracks ? yt.previous : previous}
disabled={ytTracks ? !yt.ready || yt.index === 0 : !hasPrevious}
aria-label={t`Previous track`}
>
⏮
</button>
<span className="global-player-position">
{queueIndex + 1} / {queue.length}
{ytTracks
? `${yt.index >= 0 ? yt.index + 1 : "–"} / ${ytTracks.length}`
: `${queueIndex + 1} / ${queue.length}`}
</span>
<button
type="button"
className="btn btn--ghost"
onClick={next}
disabled={!hasNext}
onClick={ytTracks ? yt.next : next}
disabled={ytTracks
? !yt.ready || yt.index === ytTracks.length - 1
: !hasNext}
aria-label={t`Next track`}
>
⏭
@@ -189,8 +247,14 @@ export function GlobalPlayer() {
<div className="global-player-body">
{current.kind === "embed"
? (
<div className="global-player-iframe-wrap">
<div
className="global-player-iframe-wrap"
// Keyed so switching embeds swaps this wrapper rather than the
// iframe itself, which the YouTube player API may have removed.
key={current.embedUrl}
>
<iframe
ref={iframeRef}
src={playbackUrl(current, autoplay)}
className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media"
@@ -243,36 +307,35 @@ export function GlobalPlayer() {
</div>
)}
{showQueue && !reduced && (
{!reduced && (showQueue || ytTracks) && (
<ol className="global-player-tracks">
{queue.map((item, i) => {
const isCurrent = i === queueIndex;
const num = item.kind === "stream" ? item.trackNum : undefined;
const dur = item.kind === "stream" ? item.duration : undefined;
return (
<li
{ytTracks
? ytTracks.map((row, i) => (
<TrackRow
key={`${row.videoId}-${i}`}
rowRef={i === yt.index ? currentRowRef : undefined}
num={i + 1}
title={row.title ?? t`Track ${i + 1}`}
duration={row.duration}
isCurrent={i === yt.index}
// Without the player API the list is a table of contents:
// nothing on our side can make the embed jump.
disabled={!yt.ready}
onPlay={() => yt.playAt(i)}
/>
))
: queue.map((item, i) => (
<TrackRow
key={playerItemKey(item)}
ref={isCurrent ? currentRowRef : undefined}
className={`global-player-track${
isCurrent ? " is-current" : ""
}`}
>
<button type="button" onClick={() => playAt(i)}>
<span className="global-player-track-num">
{num ?? i + 1}
</span>
<span className="global-player-track-title">
{item.title}
</span>
{dur !== undefined && (
<span className="global-player-track-dur">
{fmt(dur)}
</span>
)}
</button>
</li>
);
})}
rowRef={i === queueIndex ? currentRowRef : undefined}
num={(item.kind === "stream" ? item.trackNum : undefined) ??
i + 1}
title={item.title ?? ""}
duration={item.kind === "stream" ? item.duration : undefined}
isCurrent={i === queueIndex}
onPlay={() => playAt(i)}
/>
))}
</ol>
)}
</div>

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 };
}

File diff suppressed because one or more lines are too long

View File

@@ -212,11 +212,11 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:231
#: src/components/CommentThread.tsx:127
#: src/components/ConfirmModal.tsx:32
#: src/components/ConfirmModal.tsx:34
#: src/components/DumpCreateModal.tsx:946
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:406
#: src/pages/DumpEdit.tsx:470
#: src/pages/DumpEdit.tsx:484
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/UserPublicProfile.tsx:1733
#: src/pages/UserPublicProfile.tsx:1806
@@ -275,7 +275,7 @@ msgid "Checking invite…"
msgstr "Checking invite…"
#: src/components/ChangePasswordModal.tsx:56
#: src/components/Modal.tsx:58
#: src/components/Modal.tsx:54
msgid "Close"
msgstr "Close"
@@ -283,7 +283,7 @@ msgstr "Close"
msgid "Close anyway"
msgstr "Close anyway"
#: src/components/GlobalPlayer.tsx:184
#: src/components/GlobalPlayer.tsx:242
msgid "Close player"
msgstr "Close player"
@@ -291,7 +291,7 @@ msgstr "Close player"
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
#: src/components/GlobalPlayer.tsx:176
#: src/components/GlobalPlayer.tsx:234
msgid "Collapse player"
msgstr "Collapse player"
@@ -320,7 +320,7 @@ msgstr "Could not change password"
msgid "Could not load."
msgstr "Could not load."
#: src/pages/DumpEdit.tsx:371
#: src/pages/DumpEdit.tsx:434
msgid "Could not save"
msgstr "Could not save"
@@ -365,7 +365,7 @@ msgstr "Default tab"
#: src/components/ChatModal.tsx:241
#: src/components/CommentThread.tsx:390
#: src/components/CommentThread.tsx:396
#: src/components/ConfirmModal.tsx:16
#: src/components/ConfirmModal.tsx:17
#: src/pages/PlaylistDetail.tsx:919
msgid "Delete"
msgstr "Delete"
@@ -379,8 +379,8 @@ msgstr "Delete category"
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:466
#: src/pages/DumpEdit.tsx:265
#: src/pages/DumpEdit.tsx:480
msgid "Delete dump"
msgstr "Delete dump"
@@ -394,7 +394,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?"
msgstr "Delete this comment?"
#: src/pages/DumpEdit.tsx:263
#: src/pages/DumpEdit.tsx:264
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
@@ -428,7 +428,7 @@ msgstr "Drop a file here"
msgid "Drop a file here, or paste one"
msgstr "Drop a file here, or paste one"
#: src/pages/DumpEdit.tsx:438
#: src/pages/DumpEdit.tsx:426
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
@@ -514,7 +514,7 @@ msgstr "Email address"
msgid "Enter a query to search."
msgstr "Enter a query to search."
#: src/components/GlobalPlayer.tsx:176
#: src/components/GlobalPlayer.tsx:234
msgid "Expand player"
msgstr "Expand player"
@@ -855,7 +855,7 @@ msgstr "New playlist"
msgid "Next →"
msgstr "Next →"
#: src/components/GlobalPlayer.tsx:166
#: src/components/GlobalPlayer.tsx:224
msgid "Next track"
msgstr "Next track"
@@ -1006,7 +1006,7 @@ msgstr "Post reply"
msgid "Posting…"
msgstr "Posting…"
#: src/components/GlobalPlayer.tsx:154
#: src/components/GlobalPlayer.tsx:208
msgid "Previous track"
msgstr "Previous track"
@@ -1034,15 +1034,15 @@ msgstr "public"
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:241
#: src/pages/DumpEdit.tsx:245
msgid "Refresh metadata"
msgstr "Refresh metadata"
#: src/components/GlobalPlayer.tsx:240
#: src/components/GlobalPlayer.tsx:304
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
#: src/pages/DumpEdit.tsx:240
#: src/pages/DumpEdit.tsx:244
msgid "Refreshing…"
msgstr "Refreshing…"
@@ -1080,7 +1080,7 @@ msgstr "Remove like"
msgid "Remove vote"
msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:430
#: src/pages/DumpEdit.tsx:418
msgid "Replace file"
msgstr "Replace file"
@@ -1106,8 +1106,8 @@ msgstr "Reset failed"
msgid "Reset password"
msgstr "Reset password"
#: src/pages/DumpEdit.tsx:391
#: src/pages/DumpEdit.tsx:409
#: src/pages/DumpEdit.tsx:399
#: src/pages/DumpEdit.tsx:448
msgid "Reset to default"
msgstr "Reset to default"
@@ -1128,7 +1128,7 @@ msgstr "Role"
#: src/components/ChatModal.tsx:224
#: src/components/CommentThread.tsx:341
#: src/pages/Dump.tsx:398
#: src/pages/DumpEdit.tsx:473
#: src/pages/DumpEdit.tsx:487
#: src/pages/PlaylistDetail.tsx:930
#: src/pages/UserPublicProfile.tsx:1725
#: src/pages/UserPublicProfile.tsx:1798
@@ -1237,13 +1237,13 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed."
#: src/pages/DumpEdit.tsx:375
#: src/pages/DumpEdit.tsx:383
msgid "Thumbnail"
msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:174
#: src/components/PlaylistCreateForm.tsx:74
#: src/pages/DumpEdit.tsx:399
#: src/pages/DumpEdit.tsx:438
msgid "Title"
msgstr "Title"
@@ -1256,6 +1256,11 @@ msgstr "Title is required."
msgid "Today"
msgstr "Today"
#. placeholder {0}: i + 1
#: src/components/GlobalPlayer.tsx:318
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/ChatModal.tsx:637
msgid "Type a message…"
msgstr "Type a message…"
@@ -1299,7 +1304,7 @@ msgstr "Upvoted"
msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
#: src/pages/DumpEdit.tsx:422
#: src/pages/DumpEdit.tsx:410
msgid "URL"
msgstr "URL"
@@ -1345,7 +1350,7 @@ msgid "View dump →"
msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:905
#: src/pages/DumpEdit.tsx:447
#: src/pages/DumpEdit.tsx:459
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
@@ -1363,7 +1368,7 @@ msgid "Why & where"
msgstr "Why & where"
#: src/components/DumpCreateModal.tsx:904
#: src/pages/DumpEdit.tsx:446
#: src/pages/DumpEdit.tsx:458
msgid "Why?"
msgstr "Why?"

File diff suppressed because one or more lines are too long

View File

@@ -212,11 +212,11 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:231
#: src/components/CommentThread.tsx:127
#: src/components/ConfirmModal.tsx:32
#: src/components/ConfirmModal.tsx:34
#: src/components/DumpCreateModal.tsx:946
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:406
#: src/pages/DumpEdit.tsx:470
#: src/pages/DumpEdit.tsx:484
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/UserPublicProfile.tsx:1733
#: src/pages/UserPublicProfile.tsx:1806
@@ -275,7 +275,7 @@ msgid "Checking invite…"
msgstr "Vérification de l'invitation…"
#: src/components/ChangePasswordModal.tsx:56
#: src/components/Modal.tsx:58
#: src/components/Modal.tsx:54
msgid "Close"
msgstr "Fermer"
@@ -283,7 +283,7 @@ msgstr "Fermer"
msgid "Close anyway"
msgstr "Fermer quand même"
#: src/components/GlobalPlayer.tsx:184
#: src/components/GlobalPlayer.tsx:242
msgid "Close player"
msgstr "Fermer le lecteur"
@@ -291,7 +291,7 @@ msgstr "Fermer le lecteur"
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
#: src/components/GlobalPlayer.tsx:176
#: src/components/GlobalPlayer.tsx:234
msgid "Collapse player"
msgstr "Réduire le lecteur"
@@ -320,7 +320,7 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not load."
msgstr "Impossible de charger."
#: src/pages/DumpEdit.tsx:371
#: src/pages/DumpEdit.tsx:434
msgid "Could not save"
msgstr "Sauvegarde impossible"
@@ -365,7 +365,7 @@ msgstr "Onglet par défaut"
#: src/components/ChatModal.tsx:241
#: src/components/CommentThread.tsx:390
#: src/components/CommentThread.tsx:396
#: src/components/ConfirmModal.tsx:16
#: src/components/ConfirmModal.tsx:17
#: src/pages/PlaylistDetail.tsx:919
msgid "Delete"
msgstr "Supprimer"
@@ -379,8 +379,8 @@ msgstr "Supprimer la catégorie"
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
#: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:466
#: src/pages/DumpEdit.tsx:265
#: src/pages/DumpEdit.tsx:480
msgid "Delete dump"
msgstr "Supprimer la reco"
@@ -394,7 +394,7 @@ msgstr "Supprimer la collection"
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
#: src/pages/DumpEdit.tsx:263
#: src/pages/DumpEdit.tsx:264
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
@@ -428,7 +428,7 @@ msgstr "Déposez un fichier ici"
msgid "Drop a file here, or paste one"
msgstr "Déposez un fichier ici, ou collez-en un"
#: src/pages/DumpEdit.tsx:438
#: src/pages/DumpEdit.tsx:426
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
@@ -514,7 +514,7 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
#: src/components/GlobalPlayer.tsx:176
#: src/components/GlobalPlayer.tsx:234
msgid "Expand player"
msgstr "Agrandir le lecteur"
@@ -855,7 +855,7 @@ msgstr "Nouvelle collection"
msgid "Next →"
msgstr "Suivant →"
#: src/components/GlobalPlayer.tsx:166
#: src/components/GlobalPlayer.tsx:224
msgid "Next track"
msgstr "Piste suivante"
@@ -1006,7 +1006,7 @@ msgstr "Publier la réponse"
msgid "Posting…"
msgstr "Publication…"
#: src/components/GlobalPlayer.tsx:154
#: src/components/GlobalPlayer.tsx:208
msgid "Previous track"
msgstr "Piste précédente"
@@ -1034,15 +1034,15 @@ msgstr "public"
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:241
#: src/pages/DumpEdit.tsx:245
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
#: src/components/GlobalPlayer.tsx:240
#: src/components/GlobalPlayer.tsx:304
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
#: src/pages/DumpEdit.tsx:240
#: src/pages/DumpEdit.tsx:244
msgid "Refreshing…"
msgstr "Actualisation…"
@@ -1080,7 +1080,7 @@ msgstr "Retirer le j'aime"
msgid "Remove vote"
msgstr "Retirer le vote"
#: src/pages/DumpEdit.tsx:430
#: src/pages/DumpEdit.tsx:418
msgid "Replace file"
msgstr "Remplacer le fichier"
@@ -1106,8 +1106,8 @@ msgstr "Échec de la réinitialisation"
msgid "Reset password"
msgstr "Réinitialiser le mot de passe"
#: src/pages/DumpEdit.tsx:391
#: src/pages/DumpEdit.tsx:409
#: src/pages/DumpEdit.tsx:399
#: src/pages/DumpEdit.tsx:448
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
@@ -1128,7 +1128,7 @@ msgstr "Rôle"
#: src/components/ChatModal.tsx:224
#: src/components/CommentThread.tsx:341
#: src/pages/Dump.tsx:398
#: src/pages/DumpEdit.tsx:473
#: src/pages/DumpEdit.tsx:487
#: src/pages/PlaylistDetail.tsx:930
#: src/pages/UserPublicProfile.tsx:1725
#: src/pages/UserPublicProfile.tsx:1798
@@ -1237,13 +1237,13 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/DumpEdit.tsx:375
#: src/pages/DumpEdit.tsx:383
msgid "Thumbnail"
msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:174
#: src/components/PlaylistCreateForm.tsx:74
#: src/pages/DumpEdit.tsx:399
#: src/pages/DumpEdit.tsx:438
msgid "Title"
msgstr "Titre"
@@ -1256,6 +1256,11 @@ msgstr "Un titre est requis."
msgid "Today"
msgstr "Aujourd'hui"
#. placeholder {0}: i + 1
#: src/components/GlobalPlayer.tsx:318
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/ChatModal.tsx:637
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1299,7 +1304,7 @@ msgstr "Voté"
msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
#: src/pages/DumpEdit.tsx:422
#: src/pages/DumpEdit.tsx:410
msgid "URL"
msgstr "URL"
@@ -1345,7 +1350,7 @@ msgid "View dump →"
msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:905
#: src/pages/DumpEdit.tsx:447
#: src/pages/DumpEdit.tsx:459
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
@@ -1363,7 +1368,7 @@ msgid "Why & where"
msgstr "Pourquoi & où"
#: src/components/DumpCreateModal.tsx:904
#: src/pages/DumpEdit.tsx:446
#: src/pages/DumpEdit.tsx:458
msgid "Why?"
msgstr "Pourquoi ?"

58
src/utils/youtube.ts Normal file
View File

@@ -0,0 +1,58 @@
import { API_URL } from "../config/api.ts";
/** Mirrors one entry of GET /api/youtube/playlist. */
export interface YouTubeTrack {
videoId: string;
title: string;
/** Seconds. */
duration?: number;
}
/**
* The playlist an embed URL plays, if any.
*
* Covers both embed shapes the provider produces: `/embed/videoseries?list=…`
* for a bare playlist, and `/embed/<id>?list=…` for a video opened in one.
*/
export function embedListId(embedUrl: string): string | null {
try {
return new URL(embedUrl).searchParams.get("list");
} catch {
return null;
}
}
/**
* Add the parameters the IFrame Player API needs to talk to the embed.
*
* `enablejsapi` has to be in the src the iframe was created with — it cannot be
* turned on afterwards — so it is added for every playlist embed, whether or
* not the API script ends up loading.
*/
export function withJsApi(url: string): string {
try {
const u = new URL(url);
u.searchParams.set("enablejsapi", "1");
u.searchParams.set("origin", globalThis.location.origin);
return u.toString();
} catch {
return url;
}
}
/** Titles and lengths for a playlist. Throws; callers fall back to no list. */
export async function fetchPlaylistTracks(
listId: string,
): Promise<YouTubeTrack[]> {
const res = await fetch(
`${API_URL}/api/youtube/playlist?list=${encodeURIComponent(listId)}`,
);
if (!res.ok) throw new Error(`playlist fetch failed: ${res.status}`);
const body = await res.json() as {
success: boolean;
data?: { tracks?: YouTubeTrack[] };
};
const tracks = body.data?.tracks;
if (!body.success || !tracks?.length) throw new Error("no tracks");
return tracks;
}