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
555 lines
17 KiB
TypeScript
555 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
BAR_GAP,
|
|
BAR_W,
|
|
extractPeaks,
|
|
NUM_BARS,
|
|
VIEWBOX_W,
|
|
WAVEFORM_H,
|
|
} from "../utils/waveform.ts";
|
|
import { fmt } from "../utils/duration.ts";
|
|
|
|
export const IconPlay = () => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
|
<polygon points="6,3 20,12 6,21" />
|
|
</svg>
|
|
);
|
|
|
|
export const IconPause = () => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
|
<rect x="5" y="3" width="4" height="18" rx="1" />
|
|
<rect x="15" y="3" width="4" height="18" rx="1" />
|
|
</svg>
|
|
);
|
|
|
|
const IconVolume = ({ muted }: { muted: boolean }) => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
{muted
|
|
? (
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
|
)
|
|
: (
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
|
)}
|
|
</svg>
|
|
);
|
|
|
|
const IconFullscreen = () => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
|
</svg>
|
|
);
|
|
|
|
// ── Waveform ──────────────────────────────────────────────────────────────────
|
|
|
|
function Waveform(
|
|
{ src, current, duration, onSeek }: {
|
|
src: string;
|
|
current: number;
|
|
duration: number;
|
|
onSeek: (t: number) => void;
|
|
},
|
|
) {
|
|
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
extractPeaks(src, NUM_BARS)
|
|
.then((p) => {
|
|
if (!cancelled) setPeaks(p);
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [src]);
|
|
|
|
const progress = duration > 0 ? current / duration : 0;
|
|
|
|
const handleClick = (e: React.MouseEvent<Element>) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const ratio = Math.max(
|
|
0,
|
|
Math.min(1, (e.clientX - rect.left) / rect.width),
|
|
);
|
|
onSeek(ratio * duration);
|
|
};
|
|
|
|
if (!peaks) {
|
|
return (
|
|
<div className="waveform-skeleton" onClick={handleClick}>
|
|
<div
|
|
className="waveform-skeleton-fill"
|
|
style={{ width: `${progress * 100}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<svg
|
|
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
|
preserveAspectRatio="none"
|
|
className="waveform-svg"
|
|
onClick={handleClick}
|
|
>
|
|
{Array.from(peaks).map((p, i) => {
|
|
const barH = Math.max(p * WAVEFORM_H, 2);
|
|
const x = i * (BAR_W + BAR_GAP);
|
|
const y = (WAVEFORM_H - barH) / 2;
|
|
const played = i / NUM_BARS <= progress;
|
|
return (
|
|
<rect
|
|
key={i}
|
|
x={x}
|
|
y={y}
|
|
width={BAR_W}
|
|
height={barH}
|
|
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
interface MediaPlayerProps {
|
|
src: string;
|
|
kind: "audio" | "video";
|
|
mime?: string;
|
|
poster?: string;
|
|
autoplay?: boolean;
|
|
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 };
|
|
}
|
|
|
|
export function MediaPlayer(
|
|
{
|
|
src,
|
|
kind,
|
|
mime,
|
|
poster,
|
|
autoplay,
|
|
startTime,
|
|
onPlayStateChange,
|
|
onTimeUpdate,
|
|
onEnded,
|
|
onError,
|
|
trackStyle = "waveform",
|
|
seekRef,
|
|
toggleRef,
|
|
}: MediaPlayerProps,
|
|
) {
|
|
const mediaRef = useRef<HTMLMediaElement>(null);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [current, setCurrent] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [dragging, setDragging] = 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);
|
|
|
|
// ── Callback refs ─────────────────────────────────────────────────────────────
|
|
// Updated via effects (after render) so the linter doesn't flag render-phase ref
|
|
// mutation. No cleanup → no null window. Negligible stale-window between commit
|
|
// 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
|
|
// effect below so external seekRef/toggleRef never see a null window.
|
|
const seekToFnRef = useRef((_t: number) => {});
|
|
const toggleFnRef = useRef(() => {});
|
|
|
|
// seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref)
|
|
useEffect(() => {
|
|
seekToFnRef.current = (time: number) => {
|
|
setCurrent(time);
|
|
mediaRef.current!.currentTime = time;
|
|
};
|
|
}, []);
|
|
|
|
// toggle: captures `playing` — re-synced whenever play state changes
|
|
useEffect(() => {
|
|
toggleFnRef.current = () => {
|
|
const a = mediaRef.current!;
|
|
if (playing) {
|
|
a.pause();
|
|
setPlaying(false);
|
|
onPlayStateChangeRef.current?.(false);
|
|
} else {
|
|
a.play()
|
|
.then(() => {
|
|
setPlaying(true);
|
|
onPlayStateChangeRef.current?.(true);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
};
|
|
}, [playing]);
|
|
|
|
// Stable wrappers used everywhere inside the component
|
|
const seekTo = (time: number) => seekToFnRef.current(time);
|
|
const toggle = () => toggleFnRef.current();
|
|
|
|
// ── Effects ──────────────────────────────────────────────────────────────────
|
|
|
|
// Resume to a restored offset once metadata (and thus seekability) is available.
|
|
// Runs only on mount for the initial offset — later seeks go through seekTo.
|
|
useEffect(() => {
|
|
if (!startTime) return;
|
|
const a = mediaRef.current!;
|
|
const apply = () => {
|
|
a.currentTime = startTime;
|
|
setCurrent(startTime);
|
|
};
|
|
if (a.readyState >= 1) apply();
|
|
else a.addEventListener("loadedmetadata", apply, { once: true });
|
|
return () => a.removeEventListener("loadedmetadata", apply);
|
|
// 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;
|
|
mediaRef.current?.play()
|
|
.then(() => {
|
|
setPlaying(true);
|
|
onPlayStateChangeRef.current?.(true);
|
|
})
|
|
.catch(() => {});
|
|
}, [autoplay]);
|
|
|
|
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire
|
|
// between React's commit and the listener-removal effect can't reach the provider.
|
|
useEffect(() => {
|
|
const a = mediaRef.current!;
|
|
return () => {
|
|
a.pause();
|
|
onPlayStateChangeRef.current = undefined;
|
|
onTimeUpdateRef.current = undefined;
|
|
onEndedRef.current = undefined;
|
|
onErrorRef.current = undefined;
|
|
};
|
|
}, []);
|
|
|
|
// Register imperative handles into provider refs. seekRef/toggleRef are stable
|
|
// (created with useRef in PlayerProvider), so this effect runs exactly once.
|
|
useEffect(() => {
|
|
if (seekRef) seekRef.current = (t) => seekToFnRef.current(t);
|
|
if (toggleRef) toggleRef.current = () => toggleFnRef.current();
|
|
return () => {
|
|
if (seekRef) seekRef.current = null;
|
|
if (toggleRef) toggleRef.current = null;
|
|
};
|
|
}, [seekRef, toggleRef]);
|
|
|
|
// Media element event listeners
|
|
useEffect(() => {
|
|
const a = mediaRef.current!;
|
|
const onTime = () => {
|
|
if (!dragging) {
|
|
setCurrent(a.currentTime);
|
|
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
|
}
|
|
};
|
|
const onDuration = () => {
|
|
setDuration(a.duration);
|
|
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
|
};
|
|
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", onEndedEvent);
|
|
a.addEventListener("error", onErrorEvent);
|
|
return () => {
|
|
a.removeEventListener("timeupdate", onTime);
|
|
a.removeEventListener("durationchange", onDuration);
|
|
a.removeEventListener("ended", onEndedEvent);
|
|
a.removeEventListener("error", onErrorEvent);
|
|
};
|
|
}, [dragging]);
|
|
|
|
// Hide video controls after inactivity
|
|
useEffect(() => {
|
|
if (kind !== "video") return;
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
if (playing) {
|
|
hideTimer.current = setTimeout(
|
|
() => setControlsVisible(false),
|
|
HIDE_DELAY,
|
|
);
|
|
}
|
|
return () => {
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
};
|
|
}, [playing, kind]);
|
|
|
|
// ── Render helpers ────────────────────────────────────────────────────────────
|
|
|
|
const showingControls = kind !== "video" || !playing || controlsVisible;
|
|
|
|
const showControlsTemporarily = () => {
|
|
if (kind !== "video") return;
|
|
setControlsVisible(true);
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
if (playing) {
|
|
hideTimer.current = setTimeout(
|
|
() => setControlsVisible(false),
|
|
HIDE_DELAY,
|
|
);
|
|
}
|
|
};
|
|
|
|
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 (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?.();
|
|
};
|
|
|
|
// 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}
|
|
current={current}
|
|
duration={duration}
|
|
onSeek={seekTo}
|
|
/>
|
|
)
|
|
: (
|
|
<SeekBar
|
|
current={current}
|
|
duration={duration}
|
|
onSeek={seekTo}
|
|
onDragChange={setDragging}
|
|
className={kind === "audio" ? "audio-player-track--stream" : undefined}
|
|
/>
|
|
);
|
|
|
|
const controls = (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="audio-player-btn"
|
|
onClick={toggle}
|
|
aria-label={playing ? "Pause" : "Play"}
|
|
>
|
|
{playing ? <IconPause /> : <IconPlay />}
|
|
</button>
|
|
|
|
<span className="audio-player-time">{fmt(current)}</span>
|
|
|
|
{track}
|
|
|
|
<span className="audio-player-time">{fmt(duration)}</span>
|
|
|
|
<div className="audio-player-volume">
|
|
<button
|
|
type="button"
|
|
className="audio-player-vol-btn"
|
|
onClick={toggleMute}
|
|
aria-label={muted ? "Unmute" : "Mute"}
|
|
>
|
|
<IconVolume muted={muted} />
|
|
</button>
|
|
<div className="audio-player-track audio-player-track--volume">
|
|
<div
|
|
className="audio-player-fill"
|
|
style={{ width: `${(muted ? 0 : volume) * 100}%` }}
|
|
/>
|
|
<input
|
|
type="range"
|
|
className="audio-player-range"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={muted ? 0 : volume}
|
|
onChange={changeVolume}
|
|
aria-label="Volume"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{kind === "video" && (
|
|
<button
|
|
type="button"
|
|
className="audio-player-vol-btn"
|
|
onClick={goFullscreen}
|
|
aria-label="Fullscreen"
|
|
>
|
|
<IconFullscreen />
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (kind === "video") {
|
|
return (
|
|
<div
|
|
className={`video-player${
|
|
showingControls ? " video-player--controls-visible" : ""
|
|
}`}
|
|
onMouseMove={showControlsTemporarily}
|
|
onMouseLeave={() => playing && setControlsVisible(false)}
|
|
>
|
|
<video
|
|
ref={mediaRef as React.RefObject<HTMLVideoElement>}
|
|
src={src}
|
|
poster={poster}
|
|
preload="metadata"
|
|
className="video-player-video"
|
|
onClick={toggle}
|
|
>
|
|
{mime && <source src={src} type={mime} />}
|
|
</video>
|
|
<div className="video-player-controls audio-player">
|
|
{controls}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="audio-player">
|
|
<audio
|
|
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
|
src={src}
|
|
preload="metadata"
|
|
>
|
|
{mime && <source src={src} type={mime} />}
|
|
</audio>
|
|
{controls}
|
|
</div>
|
|
);
|
|
}
|