All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
The player now holds a queue instead of a single item, so albums play through and single tracks share the same code path. Adds a "stream" item kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs for ~24h, so an item carries the page it came from and re-resolves itself on error or on a stale restored session, falling back to the iframe embed if that fails too. Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a meta tag the same way as site-name/site-emoji. Also: player header gains artwork and a subtitle, volume persists across queue advances and reloads, and cross-origin streams get a plain seek bar rather than a waveform that can never decode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
282 lines
8.9 KiB
TypeScript
282 lines
8.9 KiB
TypeScript
import { useContext, useEffect, useRef, useState } from "react";
|
|
import { Link } from "react-router";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { t } from "@lingui/core/macro";
|
|
import {
|
|
PlayerContext,
|
|
type PlayerItem,
|
|
playerItemKey,
|
|
} from "../contexts/PlayerContext.ts";
|
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
|
import { fmt } from "../utils/duration.ts";
|
|
import Thumbnail from "./Thumbnail.tsx";
|
|
|
|
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
|
|
|
// 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
|
|
// session restored from localStorage has no user gesture behind it, so it stays
|
|
// paused, matching how MediaPlayer treats file items.
|
|
// 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;
|
|
try {
|
|
const url = new URL(item.embedUrl);
|
|
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
|
|
else if (item.type === "soundcloud") {
|
|
url.searchParams.set("auto_play", "true");
|
|
} else return item.embedUrl;
|
|
return url.toString();
|
|
} catch {
|
|
return item.embedUrl; // malformed stored URL — hand it to the iframe as-is
|
|
}
|
|
}
|
|
|
|
export function GlobalPlayer() {
|
|
const {
|
|
current,
|
|
queue,
|
|
queueIndex,
|
|
hasNext,
|
|
hasPrevious,
|
|
resolving,
|
|
startTime,
|
|
autoplay,
|
|
stop,
|
|
next,
|
|
previous,
|
|
playAt,
|
|
seekRef,
|
|
toggleRef,
|
|
onPlayStateChange,
|
|
onTimeUpdate,
|
|
onEnded,
|
|
onError,
|
|
} = useContext(PlayerContext);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const currentRowRef = useRef<HTMLLIElement>(null);
|
|
const [reduced, setReduced] = useState(false);
|
|
const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
|
|
|
|
const currentKey = current ? playerItemKey(current) : null;
|
|
if (prevKey !== currentKey) {
|
|
setPrevKey(currentKey);
|
|
if (current) setReduced(false);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!current) {
|
|
document.body.classList.remove("has-player");
|
|
document.body.style.removeProperty("--player-height");
|
|
return;
|
|
}
|
|
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
|
|
document.body.classList.add("has-player");
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
document.body.style.setProperty(
|
|
"--player-height",
|
|
`${el.offsetHeight}px`,
|
|
);
|
|
});
|
|
observer.observe(el);
|
|
return () => {
|
|
observer.disconnect();
|
|
document.body.classList.remove("has-player");
|
|
document.body.style.removeProperty("--player-height");
|
|
};
|
|
}, [current]);
|
|
|
|
// Keep the playing row visible as the queue advances on its own.
|
|
useEffect(() => {
|
|
currentRowRef.current?.scrollIntoView({ block: "nearest" });
|
|
}, [queueIndex]);
|
|
|
|
if (!current) return null;
|
|
|
|
// Files are classed by their media kind; everything else carries a brand key,
|
|
// so a native Bandcamp stream keeps the same styling as the Bandcamp embed.
|
|
const typeClass = current.kind === "file"
|
|
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
|
|
: current.type;
|
|
|
|
const title = current.title ??
|
|
(current.kind === "embed"
|
|
? current.embedUrl
|
|
: current.kind === "file"
|
|
? current.fileUrl
|
|
: current.streamUrl);
|
|
|
|
const showQueue = queue.length > 1;
|
|
|
|
return (
|
|
<div
|
|
className={`global-player global-player--${typeClass}${
|
|
reduced ? " global-player--reduced" : ""
|
|
}`}
|
|
ref={ref}
|
|
>
|
|
<div className="global-player-header">
|
|
{current.artworkUrl && (
|
|
<Thumbnail
|
|
src={current.artworkUrl}
|
|
className="global-player-artwork"
|
|
placeholder={{ siteName: current.subtitle }}
|
|
placeholderClassName="global-player-artwork"
|
|
loading="eager"
|
|
/>
|
|
)}
|
|
<div className="global-player-heading">
|
|
{current.dumpHref
|
|
? (
|
|
<Link to={current.dumpHref} className="global-player-title">
|
|
{title}
|
|
</Link>
|
|
)
|
|
: <span className="global-player-title">{title}</span>}
|
|
{current.subtitle && (
|
|
<span className="global-player-subtitle">{current.subtitle}</span>
|
|
)}
|
|
</div>
|
|
{showQueue && (
|
|
<div className="global-player-transport">
|
|
<button
|
|
type="button"
|
|
className="btn btn--ghost"
|
|
onClick={previous}
|
|
disabled={!hasPrevious}
|
|
aria-label={t`Previous track`}
|
|
>
|
|
⏮
|
|
</button>
|
|
<span className="global-player-position">
|
|
{queueIndex + 1} / {queue.length}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn--ghost"
|
|
onClick={next}
|
|
disabled={!hasNext}
|
|
aria-label={t`Next track`}
|
|
>
|
|
⏭
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn--ghost"
|
|
onClick={() => setReduced((r) => !r)}
|
|
aria-label={reduced ? t`Expand player` : t`Collapse player`}
|
|
>
|
|
{reduced ? "▲" : "▼"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn--ghost"
|
|
onClick={stop}
|
|
aria-label={t`Close player`}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="global-player-body">
|
|
{current.kind === "embed"
|
|
? (
|
|
<div className="global-player-iframe-wrap">
|
|
<iframe
|
|
src={playbackUrl(current, autoplay)}
|
|
className={`global-player-iframe--${current.type}`}
|
|
allow="autoplay; encrypted-media"
|
|
allowFullScreen
|
|
/>
|
|
</div>
|
|
)
|
|
: current.kind === "file"
|
|
? (
|
|
<div className="global-player-media-wrap">
|
|
<MediaPlayer
|
|
key={current.fileUrl}
|
|
src={current.fileUrl}
|
|
kind={current.mimeType.startsWith("video/") ? "video" : "audio"}
|
|
mime={current.mimeType}
|
|
autoplay={autoplay}
|
|
startTime={startTime}
|
|
onPlayStateChange={onPlayStateChange}
|
|
onTimeUpdate={onTimeUpdate}
|
|
onEnded={onEnded}
|
|
seekRef={seekRef}
|
|
toggleRef={toggleRef}
|
|
/>
|
|
</div>
|
|
)
|
|
: (
|
|
<div className="global-player-media-wrap">
|
|
<MediaPlayer
|
|
// Keyed on the signed URL so a re-resolve remounts the element
|
|
// and MediaPlayer's mount-only startTime effect resumes it.
|
|
key={current.streamUrl}
|
|
src={current.streamUrl}
|
|
kind="audio"
|
|
mime="audio/mpeg"
|
|
trackStyle="progress"
|
|
autoplay={autoplay}
|
|
startTime={startTime}
|
|
onPlayStateChange={onPlayStateChange}
|
|
onTimeUpdate={onTimeUpdate}
|
|
onEnded={onEnded}
|
|
onError={onError}
|
|
seekRef={seekRef}
|
|
toggleRef={toggleRef}
|
|
/>
|
|
{resolving && (
|
|
<p className="global-player-status">
|
|
<Trans>Refreshing stream…</Trans>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showQueue && !reduced && (
|
|
<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
|
|
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>
|
|
);
|
|
})}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|