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

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:
khannurien
2026-08-22 12:14:28 +00:00
parent eb323a8ba8
commit 2606ef4ccc
30 changed files with 1531 additions and 225 deletions

View File

@@ -14,6 +14,15 @@ GERBEUR_SITE_NAME=gerbeur
# (server startup), so changing it only needs a restart — no rebuild.
GERBEUR_SITE_EMOJI=🚚
# How Bandcamp links are played.
# embed (default) the bandcamp.com iframe — cannot autoplay
# native resolve the page's mp3 streams and play them in gerbeur's
# own player: autoplay, seeking, and albums as a queue.
# Note this bypasses Bandcamp's player, so their play counts
# and 3-play preview limit do not apply.
# Applied at runtime; changing it needs a restart of both the API and Vite.
GERBEUR_BANDCAMP_PLAYER=embed
# Port the API server listens on (the container's internal port).
GERBEUR_PORT=8000

View File

@@ -98,6 +98,17 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
// only needs a restart — no rebuild.
export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
// Which Bandcamp playback path the frontend uses.
// "embed" — the bandcamp.com iframe (cannot autoplay)
// "native" — resolve the page's mp3 streams and play them in the app's own
// player, which autoplays and supports albums as a queue
// Anything unrecognised falls back to "embed", so a typo degrades safely.
export type BandcampPlayer = "embed" | "native";
export const BANDCAMP_PLAYER: BandcampPlayer =
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
? "native"
: "embed";
// Background color for generated icons and the manifest. Mirrors the
// hard-coded theme-color in index.html.
export const THEME_COLOR = "#111827";

View File

@@ -1,5 +1,5 @@
import { Context, Next, send } from "@oak/oak";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "./site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -13,7 +13,8 @@ async function serveIndexHtml(
const html = raw
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION);
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
context.response.type = "text/html";
context.response.body = html;
}

View File

@@ -9,6 +9,7 @@ import usersRouter from "./routes/users.ts";
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 playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts";
@@ -71,6 +72,10 @@ app.use(
previewRouter.routes(),
previewRouter.allowedMethods(),
);
app.use(
bandcampRouter.routes(),
bandcampRouter.allowedMethods(),
);
app.use(
playlistsRouter.routes(),
playlistsRouter.allowedMethods(),

View File

@@ -2,7 +2,7 @@ import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-service.ts";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "../lib/site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -69,7 +69,8 @@ async function loadIndexHtml(): Promise<string | null> {
cachedHtml = (await Deno.readTextFile(path))
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION);
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
return cachedHtml;
} catch {
continue;

26
api/routes/bandcamp.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Router } from "@oak/oak";
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
const bandcampRouter = new Router();
/**
* Resolve a Bandcamp page to its streamable tracks.
*
* Public, like /api/preview: it only reads a public page and returns URLs the
* same page already hands any visitor. Errors are thrown as APIException and
* rendered by errorMiddleware; the client treats any failure as "fall back to
* the iframe embed".
*/
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
const force = ctx.request.url.searchParams.get("force") === "1";
const album = await resolveBandcamp(url, { force });
// Private: the signed URLs in the body are per-fetch, so they must not be
// held in a shared cache. Well inside the 24h signature life either way.
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
ctx.response.body = { success: true, data: album };
});
export default bandcampRouter;

View File

@@ -0,0 +1,133 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { fetchWithTimeout, isValidHttpUrl } from "./rich-content-service.ts";
import {
BANDCAMP_HOST_RE,
parseTralbum,
type Tralbum,
} from "./bandcamp-tralbum.ts";
/**
* Resolves a Bandcamp page to its streamable tracks, at play time.
*
* Bandcamp's signed stream URLs expire 24h after the page fetch, so they can
* never be baked into `rich_content` at dump-creation time the way `embedUrl`
* is. This service is the play-time counterpart: the frontend asks for a page,
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
*/
/** Comfortably inside the 24h signature life, so a cache hit is never a URL
* that is about to expire mid-listen. */
const TTL_MS = 12 * 60 * 60 * 1000;
const MAX_ENTRIES = 200;
const cache = new Map<string, Tralbum>();
/** Cache key: a page is the same page regardless of query, fragment or case. */
function cacheKey(url: string): string {
try {
const u = new URL(url);
return `${u.protocol}//${u.hostname.toLowerCase()}${
u.pathname.replace(/\/+$/, "")
}`;
} catch {
return url;
}
}
function readCache(key: string): Tralbum | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.resolvedAt > TTL_MS) {
cache.delete(key);
return null;
}
return hit;
}
function writeCache(key: string, value: Tralbum): void {
// Map preserves insertion order, so the first key is the oldest write.
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, value);
}
/**
* Fetch and parse a Bandcamp page.
*
* `force` bypasses the cache — used by the client's re-resolve path, so a
* cached entry whose URLs have started 403ing can't keep being handed back.
*/
export async function resolveBandcamp(
pageUrl: string,
{ force = false }: { force?: boolean } = {},
): Promise<Tralbum> {
if (!isValidHttpUrl(pageUrl)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid URL",
);
}
let hostname: string;
try {
hostname = new URL(pageUrl).hostname;
} catch {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid URL");
}
if (!BANDCAMP_HOST_RE.test(hostname)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Not a Bandcamp URL",
);
}
const key = cacheKey(pageUrl);
if (!force) {
const hit = readCache(key);
if (hit) return hit;
}
let res: Response;
try {
res = await fetchWithTimeout(pageUrl, 8000);
} catch {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Could not reach Bandcamp",
);
}
if (!res.ok) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
`Bandcamp returned ${res.status}`,
);
}
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Bandcamp did not return a page",
);
}
const parsed = parseTralbum(await res.text(), pageUrl);
if (!parsed) {
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
// with nothing streamable — all cases where the client falls back to the
// iframe embed rather than showing an error.
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"No streamable tracks on this page",
);
}
writeCache(key, parsed);
return parsed;
}

View File

@@ -0,0 +1,149 @@
/**
* Parser for Bandcamp's `data-tralbum` attribute.
*
* Every Bandcamp track and album page carries the whole release — titles,
* durations and signed mp3 stream URLs — as one HTML-escaped JSON blob on a
* `data-tralbum` attribute. One fetch of an album page therefore yields every
* track's stream URL; there is no per-track request.
*
* The stream URLs are signed and expire 24h after the page was fetched, which
* is why nothing here is ever persisted to `rich_content` — see
* `api/routes/bandcamp.ts` for the resolve-at-play-time endpoint.
*
* The shape is undocumented, so every field is treated as optional and the
* whole parse is best-effort: a `null` return means "not a Bandcamp page",
* which callers turn into a fallback to the iframe embed.
*/
/** Hostnames Bandcamp serves its own sites from. Anchored at the end so that
* `bandcamp.com.example.org` cannot pass. Artists on custom domains do not
* match this and keep using the embed. */
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
export interface TralbumTrack {
/** Position in the parsed array — the stable handle used to re-resolve a
* track after its signed URL expires. */
index: number;
trackNum?: number;
title: string;
/** Seconds, fractional. */
duration?: number;
streamUrl: string | null;
/** False for preorder, unreleased and still-encoding tracks. They stay in
* the list so numbering matches the release, but never enter the queue. */
streamable: boolean;
/** Bandcamp caps free plays per track; a capped track may serve a clip. */
capped: boolean;
}
export interface Tralbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: TralbumTrack[];
/** Epoch ms of the fetch that produced these signed URLs. */
resolvedAt: number;
}
/**
* Decode HTML entities in a single pass.
*
* The single pass is the point: `decodeHtmlEntities` in rich-content-service.ts
* replaces `&amp;` before `&quot;`, so an escaped-ampersand-then-quot sequence
* (`&amp;quot;`, i.e. the literal text `&quot;` inside a title) decodes twice
* and yields a bare `"` — which lands inside the JSON string and breaks the
* parse. Matching every entity form in one regex means each is replaced once.
*/
function decodeEntitiesOnce(input: string): string {
const named: Record<string, string> = {
quot: '"',
apos: "'",
amp: "&",
lt: "<",
gt: ">",
nbsp: " ",
};
return input.replace(
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/g,
(match, dec: string, hex: string, name: string) => {
if (dec) return String.fromCodePoint(Number(dec));
if (hex) return String.fromCodePoint(parseInt(hex, 16));
return named[name.toLowerCase()] ?? match;
},
);
}
/** Resolve Bandcamp's protocol-relative and root-relative art URLs. */
function artworkUrl(artId: unknown): string | undefined {
if (typeof artId !== "number" && typeof artId !== "string") return undefined;
const id = String(artId).trim();
if (!/^\d+$/.test(id)) return undefined;
// _16 is the large square art Bandcamp's own player uses.
return `https://f4.bcbits.com/img/a${id}_16.jpg`;
}
/**
* Pull the `data-tralbum` blob out of a Bandcamp page and normalise it.
* Returns null when the page has no blob, no audio, or nothing parseable.
*/
export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/);
if (!match) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(decodeEntitiesOnce(match[2]));
} catch {
return null;
}
if (!raw || typeof raw !== "object") return null;
if (raw.hasAudio === false) return null;
const rawTracks = Array.isArray(raw.trackinfo) ? raw.trackinfo : [];
const tracks: TralbumTrack[] = rawTracks.map((entry, index) => {
const t = (entry ?? {}) as Record<string, unknown>;
const file = (t.file ?? {}) as Record<string, unknown>;
const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null;
// Only ever hand out bcbits URLs. Without this the endpoint would relay
// whatever host a hostile page put in the blob straight to the browser.
const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null;
return {
index,
trackNum: typeof t.track_num === "number" ? t.track_num : undefined,
title: typeof t.title === "string" && t.title.trim()
? t.title
: `Track ${index + 1}`,
duration: typeof t.duration === "number" && t.duration > 0
? t.duration
: undefined,
streamUrl,
streamable: streamUrl !== null && t.streaming === 1 &&
t.unreleased_track !== true && !t.encoding_pending,
capped: t.is_capped === true,
};
});
if (!tracks.some((t) => t.streamable)) return null;
const current = (raw.current ?? {}) as Record<string, unknown>;
return {
sourceUrl,
itemType: raw.item_type === "album" ? "album" : "track",
title: typeof current.title === "string" ? current.title : undefined,
artist: typeof raw.artist === "string" ? raw.artist : undefined,
artworkUrl: artworkUrl(raw.art_id),
tracks,
resolvedAt: Date.now(),
};
}
function isBcbitsUrl(url: string): boolean {
try {
const { protocol, hostname } = new URL(url);
return protocol === "https:" && /(?:^|\.)bcbits\.com$/.test(hostname);
} catch {
return false;
}
}

View File

@@ -15,6 +15,7 @@
<meta name="theme-color" content="#111827" />
<meta name="site-name" content="__SITE_NAME__" />
<meta name="site-emoji" content="__SITE_EMOJI__" />
<meta name="bandcamp-player" content="__BANDCAMP_PLAYER__" />
<link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
<title>__SITE_NAME__</title>

View File

@@ -832,6 +832,37 @@
transition: width 0.1s linear;
}
/* Audio whose peaks can't be decoded (cross-origin streams). Boxed to the
waveform's height so the player doesn't resize between item kinds, with the
bar itself centred inside. */
.audio-player-track--stream {
height: 48px;
background: none;
display: flex;
align-items: center;
}
.audio-player-track--stream::before {
content: "";
position: absolute;
inset: 50% 0 auto 0;
transform: translateY(-50%);
height: 6px;
border-radius: 3px;
background: color-mix(
in srgb,
var(--color-accent) 12%,
var(--color-border) 88%
);
}
.audio-player-track--stream .audio-player-fill {
top: 50%;
bottom: auto;
transform: translateY(-50%);
height: 6px;
border-radius: 3px;
z-index: 1;
}
.audio-player-track--volume {
flex: 1 1 100px;
max-width: 120px;
@@ -1063,6 +1094,105 @@ a.global-player-title:hover {
.global-player.global-player--bandcamp {
max-width: 600px;
}
/* ── Global player: queue ── */
/* The title/artist pair takes the space the bare title used to claim. */
.global-player-heading {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.global-player-heading .global-player-title {
flex: none;
}
.global-player-subtitle {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-artwork {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: 4px;
}
.global-player-transport {
display: flex;
align-items: center;
gap: 0.25rem;
flex: none;
}
.global-player-position {
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.global-player-transport .btn--ghost:disabled {
opacity: 0.35;
cursor: default;
}
.global-player-status {
margin: 0;
padding: 0 1rem 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.global-player-tracks {
list-style: none;
margin: 0;
padding: 0 0.5rem 0.5rem;
max-height: 40vh;
overflow-y: auto;
}
.global-player-track > button {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.35rem 0.5rem;
background: none;
border: none;
border-left: 3px solid transparent;
color: inherit;
font: inherit;
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.global-player-track > button:hover {
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
}
/* 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);
font-weight: 600;
}
.global-player-track-num {
flex: none;
min-width: 1.5em;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.global-player-track-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-track-dur {
flex: none;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.feed-loading-more {
text-align: center;
padding: 1rem;

View File

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

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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 = (

View File

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

19
src/config/playerMode.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Which Bandcamp playback path this deployment uses.
*
* The server injects `GERBEUR_BANDCAMP_PLAYER` into a `<meta>` tag at request
* time (api/lib/static.ts and api/middleware/og.ts; mirrored by the Vite dev
* plugin), the same mechanism as site-name/site-emoji. Read once at startup.
*
* Defaults to "embed" whenever the value is missing, unsubstituted or
* unrecognised, so a misconfigured deployment keeps the iframe it has today.
*/
export type BandcampPlayer = "embed" | "native";
function readBandcampPlayer(): BandcampPlayer {
const meta = document.querySelector('meta[name="bandcamp-player"]')
?.getAttribute("content")?.trim();
return meta === "native" ? "native" : "embed";
}
export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer();

View File

@@ -1,8 +1,68 @@
import { createContext } from "react";
/** Fields every playable item shares, whatever its source. */
interface PlayerItemBase {
title?: string;
dumpHref?: string;
/** Square image for the player header — provider thumbnail, album art, or a
* video still. Raw URL: Thumbnail decides whether to proxy it. */
artworkUrl?: string;
/** Secondary line under the title: the artist, the site name, the filename —
* whatever identifies the source at a glance. */
subtitle?: string;
}
export type PlayerItem =
| { kind: "embed"; embedUrl: string; title?: string; type: string; dumpHref?: string }
| { kind: "file"; fileUrl: string; mimeType: string; title?: string; dumpHref?: string };
| (PlayerItemBase & {
kind: "embed";
embedUrl: string;
type: string;
})
| (PlayerItemBase & {
kind: "file";
fileUrl: string;
mimeType: string;
})
/**
* An expiring, remotely-hosted stream (today: a Bandcamp mp3).
*
* Unlike a "file" item the URL is signed and dies after ~24h, so the item
* carries everything needed to fetch a fresh one: `resolveUrl` is the page it
* came from and `resolveIndex` its position in that page's tracklist. That
* pair is also the item's stable identity, since `streamUrl` changes on every
* re-resolve.
*/
| (PlayerItemBase & {
kind: "stream";
streamUrl: string;
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
type: string;
/** Known before playback from the tracklist, so rows can show a length. */
duration?: number;
trackNum?: number;
resolveUrl: string;
resolveIndex: number;
/** Epoch ms of the resolution that produced `streamUrl`. */
resolvedAt: number;
/** Iframe embed to fall back to if native playback stops working. */
embedUrl?: string;
});
/**
* Stable identity for an item, used for active-state comparisons and to decide
* when the player is showing something new. Deliberately independent of
* `streamUrl` so a re-resolved track is still recognised as the same track.
*/
export function playerItemKey(item: PlayerItem): string {
switch (item.kind) {
case "embed":
return `embed:${item.embedUrl}`;
case "file":
return `file:${item.fileUrl}`;
case "stream":
return `stream:${item.resolveUrl}#${item.resolveIndex}`;
}
}
export interface PlayerContextValue {
// Playback state — readable by any consumer
@@ -11,14 +71,28 @@ export interface PlayerContextValue {
currentTime: number;
duration: number;
// Queue. Every playback is a queue of at least one item, so single tracks and
// albums share one code path. `current` is always `queue[queueIndex]`.
queue: PlayerItem[];
queueIndex: number;
hasNext: boolean;
hasPrevious: boolean;
/** True while a stream is being (re-)resolved, for spinner affordances. */
resolving: boolean;
// Initial seek offset for the active item (non-zero only for an item restored
// from a previous session) and whether the active MediaPlayer should autoplay.
// A restored item appears paused; a fresh play() autoplays on the user gesture.
// from a previous session, or resumed after a re-resolve) and whether the
// active MediaPlayer should autoplay. A restored item appears paused; a fresh
// play() autoplays on the user gesture.
startTime: number;
autoplay: boolean;
// Control — callable by any consumer
play(item: PlayerItem): void;
playQueue(items: PlayerItem[], startIndex?: number): void;
playAt(index: number): void;
next(): void;
previous(): void;
stop(): void;
seekTo(time: number): void;
togglePlay(): void;
@@ -31,6 +105,10 @@ export interface PlayerContextValue {
// Internal: GlobalPlayer calls these to push state back into the provider
onPlayStateChange(playing: boolean): void;
onTimeUpdate(time: number, duration: number): void;
/** Current item finished — advances the queue, or stops at the end. */
onEnded(): void;
/** The media element failed. For streams, triggers a re-resolve. */
onError(): void;
}
export const PlayerContext = createContext<PlayerContextValue>({
@@ -38,9 +116,18 @@ export const PlayerContext = createContext<PlayerContextValue>({
playing: false,
currentTime: 0,
duration: 0,
queue: [],
queueIndex: 0,
hasNext: false,
hasPrevious: false,
resolving: false,
startTime: 0,
autoplay: false,
play: () => {},
playQueue: () => {},
playAt: () => {},
next: () => {},
previous: () => {},
stop: () => {},
seekTo: () => {},
togglePlay: () => {},
@@ -48,4 +135,6 @@ export const PlayerContext = createContext<PlayerContextValue>({
toggleRef: { current: null },
onPlayStateChange: () => {},
onTimeUpdate: () => {},
onEnded: () => {},
onError: () => {},
});

View File

@@ -1,39 +1,83 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
import {
PlayerContext,
type PlayerItem,
playerItemKey,
} from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
const STORAGE_KEY = "player";
// Snapshot persisted across reloads: which item was playing and how far in.
/**
* Snapshot persisted across reloads: the whole queue, which entry was playing
* and how far in.
*
* v1 stored a single `{ item, time }`. `readSession` still accepts that shape
* so a session written by the previous build survives the upgrade.
*/
interface StoredSession {
item: PlayerItem;
v: 2;
queue: PlayerItem[];
index: number;
time: number;
}
function readSession(): StoredSession | null {
function isPlayerItem(value: unknown): value is PlayerItem {
if (!value || typeof value !== "object") return false;
const kind = (value as { kind?: unknown }).kind;
return kind === "embed" || kind === "file" || kind === "stream";
}
function readSession(): { queue: PlayerItem[]; index: number; time: number } | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredSession>;
if (!parsed.item || (parsed.item.kind !== "embed" && parsed.item.kind !== "file")) {
return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
const time = Number(parsed.time) || 0;
const rawQueue = parsed.queue;
if (Array.isArray(rawQueue)) {
const queue = rawQueue.filter(isPlayerItem);
if (queue.length === 0) return null;
const index = Math.min(
Math.max(Number(parsed.index) || 0, 0),
queue.length - 1,
);
return { queue, index, time };
}
return { item: parsed.item as PlayerItem, time: Number(parsed.time) || 0 };
// Legacy v1 single-item session.
if (isPlayerItem(parsed.item)) {
return { queue: [parsed.item], index: 0, time };
}
return null;
} catch {
return null;
}
}
export function PlayerProvider({ children }: { children: React.ReactNode }) {
const restored = useRef(readSession()).current;
/** Don't retry the same failing track more than once per window. */
const RERESOLVE_COOLDOWN_MS = 30_000;
const [current, setCurrent] = useState<PlayerItem | null>(restored?.item ?? null);
export function PlayerProvider({ children }: { children: React.ReactNode }) {
// Lazy initialiser: readSession runs exactly once, on first render.
const [restored] = useState(readSession);
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
const [duration, setDuration] = useState(0);
// Resume offset for the active item — only the restored item carries one.
// Resume offset for the active item — carried by a restored item, and by an
// item whose stream URL was re-resolved mid-listen.
const [startTime, setStartTime] = useState(restored?.time ?? 0);
// Restored items start paused (no user gesture); fresh play() autoplays.
const [autoplay, setAutoplay] = useState(false);
const [resolving, setResolving] = useState(false);
const current = queue[queueIndex] ?? null;
const hasNext = queueIndex + 1 < queue.length;
const hasPrevious = queueIndex > 0;
// GlobalPlayer registers the active MediaPlayer's imperative handles here
const seekRef = useRef<((t: number) => void) | null>(null);
@@ -43,9 +87,34 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
const suppressUpdates = useRef(false);
const play = useCallback((item: PlayerItem) => {
// Latest state, for callbacks that must not close over a stale render.
// Written in an effect (after render) rather than during it, the same
// convention MediaPlayer uses for its callback refs.
const stateRef = useRef({ current, currentTime, playing });
useEffect(() => {
stateRef.current = { current, currentTime, playing };
});
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
if (items.length === 0) return;
suppressUpdates.current = true;
setCurrent(item);
setQueue(items);
setQueueIndex(Math.min(Math.max(startIndex, 0), items.length - 1));
setCurrentTime(0);
setDuration(0);
setStartTime(0);
setAutoplay(true);
setPlaying(false);
}, []);
const play = useCallback((item: PlayerItem) => {
playQueue([item], 0);
}, [playQueue]);
/** Move within the existing queue, as a fresh (autoplaying) item. */
const advanceTo = useCallback((index: number) => {
suppressUpdates.current = true;
setQueueIndex(index);
setCurrentTime(0);
setDuration(0);
setStartTime(0);
@@ -54,7 +123,8 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}, []);
const stop = useCallback(() => {
setCurrent(null);
setQueue([]);
setQueueIndex(0);
setPlaying(false);
setCurrentTime(0);
setDuration(0);
@@ -70,6 +140,31 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef.current?.();
}, []);
const playAt = useCallback((index: number) => {
if (index < 0 || index >= queue.length) return;
// Re-selecting the playing row restarts it. Going through advanceTo would
// leave the index unchanged — so the element would never remount and never
// seek — while still flipping `playing` false against audio that's running.
if (index === queueIndex) {
seekTo(0);
return;
}
advanceTo(index);
}, [queue.length, queueIndex, advanceTo, seekTo]);
const next = useCallback(() => {
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
}, [queueIndex, queue.length, advanceTo]);
/** Restart the track first, like every other music player, then step back. */
const previous = useCallback(() => {
if (stateRef.current.currentTime > 3 || queueIndex === 0) {
seekTo(0);
return;
}
advanceTo(queueIndex - 1);
}, [queueIndex, advanceTo, seekTo]);
const onPlayStateChange = useCallback((p: boolean) => {
if (p) suppressUpdates.current = false;
setPlaying(p);
@@ -81,25 +176,127 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
setDuration(d);
}, []);
// ── Persistence ────────────────────────────────────────────────────────────────
// Write the item to storage as soon as it changes (survives crashes), then refresh
// the playback position on pagehide — which also fires on a normal reload — so the
// resume offset is accurate without thrashing localStorage on every timeupdate.
const onEnded = useCallback(() => {
if (stateRef.current.current && queueIndex + 1 < queue.length) {
advanceTo(queueIndex + 1);
return;
}
setPlaying(false);
}, [queueIndex, queue.length, advanceTo]);
// ── Expiring streams ───────────────────────────────────────────────────────────
// Bandcamp signs its mp3 URLs for ~24h. A session restored the next day, or a
// long listen, will 403. Re-resolve the source page, swap in fresh URLs and
// resume at the same offset; if that fails too, fall back to the iframe embed.
const lastReresolve = useRef<{ key: string; at: number } | null>(null);
const fallBackToEmbed = useCallback((
item: Extract<PlayerItem, { kind: "stream" }>,
) => {
if (!item.embedUrl) {
stop();
return;
}
playQueue([{
kind: "embed",
embedUrl: item.embedUrl,
type: item.type,
title: item.title,
dumpHref: item.dumpHref,
}], 0);
}, [playQueue, stop]);
const reresolve = useCallback(async (
item: Extract<PlayerItem, { kind: "stream" }>,
{ resumeAt, shouldAutoplay }: { resumeAt: number; shouldAutoplay: boolean },
) => {
const key = playerItemKey(item);
const now = Date.now();
const last = lastReresolve.current;
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
// Already tried recently — the track is genuinely dead, not just stale.
fallBackToEmbed(item);
return;
}
lastReresolve.current = { key, at: now };
setResolving(true);
try {
const items = await resolveStreamQueue(item);
const index = Math.max(
items.findIndex((i) =>
i.kind === "stream" && i.resolveIndex === item.resolveIndex
),
0,
);
suppressUpdates.current = true;
setQueue(items);
setQueueIndex(index);
setCurrentTime(resumeAt);
setStartTime(resumeAt);
setDuration(0);
setAutoplay(shouldAutoplay);
setPlaying(false);
} catch {
fallBackToEmbed(item);
} finally {
setResolving(false);
}
}, [fallBackToEmbed]);
const onError = useCallback(() => {
const item = stateRef.current.current;
if (item?.kind !== "stream") return;
void reresolve(item, {
resumeAt: stateRef.current.currentTime,
shouldAutoplay: true,
});
}, [reresolve]);
// A session restored from a previous day holds URLs that are already dead.
// Refresh them up front so the first press of play doesn't visibly stall.
useEffect(() => {
if (current) {
if (!restored) return;
const item = restored.queue[restored.index];
if (item?.kind === "stream" && isStreamStale(item)) {
// Genuinely an external-system sync: the stored URLs are dead and only
// the network can replace them. The setState this triggers is the point.
// eslint-disable-next-line react-hooks/set-state-in-effect
void reresolve(item, {
resumeAt: restored.time,
shouldAutoplay: false,
});
}
// Runs once, against the session captured at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Persistence ────────────────────────────────────────────────────────────────
// Write the queue to storage as soon as it changes (survives crashes), then
// refresh the playback position on pagehide — which also fires on a normal
// reload — so the resume offset is accurate without thrashing localStorage on
// every timeupdate.
useEffect(() => {
if (queue.length > 0) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ item: current, time: currentTime } satisfies StoredSession),
JSON.stringify(
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession,
),
);
} else {
localStorage.removeItem(STORAGE_KEY);
}
// currentTime intentionally omitted from deps — see pagehide writer below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current]);
}, [queue, queueIndex]);
const sessionRef = useRef<StoredSession | null>(null);
sessionRef.current = current ? { item: current, time: currentTime } : null;
useEffect(() => {
sessionRef.current = queue.length > 0
? { v: 2, queue, index: queueIndex, time: currentTime }
: null;
});
useEffect(() => {
const persist = () => {
@@ -118,9 +315,18 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
playing,
currentTime,
duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime,
autoplay,
play,
playQueue,
playAt,
next,
previous,
stop,
seekTo,
togglePlay,
@@ -128,19 +334,32 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
}), [
current,
playing,
currentTime,
duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime,
autoplay,
play,
playQueue,
playAt,
next,
previous,
stop,
seekTo,
togglePlay,
onPlayStateChange,
onTimeUpdate,
onEnded,
onError,
]);
return (

View File

@@ -0,0 +1,86 @@
import { useCallback, useContext, useState } from "react";
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import { BANDCAMP_PLAYER } from "../config/playerMode.ts";
import { resolveBandcampQueue } from "../utils/bandcamp.ts";
export interface PlayableRichContent {
type: string;
url: string;
title?: string;
embedUrl?: string;
/** Header artwork. Callers with a dump-level override should pass that. */
thumbnailUrl?: string;
/** Header subtitle — "YouTube", "SoundCloud", the site's own name. */
siteName?: string;
}
/**
* Start playback for a rich-content item.
*
* Everything except Bandcamp goes straight to the iframe embed, exactly as
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
* mode the page is resolved to its mp3 streams and played as a queue, which is
* what makes it autoplay and lets albums play through. Any failure — offline,
* preorder-only release, Bandcamp changing its markup — silently falls back to
* the embed, so this can never leave a card unplayable.
*/
export function usePlayRichContent() {
const { play, playQueue } = useContext(PlayerContext);
const [pending, setPending] = useState(false);
const playEmbed = useCallback(
(rc: PlayableRichContent, dumpHref?: string) => {
if (!rc.embedUrl) return;
play({
kind: "embed",
embedUrl: rc.embedUrl,
title: rc.title,
type: rc.type,
dumpHref,
artworkUrl: rc.thumbnailUrl,
subtitle: rc.siteName,
});
},
[play],
);
const playRichContent = useCallback(
async (rc: PlayableRichContent, dumpHref?: string) => {
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
if (!native) {
playEmbed(rc, dumpHref);
return;
}
setPending(true);
try {
const items: PlayerItem[] = await resolveBandcampQueue(rc.url, {
dumpHref,
embedUrl: rc.embedUrl,
// Bandcamp's own album art wins; this is the fallback if the page
// doesn't carry one.
fallbackArtworkUrl: rc.thumbnailUrl,
});
playQueue(items, 0);
} catch (err) {
console.warn("bandcamp: native playback unavailable, using embed", err);
playEmbed(rc, dumpHref);
} finally {
setPending(false);
}
},
[playEmbed, playQueue],
);
return { playRichContent, pending };
}
/**
* Whether a rich-content item can be played at all. In native mode a Bandcamp
* page is playable even without a stored embedUrl, since the streams are
* resolved from the page itself.
*/
export function canPlayRichContent(rc: PlayableRichContent): boolean {
if (rc.embedUrl) return true;
return rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
}

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:111
#: src/components/JournalCard.tsx:112
#: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:107
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -58,9 +58,9 @@ msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:291
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:181
#: src/pages/Dump.tsx:292
#: src/pages/Dump.tsx:522
#: src/pages/DumpEdit.tsx:187
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -78,7 +78,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:362
#: src/pages/Dump.tsx:363
msgid "+ Playlist"
msgstr "+ Playlist"
@@ -202,8 +202,8 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:403
#: src/pages/DumpEdit.tsx:460
#: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:469
#: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1744
@@ -266,6 +266,14 @@ msgstr "Checking invite…"
msgid "Close"
msgstr "Close"
#: src/components/GlobalPlayer.tsx:184
msgid "Close player"
msgstr "Close player"
#: src/components/GlobalPlayer.tsx:176
msgid "Collapse player"
msgstr "Collapse player"
#: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme"
msgstr "Color scheme"
@@ -291,7 +299,7 @@ msgstr "Could not change password"
msgid "Could not load."
msgstr "Could not load."
#: src/pages/DumpEdit.tsx:361
#: src/pages/DumpEdit.tsx:370
msgid "Could not save"
msgstr "Could not save"
@@ -346,8 +354,8 @@ msgstr "Delete category"
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:255
#: src/pages/DumpEdit.tsx:456
#: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:465
msgid "Delete dump"
msgstr "Delete dump"
@@ -361,7 +369,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?"
msgstr "Delete this comment?"
#: src/pages/DumpEdit.tsx:254
#: src/pages/DumpEdit.tsx:263
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
@@ -391,7 +399,7 @@ msgstr "Done"
msgid "Drop a file here"
msgstr "Drop a file here"
#: src/pages/DumpEdit.tsx:428
#: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
@@ -426,7 +434,7 @@ msgstr "Earlier"
#: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:517
#: src/pages/Dump.tsx:518
#: src/pages/PlaylistDetail.tsx:625
msgid "Edit"
msgstr "Edit"
@@ -445,7 +453,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:456
#: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}"
msgstr "edited {0}"
@@ -455,12 +463,12 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:454
#: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}"
msgstr "Edited {0}"
#: src/pages/DumpEdit.tsx:206
#: src/pages/DumpEdit.tsx:212
msgid "Editing"
msgstr "Editing"
@@ -477,6 +485,10 @@ msgstr "Email address"
msgid "Enter a query to search."
msgstr "Enter a query to search."
#: src/components/GlobalPlayer.tsx:176
msgid "Expand player"
msgstr "Expand player"
#: src/components/CategoryManager.tsx:230
msgid "Failed to create category"
msgstr "Failed to create category"
@@ -632,7 +644,7 @@ msgstr "Hot"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
#: src/pages/Dump.tsx:551
#: src/pages/Dump.tsx:552
msgid "In collections"
msgstr "In collections"
@@ -683,8 +695,8 @@ msgstr "Load more"
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:267
#: src/pages/DumpEdit.tsx:157
#: src/pages/Dump.tsx:268
#: src/pages/DumpEdit.tsx:163
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -801,6 +813,10 @@ msgstr "New password"
msgid "New playlist"
msgstr "New playlist"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Next track"
#: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet."
@@ -942,11 +958,15 @@ msgstr "Post reply"
msgid "Posting…"
msgstr "Posting…"
#: src/components/DumpCard.tsx:120
#: src/components/JournalCard.tsx:121
#: src/components/GlobalPlayer.tsx:154
msgid "Previous track"
msgstr "Previous track"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:116
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:462
#: src/pages/Dump.tsx:463
#: src/pages/PlaylistDetail.tsx:644
msgid "private"
msgstr "private"
@@ -966,11 +986,15 @@ msgstr "public"
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:235
#: src/pages/DumpEdit.tsx:241
msgid "Refresh metadata"
msgstr "Refresh metadata"
#: src/pages/DumpEdit.tsx:234
#: src/components/GlobalPlayer.tsx:240
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
#: src/pages/DumpEdit.tsx:240
msgid "Refreshing…"
msgstr "Refreshing…"
@@ -988,7 +1012,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:529
#: src/pages/Dump.tsx:530
msgid "Related"
msgstr "Related"
@@ -1008,7 +1032,7 @@ msgstr "Remove like"
msgid "Remove vote"
msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:420
#: src/pages/DumpEdit.tsx:429
msgid "Replace file"
msgstr "Replace file"
@@ -1034,13 +1058,13 @@ msgstr "Reset failed"
msgid "Reset password"
msgstr "Reset password"
#: src/pages/DumpEdit.tsx:381
#: src/pages/DumpEdit.tsx:399
#: src/pages/DumpEdit.tsx:390
#: src/pages/DumpEdit.tsx:408
msgid "Reset to default"
msgstr "Reset to default"
#: src/pages/Dump.tsx:284
#: src/pages/DumpEdit.tsx:174
#: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
msgstr "Retry"
@@ -1050,8 +1074,8 @@ msgstr "Role"
#: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:395
#: src/pages/DumpEdit.tsx:463
#: src/pages/Dump.tsx:396
#: src/pages/DumpEdit.tsx:472
#: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1736
@@ -1060,7 +1084,7 @@ msgstr "Save"
#: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:394
#: src/pages/Dump.tsx:395
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663
@@ -1152,13 +1176,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:365
#: src/pages/DumpEdit.tsx:374
msgid "Thumbnail"
msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:389
#: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:389
#: src/pages/DumpEdit.tsx:398
msgid "Title"
msgstr "Title"
@@ -1210,7 +1234,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:344
#: src/pages/DumpEdit.tsx:412
#: src/pages/DumpEdit.tsx:421
msgid "URL"
msgstr "URL"
@@ -1256,7 +1280,7 @@ msgid "View dump →"
msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:434
#: src/pages/DumpEdit.tsx:437
#: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
@@ -1266,7 +1290,7 @@ msgid "Who am I?"
msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:433
#: src/pages/DumpEdit.tsx:436
#: src/pages/DumpEdit.tsx:445
msgid "Why?"
msgstr "Why?"

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:111
#: src/components/JournalCard.tsx:112
#: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:107
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -58,9 +58,9 @@ msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:291
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:181
#: src/pages/Dump.tsx:292
#: src/pages/Dump.tsx:522
#: src/pages/DumpEdit.tsx:187
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -78,7 +78,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:362
#: src/pages/Dump.tsx:363
msgid "+ Playlist"
msgstr "+ Collection"
@@ -202,8 +202,8 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:403
#: src/pages/DumpEdit.tsx:460
#: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:469
#: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1744
@@ -266,6 +266,14 @@ msgstr "Vérification de l'invitation…"
msgid "Close"
msgstr "Fermer"
#: src/components/GlobalPlayer.tsx:184
msgid "Close player"
msgstr "Fermer le lecteur"
#: src/components/GlobalPlayer.tsx:176
msgid "Collapse player"
msgstr "Réduire le lecteur"
#: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme"
msgstr "Thème de couleur"
@@ -291,7 +299,7 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not load."
msgstr "Impossible de charger."
#: src/pages/DumpEdit.tsx:361
#: src/pages/DumpEdit.tsx:370
msgid "Could not save"
msgstr "Sauvegarde impossible"
@@ -346,8 +354,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:255
#: src/pages/DumpEdit.tsx:456
#: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:465
msgid "Delete dump"
msgstr "Supprimer la reco"
@@ -361,7 +369,7 @@ msgstr "Supprimer la collection"
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
#: src/pages/DumpEdit.tsx:254
#: src/pages/DumpEdit.tsx:263
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
@@ -391,7 +399,7 @@ msgstr "Terminé"
msgid "Drop a file here"
msgstr "Déposez un fichier ici"
#: src/pages/DumpEdit.tsx:428
#: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
@@ -426,7 +434,7 @@ msgstr "Plus tôt"
#: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:517
#: src/pages/Dump.tsx:518
#: src/pages/PlaylistDetail.tsx:625
msgid "Edit"
msgstr "Modifier"
@@ -445,7 +453,7 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:456
#: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}"
msgstr "modifié {0}"
@@ -455,12 +463,12 @@ msgstr "modifié {0}"
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:454
#: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}"
msgstr "Modifié le {0}"
#: src/pages/DumpEdit.tsx:206
#: src/pages/DumpEdit.tsx:212
msgid "Editing"
msgstr "Modification"
@@ -477,6 +485,10 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
#: src/components/GlobalPlayer.tsx:176
msgid "Expand player"
msgstr "Agrandir le lecteur"
#: src/components/CategoryManager.tsx:230
msgid "Failed to create category"
msgstr "Échec de la création de la catégorie"
@@ -632,7 +644,7 @@ msgstr "Tendances"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#: src/pages/Dump.tsx:551
#: src/pages/Dump.tsx:552
msgid "In collections"
msgstr "Dans les collections"
@@ -683,8 +695,8 @@ msgstr "Charger plus"
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:267
#: src/pages/DumpEdit.tsx:157
#: src/pages/Dump.tsx:268
#: src/pages/DumpEdit.tsx:163
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -801,6 +813,10 @@ msgstr "Nouveau mot de passe"
msgid "New playlist"
msgstr "Nouvelle collection"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Piste suivante"
#: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant."
@@ -942,11 +958,15 @@ msgstr "Publier la réponse"
msgid "Posting…"
msgstr "Publication…"
#: src/components/DumpCard.tsx:120
#: src/components/JournalCard.tsx:121
#: src/components/GlobalPlayer.tsx:154
msgid "Previous track"
msgstr "Piste précédente"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:116
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:462
#: src/pages/Dump.tsx:463
#: src/pages/PlaylistDetail.tsx:644
msgid "private"
msgstr "privé"
@@ -966,11 +986,15 @@ msgstr "public"
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:235
#: src/pages/DumpEdit.tsx:241
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
#: src/pages/DumpEdit.tsx:234
#: src/components/GlobalPlayer.tsx:240
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
#: src/pages/DumpEdit.tsx:240
msgid "Refreshing…"
msgstr "Actualisation…"
@@ -988,7 +1012,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:529
#: src/pages/Dump.tsx:530
msgid "Related"
msgstr "Connexe"
@@ -1008,7 +1032,7 @@ msgstr "Retirer le j'aime"
msgid "Remove vote"
msgstr "Retirer le vote"
#: src/pages/DumpEdit.tsx:420
#: src/pages/DumpEdit.tsx:429
msgid "Replace file"
msgstr "Remplacer le fichier"
@@ -1034,13 +1058,13 @@ msgstr "Échec de la réinitialisation"
msgid "Reset password"
msgstr "Réinitialiser le mot de passe"
#: src/pages/DumpEdit.tsx:381
#: src/pages/DumpEdit.tsx:399
#: src/pages/DumpEdit.tsx:390
#: src/pages/DumpEdit.tsx:408
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
#: src/pages/Dump.tsx:284
#: src/pages/DumpEdit.tsx:174
#: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
msgstr "Réessayer"
@@ -1050,8 +1074,8 @@ msgstr "Rôle"
#: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:395
#: src/pages/DumpEdit.tsx:463
#: src/pages/Dump.tsx:396
#: src/pages/DumpEdit.tsx:472
#: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1736
@@ -1060,7 +1084,7 @@ msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:394
#: src/pages/Dump.tsx:395
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663
@@ -1152,13 +1176,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:365
#: src/pages/DumpEdit.tsx:374
msgid "Thumbnail"
msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:389
#: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:389
#: src/pages/DumpEdit.tsx:398
msgid "Title"
msgstr "Titre"
@@ -1210,7 +1234,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:344
#: src/pages/DumpEdit.tsx:412
#: src/pages/DumpEdit.tsx:421
msgid "URL"
msgstr "URL"
@@ -1256,7 +1280,7 @@ msgid "View dump →"
msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:434
#: src/pages/DumpEdit.tsx:437
#: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
@@ -1266,7 +1290,7 @@ msgid "Who am I?"
msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:433
#: src/pages/DumpEdit.tsx:436
#: src/pages/DumpEdit.tsx:445
msgid "Why?"
msgstr "Pourquoi ?"

View File

@@ -424,6 +424,13 @@
border-radius: 0;
}
/* Queue UI: square off the artwork and the stream progress bar. */
[data-style="brutalist"] .global-player-artwork,
[data-style="brutalist"] .audio-player-track--stream::before,
[data-style="brutalist"] .audio-player-track--stream .audio-player-fill {
border-radius: 0;
}
[data-style="brutalist"] .global-player .audio-player-btn {
background: transparent;
color: var(--color-text);

View File

@@ -496,6 +496,13 @@
border-radius: 0;
}
/* Queue UI: square off the artwork and the stream progress bar. */
[data-style="geocities"] .global-player-artwork,
[data-style="geocities"] .audio-player-track--stream::before,
[data-style="geocities"] .audio-player-track--stream .audio-player-fill {
border-radius: 0;
}
[data-style="geocities"] .global-player .audio-player-btn {
color: var(--color-on-accent);
}

View File

@@ -169,6 +169,9 @@
[data-style="nyt"] .global-player,
[data-style="nyt"] .global-player-media-wrap,
[data-style="nyt"] .global-player-iframe-wrap,
[data-style="nyt"] .global-player-artwork,
[data-style="nyt"] .audio-player-track--stream::before,
[data-style="nyt"] .audio-player-track--stream .audio-player-fill,
[data-style="nyt"] .fdz,
[data-style="nyt"] .visibility-toggle,
[data-style="nyt"] .feed-tab,

78
src/utils/bandcamp.ts Normal file
View File

@@ -0,0 +1,78 @@
import { API_URL } from "../config/api.ts";
import type { PlayerItem } from "../contexts/PlayerContext.ts";
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
interface BandcampTrack {
index: number;
trackNum?: number;
title: string;
duration?: number;
streamUrl: string | null;
streamable: boolean;
capped: boolean;
}
interface BandcampAlbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: BandcampTrack[];
resolvedAt: number;
}
export interface BandcampContext {
dumpHref?: string;
/** Kept on every item so playback can fall back to the iframe later. */
embedUrl?: string;
/** Used only if the page carries no album art of its own. */
fallbackArtworkUrl?: string;
}
/**
* Resolve a Bandcamp page into a playable queue.
*
* Throws on any failure — callers treat that as "fall back to the embed".
* `force` skips the server's cache, for re-resolving after a signature expires.
*/
export async function resolveBandcampQueue(
pageUrl: string,
ctx: BandcampContext = {},
{ force = false }: { force?: boolean } = {},
): Promise<PlayerItem[]> {
const res = await fetch(
`${API_URL}/api/bandcamp/tracks?url=${encodeURIComponent(pageUrl)}${
force ? "&force=1" : ""
}`,
);
if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
const body = await res.json() as { success: boolean; data?: BandcampAlbum };
const album = body.data;
if (!body.success || !album) throw new Error("resolve returned no data");
const items = album.tracks
.filter((t): t is BandcampTrack & { streamUrl: string } =>
t.streamable && typeof t.streamUrl === "string"
)
.map((t): PlayerItem => ({
kind: "stream",
streamUrl: t.streamUrl,
type: "bandcamp",
// The row names the track; the artist rides along on the subtitle line.
title: t.title,
subtitle: album.artist,
duration: t.duration,
trackNum: t.trackNum,
artworkUrl: album.artworkUrl ?? ctx.fallbackArtworkUrl,
dumpHref: ctx.dumpHref,
resolveUrl: album.sourceUrl,
resolveIndex: t.index,
resolvedAt: album.resolvedAt,
embedUrl: ctx.embedUrl,
}));
if (items.length === 0) throw new Error("no streamable tracks");
return items;
}

7
src/utils/duration.ts Normal file
View File

@@ -0,0 +1,7 @@
/** Format seconds as m:ss. Shared by the media player and the player queue. */
export 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")}`;
}

View File

@@ -0,0 +1,34 @@
import type { PlayerItem } from "../contexts/PlayerContext.ts";
import { resolveBandcampQueue } from "./bandcamp.ts";
/**
* Re-resolve the queue a stream item belongs to, for when its signed URL
* expires. Keeping the dispatch here is what lets PlayerProvider handle
* expiry without knowing that Bandcamp exists.
*/
export function resolveStreamQueue(
item: Extract<PlayerItem, { kind: "stream" }>,
): Promise<PlayerItem[]> {
if (item.type === "bandcamp") {
return resolveBandcampQueue(
item.resolveUrl,
{
dumpHref: item.dumpHref,
embedUrl: item.embedUrl,
// Carry the current artwork so a re-resolve can't blank the header.
fallbackArtworkUrl: item.artworkUrl,
},
{ force: true },
);
}
return Promise.reject(new Error(`no resolver for stream type ${item.type}`));
}
/** Signed URLs live 24h; re-resolve before that to avoid a stall mid-play. */
export const STREAM_STALE_MS = 20 * 60 * 60 * 1000;
export function isStreamStale(
item: Extract<PlayerItem, { kind: "stream" }>,
): boolean {
return Date.now() - item.resolvedAt > STREAM_STALE_MS;
}

View File

@@ -4,6 +4,7 @@ import { lingui } from "@lingui/vite-plugin";
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚";
const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed";
// Cache-busting token for the favicon URLs (see index.html). Changes when the
// emoji changes, so browsers don't keep serving a stale cached icon. Mirrors
@@ -49,6 +50,7 @@ export default defineConfig({
.replaceAll("__SITE_NAME__", SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
: html,
},
},