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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user