v3: native bandcamp playback and a queue in the global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
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
This commit is contained in:
@@ -4,7 +4,7 @@ import { formatBytes } from "../utils/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
@@ -20,11 +20,39 @@ interface FilePreviewProps {
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the global-player item for an uploaded file, so every entry point
|
||||
* (compact cards, the detail page, the waveform) puts the same title, artwork
|
||||
* and subtitle in the player header.
|
||||
*
|
||||
* Artwork is only claimed when one actually exists: videos get their generated
|
||||
* still and any dump can carry an uploaded thumbnail, but a bare audio file has
|
||||
* neither, and pointing at the endpoint anyway would just render a placeholder.
|
||||
*/
|
||||
function filePlayerItem(
|
||||
dump: Dump,
|
||||
fileUrl: string,
|
||||
mime: string,
|
||||
token?: string | null,
|
||||
): PlayerItem {
|
||||
const hasArtwork = !!dump.thumbnailMime || mime.startsWith("video/");
|
||||
return {
|
||||
kind: "file",
|
||||
fileUrl,
|
||||
mimeType: mime,
|
||||
title: dump.title,
|
||||
dumpHref: dumpUrl(dump),
|
||||
artworkUrl: hasArtwork ? dumpThumbnailUrl(dump, token) : undefined,
|
||||
subtitle: dump.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
// Waveform preview for the dump detail page — routes to global player,
|
||||
// reflects live play state and position from PlayerContext.
|
||||
function AudioFilePreview(
|
||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||
) {
|
||||
const { token } = useAuth();
|
||||
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
||||
useContext(PlayerContext);
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
@@ -45,7 +73,7 @@ function AudioFilePreview(
|
||||
|
||||
const handlePlayBtn = () => {
|
||||
if (isActive) togglePlay();
|
||||
else play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) });
|
||||
else play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
};
|
||||
|
||||
const handleWaveformClick = (e: React.MouseEvent<Element>) => {
|
||||
@@ -59,7 +87,7 @@ function AudioFilePreview(
|
||||
} else {
|
||||
// Start playing and seek once it loads — seekTo after play() is a no-op
|
||||
// until MediaPlayer mounts; the fraction is best-effort on first click
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) });
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,7 +206,7 @@ export default function FilePreview(
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) });
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}}
|
||||
>
|
||||
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
|
||||
@@ -196,7 +224,7 @@ export default function FilePreview(
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) });
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}}
|
||||
>
|
||||
{thumbOverride
|
||||
@@ -230,12 +258,9 @@ export default function FilePreview(
|
||||
type="button"
|
||||
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||
onClick={() =>
|
||||
videoActive ? togglePlay() : play({
|
||||
kind: "file",
|
||||
fileUrl,
|
||||
mimeType: mime,
|
||||
title: dump.title,
|
||||
})}
|
||||
videoActive
|
||||
? togglePlay()
|
||||
: play(filePlayerItem(dump, fileUrl, mime, token))}
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
|
||||
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" }>;
|
||||
|
||||
function itemKey(
|
||||
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
|
||||
) {
|
||||
if (!item) return null;
|
||||
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
|
||||
}
|
||||
|
||||
// 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, so it is left alone.
|
||||
// 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 {
|
||||
@@ -35,20 +37,32 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||
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(itemKey(current));
|
||||
const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
|
||||
|
||||
if (prevKey !== itemKey(current)) {
|
||||
setPrevKey(itemKey(current));
|
||||
const currentKey = current ? playerItemKey(current) : null;
|
||||
if (prevKey !== currentKey) {
|
||||
setPrevKey(currentKey);
|
||||
if (current) setReduced(false);
|
||||
}
|
||||
|
||||
@@ -79,16 +93,27 @@ export function GlobalPlayer() {
|
||||
};
|
||||
}, [current]);
|
||||
|
||||
// Keep the playing row visible as the queue advances on its own.
|
||||
useEffect(() => {
|
||||
currentRowRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [queueIndex]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const typeClass = current.kind === "embed"
|
||||
? current.type
|
||||
: current.mimeType.startsWith("video/")
|
||||
? "file-video"
|
||||
: "file-audio";
|
||||
// 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.fileUrl);
|
||||
(current.kind === "embed"
|
||||
? current.embedUrl
|
||||
: current.kind === "file"
|
||||
? current.fileUrl
|
||||
: current.streamUrl);
|
||||
|
||||
const showQueue = queue.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -98,21 +123,66 @@ export function GlobalPlayer() {
|
||||
ref={ref}
|
||||
>
|
||||
<div className="global-player-header">
|
||||
{current.dumpHref
|
||||
? (
|
||||
<Link to={current.dumpHref} className="global-player-title">
|
||||
{title}
|
||||
</Link>
|
||||
)
|
||||
: <span className="global-player-title">{title}</span>}
|
||||
{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}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={stop}
|
||||
aria-label={t`Close player`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -128,7 +198,8 @@ export function GlobalPlayer() {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
: current.kind === "file"
|
||||
? (
|
||||
<div className="global-player-media-wrap">
|
||||
<MediaPlayer
|
||||
key={current.fileUrl}
|
||||
@@ -139,11 +210,71 @@ export function GlobalPlayer() {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useContext } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../model.ts";
|
||||
@@ -11,7 +10,10 @@ import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
|
||||
export type { JournalShape };
|
||||
|
||||
@@ -32,7 +34,7 @@ export function JournalCard(
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const { play } = useContext(PlayerContext);
|
||||
const { playRichContent } = usePlayRichContent();
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
|
||||
@@ -71,7 +73,13 @@ export function JournalCard(
|
||||
})()
|
||||
: "🔗";
|
||||
|
||||
const embedUrl = dump.richContent?.embedUrl;
|
||||
const richContent = dump.richContent;
|
||||
// In native mode a Bandcamp page is playable even with no stored embedUrl.
|
||||
// The card's own thumbnail (dump upload or provider) becomes the player's
|
||||
// header artwork.
|
||||
const playable = richContent && canPlayRichContent(richContent)
|
||||
? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined }
|
||||
: null;
|
||||
|
||||
const titleLink = (
|
||||
<Link
|
||||
@@ -142,15 +150,8 @@ export function JournalCard(
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
onClick={embedUrl
|
||||
? () =>
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl,
|
||||
title: dump.richContent?.title,
|
||||
type: dump.richContent?.type ?? "unknown",
|
||||
dumpHref: dumpUrl(dump),
|
||||
})
|
||||
onClick={playable
|
||||
? () => void playRichContent(playable, dumpUrl(dump))
|
||||
: handleNavigate}
|
||||
>
|
||||
<div className="journal-card-image">
|
||||
@@ -167,7 +168,7 @@ export function JournalCard(
|
||||
siteName: dump.richContent?.siteName,
|
||||
}}
|
||||
/>
|
||||
{embedUrl && (
|
||||
{playable && (
|
||||
<span className="rich-content-play-overlay" aria-hidden="true">
|
||||
▶
|
||||
</span>
|
||||
|
||||
@@ -7,13 +7,7 @@ import {
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
|
||||
function fmt(s: number): string {
|
||||
if (!isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
|
||||
export const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
@@ -119,6 +113,72 @@ function Waveform(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seek bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
|
||||
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
|
||||
function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
},
|
||||
) {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
onMouseDown={() => onDragChange(true)}
|
||||
onMouseUp={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MediaPlayer ───────────────────────────────────────────────────────────────
|
||||
|
||||
const HIDE_DELAY = 2500;
|
||||
@@ -132,6 +192,14 @@ interface MediaPlayerProps {
|
||||
startTime?: number;
|
||||
onPlayStateChange?: (playing: boolean) => void;
|
||||
onTimeUpdate?: (time: number, duration: number) => void;
|
||||
/** Fired when the media reaches its end — the queue's advance hook. */
|
||||
onEnded?: () => void;
|
||||
/** Fired when the element fails to load or decode. For expiring stream URLs
|
||||
* this is the signal to re-resolve. */
|
||||
onError?: () => void;
|
||||
/** "waveform" (default) decodes peaks from the source; "progress" draws a
|
||||
* slim seek bar and never fetches the media a second time. */
|
||||
trackStyle?: "waveform" | "progress";
|
||||
seekRef?: { current: ((t: number) => void) | null };
|
||||
toggleRef?: { current: (() => void) | null };
|
||||
}
|
||||
@@ -146,6 +214,9 @@ export function MediaPlayer(
|
||||
startTime,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onError,
|
||||
trackStyle = "waveform",
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
@@ -155,8 +226,8 @@ export function MediaPlayer(
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(lastVolume);
|
||||
const [muted, setMuted] = useState(lastMuted);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -166,11 +237,15 @@ export function MediaPlayer(
|
||||
// and effect, acceptable since these are only called from async event handlers.
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onEndedRef = useRef(onEnded);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
// Sync prop callbacks after every render
|
||||
useEffect(() => {
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
onEndedRef.current = onEnded;
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
// Stable function refs — updated via effects, indirected by the registration
|
||||
@@ -226,6 +301,17 @@ export function MediaPlayer(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Apply the remembered volume to the freshly-mounted element, which always
|
||||
// starts at 1.0 regardless of what the last track was playing at.
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current;
|
||||
if (!a) return;
|
||||
a.volume = volume;
|
||||
a.muted = muted;
|
||||
// Mount only: later changes go through changeVolume/toggleMute.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
@@ -245,6 +331,8 @@ export function MediaPlayer(
|
||||
a.pause();
|
||||
onPlayStateChangeRef.current = undefined;
|
||||
onTimeUpdateRef.current = undefined;
|
||||
onEndedRef.current = undefined;
|
||||
onErrorRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -272,17 +360,24 @@ export function MediaPlayer(
|
||||
setDuration(a.duration);
|
||||
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||
};
|
||||
const onEnded = () => {
|
||||
const onEndedEvent = () => {
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
onEndedRef.current?.();
|
||||
};
|
||||
const onErrorEvent = () => {
|
||||
setPlaying(false);
|
||||
onErrorRef.current?.();
|
||||
};
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
a.addEventListener("durationchange", onDuration);
|
||||
a.addEventListener("ended", onEnded);
|
||||
a.addEventListener("ended", onEndedEvent);
|
||||
a.addEventListener("error", onErrorEvent);
|
||||
return () => {
|
||||
a.removeEventListener("timeupdate", onTime);
|
||||
a.removeEventListener("durationchange", onDuration);
|
||||
a.removeEventListener("ended", onEnded);
|
||||
a.removeEventListener("ended", onEndedEvent);
|
||||
a.removeEventListener("error", onErrorEvent);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
@@ -317,32 +412,32 @@ export function MediaPlayer(
|
||||
}
|
||||
};
|
||||
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
seekTo(Number(e.target.value));
|
||||
|
||||
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
const nextMuted = v > 0 && muted ? false : muted;
|
||||
setVolume(v);
|
||||
mediaRef.current!.volume = v;
|
||||
if (v > 0 && muted) {
|
||||
setMuted(false);
|
||||
mediaRef.current!.muted = false;
|
||||
if (nextMuted !== muted) {
|
||||
setMuted(nextMuted);
|
||||
mediaRef.current!.muted = nextMuted;
|
||||
}
|
||||
rememberVolume(v, nextMuted);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const next = !muted;
|
||||
setMuted(next);
|
||||
mediaRef.current!.muted = next;
|
||||
rememberVolume(volume, next);
|
||||
};
|
||||
|
||||
const goFullscreen = () => {
|
||||
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const track = kind === "audio"
|
||||
// A waveform needs the raw bytes, which cross-origin streams won't hand over.
|
||||
// Those get an honest progress bar rather than a permanent loading skeleton.
|
||||
const track = kind === "audio" && trackStyle === "waveform"
|
||||
? (
|
||||
<Waveform
|
||||
src={src}
|
||||
@@ -352,24 +447,13 @@ export function MediaPlayer(
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="audio-player-track">
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
onMouseDown={() => setDragging(true)}
|
||||
onMouseUp={() => setDragging(false)}
|
||||
onChange={seek}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
</div>
|
||||
<SeekBar
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
onDragChange={setDragging}
|
||||
className={kind === "audio" ? "audio-player-track--stream" : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
const controls = (
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
|
||||
interface RichContentCardProps {
|
||||
@@ -16,8 +20,12 @@ export default function RichContentCard(
|
||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||
RichContentCardProps,
|
||||
) {
|
||||
const { play, current, playing } = useContext(PlayerContext);
|
||||
const { current, playing } = useContext(PlayerContext);
|
||||
const { playRichContent, pending } = usePlayRichContent();
|
||||
const canPlay = canPlayRichContent(richContent);
|
||||
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
|
||||
// The dump's own thumbnail overrides the provider's, in the player header too.
|
||||
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
|
||||
|
||||
const placeholder = {
|
||||
url: richContent.url,
|
||||
@@ -37,26 +45,26 @@ export default function RichContentCard(
|
||||
/>
|
||||
);
|
||||
|
||||
if (richContent.embedUrl) {
|
||||
const isActive = current?.kind === "embed" &&
|
||||
current.embedUrl === richContent.embedUrl;
|
||||
if (canPlay) {
|
||||
// A native Bandcamp queue is the same card as its embed, matched on the
|
||||
// source page rather than a stream URL that changes on every resolve.
|
||||
const isActive = current != null &&
|
||||
((current.kind === "embed" &&
|
||||
current.embedUrl === richContent.embedUrl) ||
|
||||
(current.kind === "stream" &&
|
||||
current.resolveUrl === richContent.url));
|
||||
const isPlaying = isActive && playing;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isActive ? " is-playing" : ""
|
||||
}`}
|
||||
}${pending ? " is-pending" : ""}`}
|
||||
disabled={pending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
dumpHref,
|
||||
});
|
||||
void playRichContent(playable, dumpHref);
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
@@ -81,8 +89,6 @@ export default function RichContentCard(
|
||||
);
|
||||
}
|
||||
|
||||
const canPlay = !!richContent.embedUrl;
|
||||
|
||||
const thumbnailImg = (
|
||||
<Thumbnail
|
||||
src={thumbnailSrc}
|
||||
@@ -97,15 +103,9 @@ export default function RichContentCard(
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="rich-content-thumbnail-btn"
|
||||
onClick={() =>
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
dumpHref,
|
||||
})}
|
||||
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
|
||||
disabled={pending}
|
||||
onClick={() => void playRichContent(playable, dumpHref)}
|
||||
aria-label="Play"
|
||||
>
|
||||
{thumbnailImg}
|
||||
|
||||
Reference in New Issue
Block a user