v3: native bandcamp playback and a queue in the global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
The player now holds a queue instead of a single item, so albums play through and single tracks share the same code path. Adds a "stream" item kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs for ~24h, so an item carries the page it came from and re-resolves itself on error or on a stale restored session, falling back to the iframe embed if that fails too. Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a meta tag the same way as site-name/site-emoji. Also: player header gains artwork and a subtitle, volume persists across queue advances and reloads, and cross-origin streams get a plain seek bar rather than a waveform that can never decode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
26
api/routes/bandcamp.ts
Normal 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;
|
||||||
133
api/services/bandcamp-stream-service.ts
Normal file
133
api/services/bandcamp-stream-service.ts
Normal 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;
|
||||||
|
}
|
||||||
149
api/services/bandcamp-tralbum.ts
Normal file
149
api/services/bandcamp-tralbum.ts
Normal 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 `&` before `"`, so an escaped-ampersand-then-quot sequence
|
||||||
|
* (`&quot;`, i.e. the literal text `"` 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
130
src/App.css
130
src/App.css
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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
19
src/config/playerMode.ts
Normal 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();
|
||||||
@@ -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: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
86
src/hooks/usePlayRichContent.ts
Normal file
86
src/hooks/usePlayRichContent.ts
Normal 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
@@ -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
@@ -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 ?"
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
78
src/utils/bandcamp.ts
Normal 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
7
src/utils/duration.ts
Normal 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")}`;
|
||||||
|
}
|
||||||
34
src/utils/streamSources.ts
Normal file
34
src/utils/streamSources.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user