From 2606ef4ccc903d4682eec5d89a3c908ed80013e9 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 22 Aug 2026 12:14:28 +0000 Subject: [PATCH] v3: native bandcamp playback and a queue in the global player 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 Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ --- .env.example | 9 + api/config.ts | 11 + api/lib/static.ts | 5 +- api/main.ts | 5 + api/middleware/og.ts | 5 +- api/routes/bandcamp.ts | 26 +++ api/services/bandcamp-stream-service.ts | 133 ++++++++++++ api/services/bandcamp-tralbum.ts | 149 +++++++++++++ index.html | 1 + src/App.css | 130 ++++++++++++ src/components/FilePreview.tsx | 47 ++++- src/components/GlobalPlayer.tsx | 185 ++++++++++++++--- src/components/JournalCard.tsx | 29 +-- src/components/MediaPlayer.tsx | 162 +++++++++++---- src/components/RichContentCard.tsx | 46 ++-- src/config/playerMode.ts | 19 ++ src/contexts/PlayerContext.ts | 97 ++++++++- src/contexts/PlayerProvider.tsx | 265 ++++++++++++++++++++++-- src/hooks/usePlayRichContent.ts | 86 ++++++++ src/locales/en.js | 2 +- src/locales/en.po | 102 +++++---- src/locales/fr.js | 2 +- src/locales/fr.po | 102 +++++---- src/themes/brutalist.css | 7 + src/themes/geocities.css | 7 + src/themes/nyt.css | 3 + src/utils/bandcamp.ts | 78 +++++++ src/utils/duration.ts | 7 + src/utils/streamSources.ts | 34 +++ vite.config.ts | 2 + 30 files changed, 1531 insertions(+), 225 deletions(-) create mode 100644 api/routes/bandcamp.ts create mode 100644 api/services/bandcamp-stream-service.ts create mode 100644 api/services/bandcamp-tralbum.ts create mode 100644 src/config/playerMode.ts create mode 100644 src/hooks/usePlayRichContent.ts create mode 100644 src/utils/bandcamp.ts create mode 100644 src/utils/duration.ts create mode 100644 src/utils/streamSources.ts diff --git a/.env.example b/.env.example index e148709..dc48296 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,15 @@ GERBEUR_SITE_NAME=gerbeur # (server startup), so changing it only needs a restart — no rebuild. GERBEUR_SITE_EMOJI=🚚 +# How Bandcamp links are played. +# embed (default) the bandcamp.com iframe — cannot autoplay +# native resolve the page's mp3 streams and play them in gerbeur's +# own player: autoplay, seeking, and albums as a queue. +# Note this bypasses Bandcamp's player, so their play counts +# and 3-play preview limit do not apply. +# Applied at runtime; changing it needs a restart of both the API and Vite. +GERBEUR_BANDCAMP_PLAYER=embed + # Port the API server listens on (the container's internal port). GERBEUR_PORT=8000 diff --git a/api/config.ts b/api/config.ts index cd7e405..58505ac 100644 --- a/api/config.ts +++ b/api/config.ts @@ -98,6 +98,17 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; // only needs a restart — no rebuild. export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; +// Which Bandcamp playback path the frontend uses. +// "embed" — the bandcamp.com iframe (cannot autoplay) +// "native" — resolve the page's mp3 streams and play them in the app's own +// player, which autoplays and supports albums as a queue +// Anything unrecognised falls back to "embed", so a typo degrades safely. +export type BandcampPlayer = "embed" | "native"; +export const BANDCAMP_PLAYER: BandcampPlayer = + Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native" + ? "native" + : "embed"; + // Background color for generated icons and the manifest. Mirrors the // hard-coded theme-color in index.html. export const THEME_COLOR = "#111827"; diff --git a/api/lib/static.ts b/api/lib/static.ts index 1243a6e..5f63eba 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -1,5 +1,5 @@ import { Context, Next, send } from "@oak/oak"; -import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; +import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { emojiToCodepoint } from "./site-icons.ts"; const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); @@ -13,7 +13,8 @@ async function serveIndexHtml( const html = raw .replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_EMOJI__", SITE_EMOJI) - .replaceAll("__ICON_VERSION__", ICON_VERSION); + .replaceAll("__ICON_VERSION__", ICON_VERSION) + .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); context.response.type = "text/html"; context.response.body = html; } diff --git a/api/main.ts b/api/main.ts index 286c3cf..1152e2f 100644 --- a/api/main.ts +++ b/api/main.ts @@ -9,6 +9,7 @@ import usersRouter from "./routes/users.ts"; import avatarsRouter from "./routes/avatars.ts"; import wsRouter from "./routes/ws.ts"; import previewRouter from "./routes/preview.ts"; +import bandcampRouter from "./routes/bandcamp.ts"; import playlistsRouter from "./routes/playlists.ts"; import commentsRouter from "./routes/comments.ts"; import chatRouter from "./routes/chat.ts"; @@ -71,6 +72,10 @@ app.use( previewRouter.routes(), previewRouter.allowedMethods(), ); +app.use( + bandcampRouter.routes(), + bandcampRouter.allowedMethods(), +); app.use( playlistsRouter.routes(), playlistsRouter.allowedMethods(), diff --git a/api/middleware/og.ts b/api/middleware/og.ts index a65dc8b..513f6f4 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -2,7 +2,7 @@ import { Context, Next } from "@oak/oak"; import { getDump } from "../services/dump-service.ts"; import { getUserByUsername } from "../services/user-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts"; -import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; +import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { emojiToCodepoint } from "../lib/site-icons.ts"; const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); @@ -69,7 +69,8 @@ async function loadIndexHtml(): Promise { cachedHtml = (await Deno.readTextFile(path)) .replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_EMOJI__", SITE_EMOJI) - .replaceAll("__ICON_VERSION__", ICON_VERSION); + .replaceAll("__ICON_VERSION__", ICON_VERSION) + .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); return cachedHtml; } catch { continue; diff --git a/api/routes/bandcamp.ts b/api/routes/bandcamp.ts new file mode 100644 index 0000000..0fb5970 --- /dev/null +++ b/api/routes/bandcamp.ts @@ -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; diff --git a/api/services/bandcamp-stream-service.ts b/api/services/bandcamp-stream-service.ts new file mode 100644 index 0000000..bdfc6e9 --- /dev/null +++ b/api/services/bandcamp-stream-service.ts @@ -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(); + +/** 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 { + 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; +} diff --git a/api/services/bandcamp-tralbum.ts b/api/services/bandcamp-tralbum.ts new file mode 100644 index 0000000..2829e8a --- /dev/null +++ b/api/services/bandcamp-tralbum.ts @@ -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 = { + 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; + 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; + const file = (t.file ?? {}) as Record; + 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; + 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; + } +} diff --git a/index.html b/index.html index 06a5e43..cba95f6 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@ + __SITE_NAME__ diff --git a/src/App.css b/src/App.css index ba4d1c5..0d1edbb 100644 --- a/src/App.css +++ b/src/App.css @@ -832,6 +832,37 @@ transition: width 0.1s linear; } +/* Audio whose peaks can't be decoded (cross-origin streams). Boxed to the + waveform's height so the player doesn't resize between item kinds, with the + bar itself centred inside. */ +.audio-player-track--stream { + height: 48px; + background: none; + display: flex; + align-items: center; +} +.audio-player-track--stream::before { + content: ""; + position: absolute; + inset: 50% 0 auto 0; + transform: translateY(-50%); + height: 6px; + border-radius: 3px; + background: color-mix( + in srgb, + var(--color-accent) 12%, + var(--color-border) 88% + ); +} +.audio-player-track--stream .audio-player-fill { + top: 50%; + bottom: auto; + transform: translateY(-50%); + height: 6px; + border-radius: 3px; + z-index: 1; +} + .audio-player-track--volume { flex: 1 1 100px; max-width: 120px; @@ -1063,6 +1094,105 @@ a.global-player-title:hover { .global-player.global-player--bandcamp { max-width: 600px; } + +/* ── Global player: queue ── */ + +/* The title/artist pair takes the space the bare title used to claim. */ +.global-player-heading { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} +.global-player-heading .global-player-title { + flex: none; +} +.global-player-subtitle { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.global-player-artwork { + width: 36px; + height: 36px; + flex: none; + object-fit: cover; + border-radius: 4px; +} +.global-player-transport { + display: flex; + align-items: center; + gap: 0.25rem; + flex: none; +} +.global-player-position { + font-size: 0.75rem; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.global-player-transport .btn--ghost:disabled { + opacity: 0.35; + cursor: default; +} +.global-player-status { + margin: 0; + padding: 0 1rem 0.5rem; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.global-player-tracks { + list-style: none; + margin: 0; + padding: 0 0.5rem 0.5rem; + max-height: 40vh; + overflow-y: auto; +} +.global-player-track > button { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.35rem 0.5rem; + background: none; + border: none; + border-left: 3px solid transparent; + color: inherit; + font: inherit; + font-size: 0.8rem; + text-align: left; + cursor: pointer; +} +.global-player-track > button:hover { + background: var(--color-surface-alt, rgba(127, 127, 127, 0.12)); +} +/* An accent border rather than a filled pill, so no theme has to un-round it. */ +.global-player-track.is-current > button { + border-left-color: var(--color-accent); + font-weight: 600; +} +.global-player-track-num { + flex: none; + min-width: 1.5em; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} +.global-player-track-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.global-player-track-dur { + flex: none; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} .feed-loading-more { text-align: center; padding: 1rem; diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 3ec55cc..bc405b9 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -4,7 +4,7 @@ import { formatBytes } from "../utils/format.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx"; -import { PlayerContext } from "../contexts/PlayerContext.ts"; +import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts"; import { BAR_GAP, BAR_W, @@ -20,11 +20,39 @@ interface FilePreviewProps { global?: boolean; } +/** + * Build the global-player item for an uploaded file, so every entry point + * (compact cards, the detail page, the waveform) puts the same title, artwork + * and subtitle in the player header. + * + * Artwork is only claimed when one actually exists: videos get their generated + * still and any dump can carry an uploaded thumbnail, but a bare audio file has + * neither, and pointing at the endpoint anyway would just render a placeholder. + */ +function filePlayerItem( + dump: Dump, + fileUrl: string, + mime: string, + token?: string | null, +): PlayerItem { + const hasArtwork = !!dump.thumbnailMime || mime.startsWith("video/"); + return { + kind: "file", + fileUrl, + mimeType: mime, + title: dump.title, + dumpHref: dumpUrl(dump), + artworkUrl: hasArtwork ? dumpThumbnailUrl(dump, token) : undefined, + subtitle: dump.fileName, + }; +} + // Waveform preview for the dump detail page — routes to global player, // reflects live play state and position from PlayerContext. function AudioFilePreview( { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, ) { + const { token } = useAuth(); const { current, playing, currentTime, duration, play, togglePlay, seekTo } = useContext(PlayerContext); const [peaks, setPeaks] = useState(null); @@ -45,7 +73,7 @@ function AudioFilePreview( const handlePlayBtn = () => { if (isActive) togglePlay(); - else play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); + else play(filePlayerItem(dump, fileUrl, mime, token)); }; const handleWaveformClick = (e: React.MouseEvent) => { @@ -59,7 +87,7 @@ function AudioFilePreview( } else { // Start playing and seek once it loads — seekTo after play() is a no-op // until MediaPlayer mounts; the fraction is best-effort on first click - play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); + play(filePlayerItem(dump, fileUrl, mime, token)); } }; @@ -178,7 +206,7 @@ export default function FilePreview( onClick={(e) => { e.preventDefault(); e.stopPropagation(); - play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); + play(filePlayerItem(dump, fileUrl, mime, token)); }} > @@ -196,7 +224,7 @@ export default function FilePreview( onClick={(e) => { e.preventDefault(); e.stopPropagation(); - play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); + play(filePlayerItem(dump, fileUrl, mime, token)); }} > {thumbOverride @@ -230,12 +258,9 @@ export default function FilePreview( type="button" className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`} onClick={() => - videoActive ? togglePlay() : play({ - kind: "file", - fileUrl, - mimeType: mime, - title: dump.title, - })} + videoActive + ? togglePlay() + : play(filePlayerItem(dump, fileUrl, mime, token))} >