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. # (server startup), so changing it only needs a restart — no rebuild.
GERBEUR_SITE_EMOJI=🚚 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). # Port the API server listens on (the container's internal port).
GERBEUR_PORT=8000 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. // only needs a restart — no rebuild.
export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; 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 // Background color for generated icons and the manifest. Mirrors the
// hard-coded theme-color in index.html. // hard-coded theme-color in index.html.
export const THEME_COLOR = "#111827"; export const THEME_COLOR = "#111827";

View File

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

View File

@@ -9,6 +9,7 @@ import usersRouter from "./routes/users.ts";
import avatarsRouter from "./routes/avatars.ts"; import avatarsRouter from "./routes/avatars.ts";
import wsRouter from "./routes/ws.ts"; import wsRouter from "./routes/ws.ts";
import previewRouter from "./routes/preview.ts"; import previewRouter from "./routes/preview.ts";
import bandcampRouter from "./routes/bandcamp.ts";
import playlistsRouter from "./routes/playlists.ts"; import playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts"; import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts"; import chatRouter from "./routes/chat.ts";
@@ -71,6 +72,10 @@ app.use(
previewRouter.routes(), previewRouter.routes(),
previewRouter.allowedMethods(), previewRouter.allowedMethods(),
); );
app.use(
bandcampRouter.routes(),
bandcampRouter.allowedMethods(),
);
app.use( app.use(
playlistsRouter.routes(), playlistsRouter.routes(),
playlistsRouter.allowedMethods(), playlistsRouter.allowedMethods(),

View File

@@ -2,7 +2,7 @@ import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts"; import { getDump } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts"; import { getUserByUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-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"; import { emojiToCodepoint } from "../lib/site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -69,7 +69,8 @@ async function loadIndexHtml(): Promise<string | null> {
cachedHtml = (await Deno.readTextFile(path)) cachedHtml = (await Deno.readTextFile(path))
.replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION); .replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
return cachedHtml; return cachedHtml;
} catch { } catch {
continue; 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="theme-color" content="#111827" />
<meta name="site-name" content="__SITE_NAME__" /> <meta name="site-name" content="__SITE_NAME__" />
<meta name="site-emoji" content="__SITE_EMOJI__" /> <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="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
<title>__SITE_NAME__</title> <title>__SITE_NAME__</title>

View File

@@ -832,6 +832,37 @@
transition: width 0.1s linear; 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 { .audio-player-track--volume {
flex: 1 1 100px; flex: 1 1 100px;
max-width: 120px; max-width: 120px;
@@ -1063,6 +1094,105 @@ a.global-player-title:hover {
.global-player.global-player--bandcamp { .global-player.global-player--bandcamp {
max-width: 600px; 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 { .feed-loading-more {
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;

View File

@@ -4,7 +4,7 @@ import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx"; import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import { import {
BAR_GAP, BAR_GAP,
BAR_W, BAR_W,
@@ -20,11 +20,39 @@ interface FilePreviewProps {
global?: boolean; 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, // Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext. // reflects live play state and position from PlayerContext.
function AudioFilePreview( function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) { ) {
const { token } = useAuth();
const { current, playing, currentTime, duration, play, togglePlay, seekTo } = const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext); useContext(PlayerContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null); const [peaks, setPeaks] = useState<Float32Array | null>(null);
@@ -45,7 +73,7 @@ function AudioFilePreview(
const handlePlayBtn = () => { const handlePlayBtn = () => {
if (isActive) togglePlay(); 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>) => { const handleWaveformClick = (e: React.MouseEvent<Element>) => {
@@ -59,7 +87,7 @@ function AudioFilePreview(
} else { } else {
// Start playing and seek once it loads — seekTo after play() is a no-op // 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 // 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) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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)} /> <VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
@@ -196,7 +224,7 @@ export default function FilePreview(
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); play(filePlayerItem(dump, fileUrl, mime, token));
}} }}
> >
{thumbOverride {thumbOverride
@@ -230,12 +258,9 @@ export default function FilePreview(
type="button" type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`} className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
onClick={() => onClick={() =>
videoActive ? togglePlay() : play({ videoActive
kind: "file", ? togglePlay()
fileUrl, : play(filePlayerItem(dump, fileUrl, mime, token))}
mimeType: mime,
title: dump.title,
})}
> >
<video <video
src={fileUrl} src={fileUrl}

View File

@@ -1,23 +1,25 @@
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router"; 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 { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../utils/duration.ts";
import Thumbnail from "./Thumbnail.tsx";
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>; 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 // 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 // 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 // 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 // session restored from localStorage has no user gesture behind it, so it stays
// paused, matching how MediaPlayer treats file items. // 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) { function playbackUrl(item: EmbedItem, autoplay: boolean) {
if (!autoplay) return item.embedUrl; if (!autoplay) return item.embedUrl;
try { try {
@@ -35,20 +37,32 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
export function GlobalPlayer() { export function GlobalPlayer() {
const { const {
current, current,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime, startTime,
autoplay, autoplay,
stop, stop,
next,
previous,
playAt,
seekRef, seekRef,
toggleRef, toggleRef,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
} = useContext(PlayerContext); } = useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const currentRowRef = useRef<HTMLLIElement>(null);
const [reduced, setReduced] = useState(false); const [reduced, setReduced] = useState(false);
const [prevKey, setPrevKey] = useState(itemKey(current)); const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
if (prevKey !== itemKey(current)) { const currentKey = current ? playerItemKey(current) : null;
setPrevKey(itemKey(current)); if (prevKey !== currentKey) {
setPrevKey(currentKey);
if (current) setReduced(false); if (current) setReduced(false);
} }
@@ -79,16 +93,27 @@ export function GlobalPlayer() {
}; };
}, [current]); }, [current]);
// Keep the playing row visible as the queue advances on its own.
useEffect(() => {
currentRowRef.current?.scrollIntoView({ block: "nearest" });
}, [queueIndex]);
if (!current) return null; if (!current) return null;
const typeClass = current.kind === "embed" // Files are classed by their media kind; everything else carries a brand key,
? current.type // so a native Bandcamp stream keeps the same styling as the Bandcamp embed.
: current.mimeType.startsWith("video/") const typeClass = current.kind === "file"
? "file-video" ? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
: "file-audio"; : current.type;
const title = current.title ?? 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 ( return (
<div <div
@@ -98,6 +123,16 @@ export function GlobalPlayer() {
ref={ref} ref={ref}
> >
<div className="global-player-header"> <div className="global-player-header">
{current.artworkUrl && (
<Thumbnail
src={current.artworkUrl}
className="global-player-artwork"
placeholder={{ siteName: current.subtitle }}
placeholderClassName="global-player-artwork"
loading="eager"
/>
)}
<div className="global-player-heading">
{current.dumpHref {current.dumpHref
? ( ? (
<Link to={current.dumpHref} className="global-player-title"> <Link to={current.dumpHref} className="global-player-title">
@@ -105,14 +140,49 @@ export function GlobalPlayer() {
</Link> </Link>
) )
: <span className="global-player-title">{title}</span>} : <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 <button
type="button" type="button"
className="btn btn--ghost" className="btn btn--ghost"
onClick={() => setReduced((r) => !r)} onClick={() => setReduced((r) => !r)}
aria-label={reduced ? t`Expand player` : t`Collapse player`}
> >
{reduced ? "▲" : "▼"} {reduced ? "▲" : "▼"}
</button> </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> </button>
</div> </div>
@@ -128,7 +198,8 @@ export function GlobalPlayer() {
/> />
</div> </div>
) )
: ( : current.kind === "file"
? (
<div className="global-player-media-wrap"> <div className="global-player-media-wrap">
<MediaPlayer <MediaPlayer
key={current.fileUrl} key={current.fileUrl}
@@ -139,10 +210,70 @@ export function GlobalPlayer() {
startTime={startTime} startTime={startTime}
onPlayStateChange={onPlayStateChange} onPlayStateChange={onPlayStateChange}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
seekRef={seekRef} seekRef={seekRef}
toggleRef={toggleRef} toggleRef={toggleRef}
/> />
</div> </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>
</div> </div>

View File

@@ -1,4 +1,3 @@
import { useContext } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
@@ -11,7 +10,10 @@ import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx"; import { Tooltip } from "./Tooltip.tsx";
import Thumbnail from "./Thumbnail.tsx"; import Thumbnail from "./Thumbnail.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import {
canPlayRichContent,
usePlayRichContent,
} from "../hooks/usePlayRichContent.ts";
export type { JournalShape }; export type { JournalShape };
@@ -32,7 +34,7 @@ export function JournalCard(
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth(); const { token } = useAuth();
const { play } = useContext(PlayerContext); const { playRichContent } = usePlayRichContent();
const unread = !isOwner && isRecent(dump.createdAt) && const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id); !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 = ( const titleLink = (
<Link <Link
@@ -142,15 +150,8 @@ export function JournalCard(
return ( return (
<li <li
className={className} className={className}
onClick={embedUrl onClick={playable
? () => ? () => void playRichContent(playable, dumpUrl(dump))
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",
dumpHref: dumpUrl(dump),
})
: handleNavigate} : handleNavigate}
> >
<div className="journal-card-image"> <div className="journal-card-image">
@@ -167,7 +168,7 @@ export function JournalCard(
siteName: dump.richContent?.siteName, siteName: dump.richContent?.siteName,
}} }}
/> />
{embedUrl && ( {playable && (
<span className="rich-content-play-overlay" aria-hidden="true"> <span className="rich-content-play-overlay" aria-hidden="true">
</span> </span>

View File

@@ -7,13 +7,7 @@ import {
VIEWBOX_W, VIEWBOX_W,
WAVEFORM_H, WAVEFORM_H,
} from "../utils/waveform.ts"; } from "../utils/waveform.ts";
import { fmt } from "../utils/duration.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")}`;
}
export const IconPlay = () => ( export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}> <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 ─────────────────────────────────────────────────────────────── // ── MediaPlayer ───────────────────────────────────────────────────────────────
const HIDE_DELAY = 2500; const HIDE_DELAY = 2500;
@@ -132,6 +192,14 @@ interface MediaPlayerProps {
startTime?: number; startTime?: number;
onPlayStateChange?: (playing: boolean) => void; onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => 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 }; seekRef?: { current: ((t: number) => void) | null };
toggleRef?: { current: (() => void) | null }; toggleRef?: { current: (() => void) | null };
} }
@@ -146,6 +214,9 @@ export function MediaPlayer(
startTime, startTime,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
trackStyle = "waveform",
seekRef, seekRef,
toggleRef, toggleRef,
}: MediaPlayerProps, }: MediaPlayerProps,
@@ -155,8 +226,8 @@ export function MediaPlayer(
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(lastVolume);
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(lastMuted);
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 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. // and effect, acceptable since these are only called from async event handlers.
const onPlayStateChangeRef = useRef(onPlayStateChange); const onPlayStateChangeRef = useRef(onPlayStateChange);
const onTimeUpdateRef = useRef(onTimeUpdate); const onTimeUpdateRef = useRef(onTimeUpdate);
const onEndedRef = useRef(onEnded);
const onErrorRef = useRef(onError);
// Sync prop callbacks after every render // Sync prop callbacks after every render
useEffect(() => { useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange; onPlayStateChangeRef.current = onPlayStateChange;
onTimeUpdateRef.current = onTimeUpdate; onTimeUpdateRef.current = onTimeUpdate;
onEndedRef.current = onEnded;
onErrorRef.current = onError;
}); });
// Stable function refs — updated via effects, indirected by the registration // 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 // 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) // Autoplay on mount (e.g. triggered by play() in PlayerContext)
useEffect(() => { useEffect(() => {
if (!autoplay) return; if (!autoplay) return;
@@ -245,6 +331,8 @@ export function MediaPlayer(
a.pause(); a.pause();
onPlayStateChangeRef.current = undefined; onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined; onTimeUpdateRef.current = undefined;
onEndedRef.current = undefined;
onErrorRef.current = undefined;
}; };
}, []); }, []);
@@ -272,17 +360,24 @@ export function MediaPlayer(
setDuration(a.duration); setDuration(a.duration);
onTimeUpdateRef.current?.(a.currentTime, a.duration); onTimeUpdateRef.current?.(a.currentTime, a.duration);
}; };
const onEnded = () => { const onEndedEvent = () => {
setPlaying(false); setPlaying(false);
onPlayStateChangeRef.current?.(false); onPlayStateChangeRef.current?.(false);
onEndedRef.current?.();
};
const onErrorEvent = () => {
setPlaying(false);
onErrorRef.current?.();
}; };
a.addEventListener("timeupdate", onTime); a.addEventListener("timeupdate", onTime);
a.addEventListener("durationchange", onDuration); a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEnded); a.addEventListener("ended", onEndedEvent);
a.addEventListener("error", onErrorEvent);
return () => { return () => {
a.removeEventListener("timeupdate", onTime); a.removeEventListener("timeupdate", onTime);
a.removeEventListener("durationchange", onDuration); a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEnded); a.removeEventListener("ended", onEndedEvent);
a.removeEventListener("error", onErrorEvent);
}; };
}, [dragging]); }, [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 changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value); const v = Number(e.target.value);
const nextMuted = v > 0 && muted ? false : muted;
setVolume(v); setVolume(v);
mediaRef.current!.volume = v; mediaRef.current!.volume = v;
if (v > 0 && muted) { if (nextMuted !== muted) {
setMuted(false); setMuted(nextMuted);
mediaRef.current!.muted = false; mediaRef.current!.muted = nextMuted;
} }
rememberVolume(v, nextMuted);
}; };
const toggleMute = () => { const toggleMute = () => {
const next = !muted; const next = !muted;
setMuted(next); setMuted(next);
mediaRef.current!.muted = next; mediaRef.current!.muted = next;
rememberVolume(volume, next);
}; };
const goFullscreen = () => { const goFullscreen = () => {
(mediaRef.current as HTMLVideoElement).requestFullscreen?.(); (mediaRef.current as HTMLVideoElement).requestFullscreen?.();
}; };
const progress = duration > 0 ? current / duration : 0; // 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" const track = kind === "audio" && trackStyle === "waveform"
? ( ? (
<Waveform <Waveform
src={src} src={src}
@@ -352,24 +447,13 @@ export function MediaPlayer(
/> />
) )
: ( : (
<div className="audio-player-track"> <SeekBar
<div current={current}
className="audio-player-fill" duration={duration}
style={{ width: `${progress * 100}%` }} onSeek={seekTo}
onDragChange={setDragging}
className={kind === "audio" ? "audio-player-track--stream" : undefined}
/> />
<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>
); );
const controls = ( const controls = (

View File

@@ -1,6 +1,10 @@
import { useContext } from "react"; import { useContext } from "react";
import type { RichContent } from "../model.ts"; import type { RichContent } from "../model.ts";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
canPlayRichContent,
usePlayRichContent,
} from "../hooks/usePlayRichContent.ts";
import Thumbnail from "./Thumbnail.tsx"; import Thumbnail from "./Thumbnail.tsx";
interface RichContentCardProps { interface RichContentCardProps {
@@ -16,8 +20,12 @@ export default function RichContentCard(
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }: { richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
RichContentCardProps, 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; 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 = { const placeholder = {
url: richContent.url, url: richContent.url,
@@ -37,26 +45,26 @@ export default function RichContentCard(
/> />
); );
if (richContent.embedUrl) { if (canPlay) {
const isActive = current?.kind === "embed" && // A native Bandcamp queue is the same card as its embed, matched on the
current.embedUrl === richContent.embedUrl; // 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; const isPlaying = isActive && playing;
return ( return (
<button <button
type="button" type="button"
className={`rich-content-thumbnail-btn${ className={`rich-content-thumbnail-btn${
isActive ? " is-playing" : "" isActive ? " is-playing" : ""
}`} }${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
play({ void playRichContent(playable, dumpHref);
kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,
dumpHref,
});
}} }}
aria-label={isPlaying ? "Pause" : "Play"} aria-label={isPlaying ? "Pause" : "Play"}
> >
@@ -81,8 +89,6 @@ export default function RichContentCard(
); );
} }
const canPlay = !!richContent.embedUrl;
const thumbnailImg = ( const thumbnailImg = (
<Thumbnail <Thumbnail
src={thumbnailSrc} src={thumbnailSrc}
@@ -97,15 +103,9 @@ export default function RichContentCard(
? ( ? (
<button <button
type="button" type="button"
className="rich-content-thumbnail-btn" className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
onClick={() => disabled={pending}
play({ onClick={() => void playRichContent(playable, dumpHref)}
kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,
dumpHref,
})}
aria-label="Play" aria-label="Play"
> >
{thumbnailImg} {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"; 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 = export type PlayerItem =
| { kind: "embed"; embedUrl: string; title?: string; type: string; dumpHref?: string } | (PlayerItemBase & {
| { kind: "file"; fileUrl: string; mimeType: string; title?: string; dumpHref?: string }; 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 { export interface PlayerContextValue {
// Playback state — readable by any consumer // Playback state — readable by any consumer
@@ -11,14 +71,28 @@ export interface PlayerContextValue {
currentTime: number; currentTime: number;
duration: 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 // 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. // from a previous session, or resumed after a re-resolve) and whether the
// A restored item appears paused; a fresh play() autoplays on the user gesture. // active MediaPlayer should autoplay. A restored item appears paused; a fresh
// play() autoplays on the user gesture.
startTime: number; startTime: number;
autoplay: boolean; autoplay: boolean;
// Control — callable by any consumer // Control — callable by any consumer
play(item: PlayerItem): void; play(item: PlayerItem): void;
playQueue(items: PlayerItem[], startIndex?: number): void;
playAt(index: number): void;
next(): void;
previous(): void;
stop(): void; stop(): void;
seekTo(time: number): void; seekTo(time: number): void;
togglePlay(): void; togglePlay(): void;
@@ -31,6 +105,10 @@ export interface PlayerContextValue {
// Internal: GlobalPlayer calls these to push state back into the provider // Internal: GlobalPlayer calls these to push state back into the provider
onPlayStateChange(playing: boolean): void; onPlayStateChange(playing: boolean): void;
onTimeUpdate(time: number, duration: number): 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>({ export const PlayerContext = createContext<PlayerContextValue>({
@@ -38,9 +116,18 @@ export const PlayerContext = createContext<PlayerContextValue>({
playing: false, playing: false,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
queue: [],
queueIndex: 0,
hasNext: false,
hasPrevious: false,
resolving: false,
startTime: 0, startTime: 0,
autoplay: false, autoplay: false,
play: () => {}, play: () => {},
playQueue: () => {},
playAt: () => {},
next: () => {},
previous: () => {},
stop: () => {}, stop: () => {},
seekTo: () => {}, seekTo: () => {},
togglePlay: () => {}, togglePlay: () => {},
@@ -48,4 +135,6 @@ export const PlayerContext = createContext<PlayerContextValue>({
toggleRef: { current: null }, toggleRef: { current: null },
onPlayStateChange: () => {}, onPlayStateChange: () => {},
onTimeUpdate: () => {}, onTimeUpdate: () => {},
onEnded: () => {},
onError: () => {},
}); });

View File

@@ -1,39 +1,83 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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"; 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 { interface StoredSession {
item: PlayerItem; v: 2;
queue: PlayerItem[];
index: number;
time: 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 { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredSession>; const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed.item || (parsed.item.kind !== "embed" && parsed.item.kind !== "file")) { const time = Number(parsed.time) || 0;
return null;
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 { } catch {
return null; return null;
} }
} }
export function PlayerProvider({ children }: { children: React.ReactNode }) { /** Don't retry the same failing track more than once per window. */
const restored = useRef(readSession()).current; 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 [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0); const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
const [duration, setDuration] = useState(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); const [startTime, setStartTime] = useState(restored?.time ?? 0);
// Restored items start paused (no user gesture); fresh play() autoplays. // Restored items start paused (no user gesture); fresh play() autoplays.
const [autoplay, setAutoplay] = useState(false); 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 // GlobalPlayer registers the active MediaPlayer's imperative handles here
const seekRef = useRef<((t: number) => void) | null>(null); 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). // MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
const suppressUpdates = useRef(false); 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; 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); setCurrentTime(0);
setDuration(0); setDuration(0);
setStartTime(0); setStartTime(0);
@@ -54,7 +123,8 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
const stop = useCallback(() => { const stop = useCallback(() => {
setCurrent(null); setQueue([]);
setQueueIndex(0);
setPlaying(false); setPlaying(false);
setCurrentTime(0); setCurrentTime(0);
setDuration(0); setDuration(0);
@@ -70,6 +140,31 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef.current?.(); 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) => { const onPlayStateChange = useCallback((p: boolean) => {
if (p) suppressUpdates.current = false; if (p) suppressUpdates.current = false;
setPlaying(p); setPlaying(p);
@@ -81,25 +176,127 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
setDuration(d); setDuration(d);
}, []); }, []);
// ── Persistence ──────────────────────────────────────────────────────────────── const onEnded = useCallback(() => {
// Write the item to storage as soon as it changes (survives crashes), then refresh if (stateRef.current.current && queueIndex + 1 < queue.length) {
// the playback position on pagehide — which also fires on a normal reload — so the advanceTo(queueIndex + 1);
// resume offset is accurate without thrashing localStorage on every timeupdate. 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(() => { 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( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ item: current, time: currentTime } satisfies StoredSession), JSON.stringify(
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession,
),
); );
} else { } else {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
// currentTime intentionally omitted from deps — see pagehide writer below. // currentTime intentionally omitted from deps — see pagehide writer below.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [current]); }, [queue, queueIndex]);
const sessionRef = useRef<StoredSession | null>(null); 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(() => { useEffect(() => {
const persist = () => { const persist = () => {
@@ -118,9 +315,18 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
playing, playing,
currentTime, currentTime,
duration, duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime, startTime,
autoplay, autoplay,
play, play,
playQueue,
playAt,
next,
previous,
stop, stop,
seekTo, seekTo,
togglePlay, togglePlay,
@@ -128,19 +334,32 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef, toggleRef,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
}), [ }), [
current, current,
playing, playing,
currentTime, currentTime,
duration, duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime, startTime,
autoplay, autoplay,
play, play,
playQueue,
playAt,
next,
previous,
stop, stop,
seekTo, seekTo,
togglePlay, togglePlay,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
]); ]);
return ( 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]" msgstr "[deleted]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:111 #: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:112 #: src/components/JournalCard.tsx:107
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{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" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:291 #: src/pages/Dump.tsx:292
#: src/pages/Dump.tsx:521 #: src/pages/Dump.tsx:522
#: src/pages/DumpEdit.tsx:181 #: src/pages/DumpEdit.tsx:187
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -78,7 +78,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:362 #: src/pages/Dump.tsx:363
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ 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/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:403 #: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:460 #: src/pages/DumpEdit.tsx:469
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674 #: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1744 #: src/pages/UserPublicProfile.tsx:1744
@@ -266,6 +266,14 @@ msgstr "Checking invite…"
msgid "Close" msgid "Close"
msgstr "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 #: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme" msgid "Color scheme"
msgstr "Color scheme" msgstr "Color scheme"
@@ -291,7 +299,7 @@ msgstr "Could not change password"
msgid "Could not load." msgid "Could not load."
msgstr "Could not load." msgstr "Could not load."
#: src/pages/DumpEdit.tsx:361 #: src/pages/DumpEdit.tsx:370
msgid "Could not save" msgid "Could not save"
msgstr "Could not save" msgstr "Could not save"
@@ -346,8 +354,8 @@ msgstr "Delete category"
msgid "Delete category \"{0}\"? This cannot be undone." msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:255 #: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:456 #: src/pages/DumpEdit.tsx:465
msgid "Delete dump" msgid "Delete dump"
msgstr "Delete dump" msgstr "Delete dump"
@@ -361,7 +369,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "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." msgid "Delete this dump? This cannot be undone."
msgstr "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" msgid "Drop a file here"
msgstr "Drop a file here" msgstr "Drop a file here"
#: src/pages/DumpEdit.tsx:428 #: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Drop a replacement here" msgstr "Drop a replacement here"
@@ -426,7 +434,7 @@ msgstr "Earlier"
#: src/components/ChatModal.tsx:172 #: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173 #: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:517 #: src/pages/Dump.tsx:518
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -445,7 +453,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152 #: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:456 #: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -455,12 +463,12 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150 #: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:454 #: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
#: src/pages/DumpEdit.tsx:206 #: src/pages/DumpEdit.tsx:212
msgid "Editing" msgid "Editing"
msgstr "Editing" msgstr "Editing"
@@ -477,6 +485,10 @@ msgstr "Email address"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "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 #: src/components/CategoryManager.tsx:230
msgid "Failed to create category" msgid "Failed to create category"
msgstr "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." 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." 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" msgid "In collections"
msgstr "In collections" msgstr "In collections"
@@ -683,8 +695,8 @@ msgstr "Load more"
msgid "Load older messages" msgid "Load older messages"
msgstr "Load older messages" msgstr "Load older messages"
#: src/pages/Dump.tsx:267 #: src/pages/Dump.tsx:268
#: src/pages/DumpEdit.tsx:157 #: src/pages/DumpEdit.tsx:163
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
@@ -801,6 +813,10 @@ msgstr "New password"
msgid "New playlist" msgid "New playlist"
msgstr "New playlist" msgstr "New playlist"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Next track"
#: src/pages/PlaylistDetail.tsx:680 #: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet." msgstr "No dumps in this playlist yet."
@@ -942,11 +958,15 @@ msgstr "Post reply"
msgid "Posting…" msgid "Posting…"
msgstr "Posting…" msgstr "Posting…"
#: src/components/DumpCard.tsx:120 #: src/components/GlobalPlayer.tsx:154
#: src/components/JournalCard.tsx:121 msgid "Previous track"
msgstr "Previous track"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:116
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:462 #: src/pages/Dump.tsx:463
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -966,11 +986,15 @@ msgstr "public"
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
#: src/pages/DumpEdit.tsx:235 #: src/pages/DumpEdit.tsx:241
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "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…" msgid "Refreshing…"
msgstr "Refreshing…" msgstr "Refreshing…"
@@ -988,7 +1012,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:529 #: src/pages/Dump.tsx:530
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -1008,7 +1032,7 @@ msgstr "Remove like"
msgid "Remove vote" msgid "Remove vote"
msgstr "Remove vote" msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:420 #: src/pages/DumpEdit.tsx:429
msgid "Replace file" msgid "Replace file"
msgstr "Replace file" msgstr "Replace file"
@@ -1034,13 +1058,13 @@ msgstr "Reset failed"
msgid "Reset password" msgid "Reset password"
msgstr "Reset password" msgstr "Reset password"
#: src/pages/DumpEdit.tsx:381 #: src/pages/DumpEdit.tsx:390
#: src/pages/DumpEdit.tsx:399 #: src/pages/DumpEdit.tsx:408
msgid "Reset to default" msgid "Reset to default"
msgstr "Reset to default" msgstr "Reset to default"
#: src/pages/Dump.tsx:284 #: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:174 #: src/pages/DumpEdit.tsx:180
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
@@ -1050,8 +1074,8 @@ msgstr "Role"
#: src/components/ChatModal.tsx:222 #: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:395 #: src/pages/Dump.tsx:396
#: src/pages/DumpEdit.tsx:463 #: src/pages/DumpEdit.tsx:472
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666 #: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1736 #: src/pages/UserPublicProfile.tsx:1736
@@ -1060,7 +1084,7 @@ msgstr "Save"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:394 #: src/pages/Dump.tsx:395
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663 #: src/pages/UserPublicProfile.tsx:1663
@@ -1152,13 +1176,13 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "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" msgid "Thumbnail"
msgstr "Thumbnail" msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:389 #: src/components/DumpCreateModal.tsx:389
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:389 #: src/pages/DumpEdit.tsx:398
msgid "Title" msgid "Title"
msgstr "Title" msgstr "Title"
@@ -1210,7 +1234,7 @@ msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:344 #: src/components/DumpCreateModal.tsx:344
#: src/pages/DumpEdit.tsx:412 #: src/pages/DumpEdit.tsx:421
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
@@ -1256,7 +1280,7 @@ msgid "View dump →"
msgstr "View dump →" msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:434 #: src/components/DumpCreateModal.tsx:434
#: src/pages/DumpEdit.tsx:437 #: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
@@ -1266,7 +1290,7 @@ msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:433 #: src/components/DumpCreateModal.tsx:433
#: src/pages/DumpEdit.tsx:436 #: src/pages/DumpEdit.tsx:445
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -424,6 +424,13 @@
border-radius: 0; 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 { [data-style="brutalist"] .global-player .audio-player-btn {
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-text);

View File

@@ -496,6 +496,13 @@
border-radius: 0; 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 { [data-style="geocities"] .global-player .audio-player-btn {
color: var(--color-on-accent); color: var(--color-on-accent);
} }

View File

@@ -169,6 +169,9 @@
[data-style="nyt"] .global-player, [data-style="nyt"] .global-player,
[data-style="nyt"] .global-player-media-wrap, [data-style="nyt"] .global-player-media-wrap,
[data-style="nyt"] .global-player-iframe-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"] .fdz,
[data-style="nyt"] .visibility-toggle, [data-style="nyt"] .visibility-toggle,
[data-style="nyt"] .feed-tab, [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_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; 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 // 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 // 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_NAME__", SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
: html, : html,
}, },
}, },