Compare commits
8 Commits
0e138be6df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8364e24d | ||
|
|
d76154d15d | ||
|
|
79b7adce8f | ||
|
|
2606ef4ccc | ||
|
|
eb323a8ba8 | ||
|
|
89314b1b6e | ||
|
|
7303509b6f | ||
|
|
1cb904d2cf |
@@ -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";
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { up as up0007PasswordResetTokens } from "./migrations/0007_password_rese
|
|||||||
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
||||||
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
||||||
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
||||||
|
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
|
||||||
|
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
|
||||||
|
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,6 +32,12 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||||
{ name: "0009_chat_reply", up: up0009ChatReply },
|
{ name: "0009_chat_reply", up: up0009ChatReply },
|
||||||
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
|
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
|
||||||
|
{ name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
|
||||||
|
{
|
||||||
|
name: "0012_fix_favicon_reclassification",
|
||||||
|
up: up0012FixFaviconReclassification,
|
||||||
|
},
|
||||||
|
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: DatabaseSync): void {
|
export function runMigrations(db: DatabaseSync): void {
|
||||||
|
|||||||
74
api/db/migrations/0011_split_favicon_thumbnail.ts
Normal file
74
api/db/migrations/0011_split_favicon_thumbnail.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
|
||||||
|
// `faviconUrl` field.
|
||||||
|
//
|
||||||
|
// The extraction cascade used to end at the page's icon and then at a guessed
|
||||||
|
// `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
|
||||||
|
// held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
|
||||||
|
// box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
|
||||||
|
// means "no artwork", which is what lets the frontend draw a placeholder.
|
||||||
|
// This migration gives rows written before that change the same meaning.
|
||||||
|
//
|
||||||
|
// Purely local — it classifies the already-stored URL and makes no network
|
||||||
|
// calls, so `accentColor` is deliberately not backfilled: the frontend derives
|
||||||
|
// a stable hue from the hostname whenever one is missing, and
|
||||||
|
// `refreshDumpMetadata` fetches the real color on demand.
|
||||||
|
//
|
||||||
|
// Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
|
||||||
|
// database built from schema.sql is a no-op.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored thumbnail URL is really a site icon.
|
||||||
|
*
|
||||||
|
* Deliberately loose. A false positive (a genuine cover image living under
|
||||||
|
* `/assets/icons/`) renders contained on a tinted field instead of
|
||||||
|
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
|
||||||
|
* extra rules.
|
||||||
|
*/
|
||||||
|
function isIconUrl(raw: string): boolean {
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = new URL(raw).pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) ||
|
||||||
|
/\.(ico|svg)$/i.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function up(db: DatabaseSync): void {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, rich_content FROM dumps
|
||||||
|
WHERE kind = 'url' AND rich_content IS NOT NULL;`,
|
||||||
|
).all() as { id: string; rich_content: string }[];
|
||||||
|
|
||||||
|
const update = db.prepare(
|
||||||
|
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
let rich: { thumbnailUrl?: string; faviconUrl?: string };
|
||||||
|
try {
|
||||||
|
rich = JSON.parse(row.rich_content);
|
||||||
|
} catch {
|
||||||
|
continue; // malformed payload — leave it untouched
|
||||||
|
}
|
||||||
|
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
|
||||||
|
if (!isIconUrl(rich.thumbnailUrl)) continue;
|
||||||
|
|
||||||
|
const { thumbnailUrl: _dropped, ...rest } = rich;
|
||||||
|
update.run(
|
||||||
|
JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }),
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patched > 0) {
|
||||||
|
console.log(
|
||||||
|
`[migrate] 0011: reclassified ${patched} favicon thumbnail(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
api/db/migrations/0012_fix_favicon_reclassification.ts
Normal file
100
api/db/migrations/0012_fix_favicon_reclassification.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
// Repairs 0011, which was too eager about what counts as a site icon.
|
||||||
|
//
|
||||||
|
// 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image`
|
||||||
|
// like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was
|
||||||
|
// moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted
|
||||||
|
// the dump from an image card to a pull-quote in the journal mosaic. A blanket
|
||||||
|
// `.svg` match had the same effect on real artwork.
|
||||||
|
//
|
||||||
|
// `isIconUrl` here looks at the *filename* rather than the whole path: an icon
|
||||||
|
// is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/`
|
||||||
|
// directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and
|
||||||
|
// catches `icon_SEARCH.png`, which 0011 missed.
|
||||||
|
//
|
||||||
|
// Applied in both directions, and deliberately narrow when reversing: a value
|
||||||
|
// is only promoted back to `thumbnailUrl` if it matches 0011's rule but not
|
||||||
|
// this one, so rows written by a normal fetch aren't disturbed.
|
||||||
|
|
||||||
|
/** 0011's rule, kept verbatim so its false positives can be identified. */
|
||||||
|
function wasIconUrl0011(raw: string): boolean {
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = new URL(raw).pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) ||
|
||||||
|
/\.(ico|svg)$/i.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a URL names a site icon, judged on its filename and directory. */
|
||||||
|
function isIconUrl(raw: string): boolean {
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = new URL(raw).pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const filename = segments.pop() ?? "";
|
||||||
|
if (/\.ico$/i.test(filename)) return true;
|
||||||
|
if (segments.some((s) => /^(favicons?|icons?)$/i.test(s))) return true;
|
||||||
|
const basename = filename.replace(/\.[^.]+$/, "");
|
||||||
|
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function up(db: DatabaseSync): void {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, rich_content FROM dumps
|
||||||
|
WHERE kind = 'url' AND rich_content IS NOT NULL;`,
|
||||||
|
).all() as { id: string; rich_content: string }[];
|
||||||
|
|
||||||
|
const update = db.prepare(
|
||||||
|
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let restored = 0;
|
||||||
|
let reclassified = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
let rich: { thumbnailUrl?: string; faviconUrl?: string };
|
||||||
|
try {
|
||||||
|
rich = JSON.parse(row.rich_content);
|
||||||
|
} catch {
|
||||||
|
continue; // malformed payload — leave it untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artwork 0011 mistook for an icon: put it back.
|
||||||
|
if (
|
||||||
|
rich.faviconUrl && !rich.thumbnailUrl &&
|
||||||
|
wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl)
|
||||||
|
) {
|
||||||
|
const { faviconUrl: _dropped, ...rest } = rich;
|
||||||
|
update.run(
|
||||||
|
JSON.stringify({ ...rest, thumbnailUrl: rich.faviconUrl }),
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
restored++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An icon 0011's rule didn't recognize: move it now.
|
||||||
|
if (
|
||||||
|
rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl)
|
||||||
|
) {
|
||||||
|
const { thumbnailUrl: _dropped, ...rest } = rich;
|
||||||
|
update.run(
|
||||||
|
JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }),
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
reclassified++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restored > 0 || reclassified > 0) {
|
||||||
|
console.log(
|
||||||
|
`[migrate] 0012: restored ${restored} thumbnail(s), reclassified ${reclassified} icon(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
api/db/migrations/0013_dump_url_canonical.ts
Normal file
119
api/db/migrations/0013_dump_url_canonical.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
// Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
|
||||||
|
// check on the create form — and backfills it for every existing URL dump.
|
||||||
|
//
|
||||||
|
// Purely local: it re-derives the key from the URL already stored on each row
|
||||||
|
// and makes no network calls.
|
||||||
|
//
|
||||||
|
// The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts`
|
||||||
|
// as it stood when this migration shipped, not an import of it. A migration
|
||||||
|
// runs exactly once per database, so importing the live version would mean two
|
||||||
|
// databases migrating at different times end up with keys computed under
|
||||||
|
// different rules. Improving the shared canonicalizer therefore calls for a new
|
||||||
|
// backfill migration rather than an edit here.
|
||||||
|
//
|
||||||
|
// Idempotent: the column is only added when missing and only rows whose key is
|
||||||
|
// still NULL are touched, so a fresh database built from schema.sql is a no-op.
|
||||||
|
|
||||||
|
const TRACKING_PARAMS = new Set([
|
||||||
|
"fbclid",
|
||||||
|
"gclid",
|
||||||
|
"dclid",
|
||||||
|
"msclkid",
|
||||||
|
"twclid",
|
||||||
|
"yclid",
|
||||||
|
"mc_cid",
|
||||||
|
"mc_eid",
|
||||||
|
"igshid",
|
||||||
|
"igsh",
|
||||||
|
"si",
|
||||||
|
"spm",
|
||||||
|
"ref_src",
|
||||||
|
"ref_url",
|
||||||
|
"_ga",
|
||||||
|
"_gl",
|
||||||
|
"__twitter_impression",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTrackingParam(key: string): boolean {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_HOSTS = new Set([
|
||||||
|
"youtube.com",
|
||||||
|
"m.youtube.com",
|
||||||
|
"music.youtube.com",
|
||||||
|
"youtube-nocookie.com",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function youtubeVideoId(
|
||||||
|
host: string,
|
||||||
|
pathname: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): string | null {
|
||||||
|
if (host === "youtu.be") return pathname.split("/")[1] || null;
|
||||||
|
if (!YOUTUBE_HOSTS.has(host)) return null;
|
||||||
|
if (pathname === "/watch") return params.get("v");
|
||||||
|
if (/^\/(embed|shorts|live)\//.test(pathname)) {
|
||||||
|
return pathname.split("/")[2] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeUrl(raw: string): string | null {
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
|
||||||
|
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
|
||||||
|
if (videoId) return `https://youtube.com/watch?v=${videoId}`;
|
||||||
|
|
||||||
|
const listId = YOUTUBE_HOSTS.has(host) && u.pathname === "/playlist"
|
||||||
|
? u.searchParams.get("list")
|
||||||
|
: null;
|
||||||
|
if (listId) return `https://youtube.com/playlist?list=${listId}`;
|
||||||
|
|
||||||
|
const path = u.pathname.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const params = [...u.searchParams.entries()]
|
||||||
|
.filter(([key]) => !isTrackingParam(key))
|
||||||
|
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
|
||||||
|
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
|
||||||
|
|
||||||
|
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function up(db: DatabaseSync): void {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
if (!columns.some((c) => c.name === "url_canonical")) {
|
||||||
|
db.exec(`ALTER TABLE dumps ADD COLUMN url_canonical TEXT;`);
|
||||||
|
}
|
||||||
|
db.exec(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dumps_url_canonical ON dumps(url_canonical);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, url FROM dumps WHERE url IS NOT NULL AND url_canonical IS NULL;`,
|
||||||
|
).all() as { id: string; url: string }[];
|
||||||
|
|
||||||
|
const update = db.prepare(
|
||||||
|
`UPDATE dumps SET url_canonical = ? WHERE id = ?;`,
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
const canonical = canonicalizeUrl(row.url);
|
||||||
|
if (canonical) update.run(canonical, row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ CREATE TABLE dumps (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
|
-- Lossy lookup key for "has this been dumped already?" — see api/lib/canonical-url.ts
|
||||||
|
url_canonical TEXT,
|
||||||
slug TEXT,
|
slug TEXT,
|
||||||
rich_content TEXT,
|
rich_content TEXT,
|
||||||
file_name TEXT,
|
file_name TEXT,
|
||||||
@@ -117,6 +119,7 @@ CREATE TABLE dump_backlinks (
|
|||||||
|
|
||||||
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
||||||
CREATE INDEX idx_dumps_url ON dumps(url);
|
CREATE INDEX idx_dumps_url ON dumps(url);
|
||||||
|
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
|
||||||
CREATE INDEX idx_votes_user ON votes(user_id);
|
CREATE INDEX idx_votes_user ON votes(user_id);
|
||||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||||
|
|||||||
104
api/lib/canonical-url.ts
Normal file
104
api/lib/canonical-url.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Canonical form of a URL, used *only* to recognise that two links point at the
|
||||||
|
* same thing — the duplicate check the create form runs while it fetches a
|
||||||
|
* preview. It is a lookup key, never something we display or fetch: `dumps.url`
|
||||||
|
* keeps the exact string the poster submitted.
|
||||||
|
*
|
||||||
|
* Because it is only ever compared against other canonical forms, it may be
|
||||||
|
* lossy in ways a real URL never could be — it forces `https`, drops `www.`,
|
||||||
|
* and throws away share/tracking parameters and timestamps, so
|
||||||
|
* `http://www.example.com/a/?utm_source=x` and `https://example.com/a` collapse
|
||||||
|
* to one key.
|
||||||
|
*
|
||||||
|
* Stored in `dumps.url_canonical`. Migration 0013 carries a frozen copy of this
|
||||||
|
* logic to backfill existing rows; changing the rules here therefore needs a
|
||||||
|
* new backfill migration, or old rows keep keys computed under the old rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRACKING_PARAMS = new Set([
|
||||||
|
"fbclid",
|
||||||
|
"gclid",
|
||||||
|
"dclid",
|
||||||
|
"msclkid",
|
||||||
|
"twclid",
|
||||||
|
"yclid",
|
||||||
|
"mc_cid",
|
||||||
|
"mc_eid",
|
||||||
|
"igshid",
|
||||||
|
"igsh",
|
||||||
|
"si",
|
||||||
|
"spm",
|
||||||
|
"ref_src",
|
||||||
|
"ref_url",
|
||||||
|
"_ga",
|
||||||
|
"_gl",
|
||||||
|
"__twitter_impression",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTrackingParam(key: string): boolean {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts already stripped of a leading "www.".
|
||||||
|
const YOUTUBE_HOSTS = new Set([
|
||||||
|
"youtube.com",
|
||||||
|
"m.youtube.com",
|
||||||
|
"music.youtube.com",
|
||||||
|
"youtube-nocookie.com",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The video a YouTube URL points at, in any of the shapes people paste
|
||||||
|
* (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`).
|
||||||
|
* Timestamps are deliberately ignored: the same video linked at 2:30 is still
|
||||||
|
* the same video for duplicate purposes.
|
||||||
|
*/
|
||||||
|
function youtubeVideoId(
|
||||||
|
host: string,
|
||||||
|
pathname: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): string | null {
|
||||||
|
if (host === "youtu.be") return pathname.split("/")[1] || null;
|
||||||
|
if (!YOUTUBE_HOSTS.has(host)) return null;
|
||||||
|
if (pathname === "/watch") return params.get("v");
|
||||||
|
if (/^\/(embed|shorts|live)\//.test(pathname)) {
|
||||||
|
return pathname.split("/")[2] || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeUrl(raw: string): string | null {
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||||
|
|
||||||
|
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
||||||
|
if (!host) return null;
|
||||||
|
|
||||||
|
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
|
||||||
|
if (videoId) return `https://youtube.com/watch?v=${videoId}`;
|
||||||
|
|
||||||
|
const listId = YOUTUBE_HOSTS.has(host) && u.pathname === "/playlist"
|
||||||
|
? u.searchParams.get("list")
|
||||||
|
: null;
|
||||||
|
if (listId) return `https://youtube.com/playlist?list=${listId}`;
|
||||||
|
|
||||||
|
// A single trailing slash is never meaningful; "/" itself becomes "".
|
||||||
|
const path = u.pathname.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const params = [...u.searchParams.entries()]
|
||||||
|
.filter(([key]) => !isTrackingParam(key))
|
||||||
|
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
|
||||||
|
// A bare "#section" anchor points into the same page, so it is dropped —
|
||||||
|
// but "#/route" and "#!/route" address distinct pages of a hash-routed app.
|
||||||
|
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
|
||||||
|
|
||||||
|
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -11,6 +11,8 @@ interface OGMeta {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
/** True only for real artwork — a square site icon must not claim a wide card. */
|
||||||
|
imageIsWide?: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +25,9 @@ function escapeAttr(s: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTags(meta: OGMeta): string {
|
function buildTags(meta: OGMeta): string {
|
||||||
const card = meta.imageUrl ? "summary_large_image" : "summary";
|
const card = meta.imageUrl && meta.imageIsWide
|
||||||
|
? "summary_large_image"
|
||||||
|
: "summary";
|
||||||
const tags = [
|
const tags = [
|
||||||
`<title>${escapeAttr(meta.title)}</title>`,
|
`<title>${escapeAttr(meta.title)}</title>`,
|
||||||
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
||||||
@@ -65,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;
|
||||||
@@ -105,10 +110,14 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
|||||||
} else if (dump.richContent?.thumbnailUrl) {
|
} else if (dump.richContent?.thumbnailUrl) {
|
||||||
imageUrl = dump.richContent.thumbnailUrl;
|
imageUrl = dump.richContent.thumbnailUrl;
|
||||||
}
|
}
|
||||||
|
// No artwork: fall back to our own site icon rather than the target's
|
||||||
|
// favicon, which is typically too small to survive a social card (and may
|
||||||
|
// be hotlink-protected).
|
||||||
meta = {
|
meta = {
|
||||||
title: dump.title,
|
title: dump.title,
|
||||||
description: dump.comment,
|
description: dump.comment,
|
||||||
imageUrl,
|
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||||
|
imageIsWide: !!imageUrl,
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
};
|
};
|
||||||
} catch { /* not found or private — serve default */ }
|
} catch { /* not found or private — serve default */ }
|
||||||
@@ -121,7 +130,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
|||||||
meta = {
|
meta = {
|
||||||
title: user.username,
|
title: user.username,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
imageUrl,
|
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
};
|
};
|
||||||
} catch { /* not found */ }
|
} catch { /* not found */ }
|
||||||
@@ -135,7 +144,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
|||||||
meta = {
|
meta = {
|
||||||
title: playlist.title,
|
title: playlist.title,
|
||||||
description: playlist.description,
|
description: playlist.description,
|
||||||
imageUrl,
|
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ export interface RichContent {
|
|||||||
siteName?: string;
|
siteName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/**
|
||||||
|
* A real preview image (og:image, a large content image, a video still).
|
||||||
|
* Never a favicon — an absent value means "this page offers no artwork",
|
||||||
|
* which is what makes the generated placeholder possible.
|
||||||
|
*/
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
/** The page's own icon, used as the placeholder's glyph. */
|
||||||
|
faviconUrl?: string;
|
||||||
|
/** The page's declared brand color, normalized to `#rrggbb`. */
|
||||||
|
accentColor?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
embedUrl?: string;
|
embedUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -470,11 +479,27 @@ function isStringArray(value: unknown): value is string[] {
|
|||||||
|
|
||||||
export interface CreateUrlDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||||
|
title?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
categoryIds?: string[];
|
categoryIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An existing dump pointing at the same URL, as shown by the create form's
|
||||||
|
* duplicate hint. Display-only projection — see `findDumpsByUrl`.
|
||||||
|
*/
|
||||||
|
export interface DumpUrlMatch {
|
||||||
|
id: string;
|
||||||
|
slug?: string;
|
||||||
|
title: string;
|
||||||
|
username: string;
|
||||||
|
createdAt: Date;
|
||||||
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCreateUrlDumpRequest(
|
export function isCreateUrlDumpRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is CreateUrlDumpRequest {
|
): obj is CreateUrlDumpRequest {
|
||||||
@@ -490,6 +515,13 @@ export function isCreateUrlDumpRequest(
|
|||||||
typeof o.comment === "string" &&
|
typeof o.comment === "string" &&
|
||||||
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
||||||
) return false;
|
) return false;
|
||||||
|
if ("title" in o && typeof o.title !== "string" && o.title !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof o.title === "string" &&
|
||||||
|
(o.title as string).length > VALIDATION.DUMP_TITLE_MAX
|
||||||
|
) return false;
|
||||||
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
|
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
|
||||||
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
|
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
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;
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Dump,
|
type Dump,
|
||||||
|
type DumpUrlMatch,
|
||||||
isCreateUrlDumpRequest,
|
isCreateUrlDumpRequest,
|
||||||
isUpdateDumpRequest,
|
isUpdateDumpRequest,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
createFileDump,
|
createFileDump,
|
||||||
createUrlDump,
|
createUrlDump,
|
||||||
deleteDump,
|
deleteDump,
|
||||||
|
findDumpsByUrl,
|
||||||
getDump,
|
getDump,
|
||||||
listDumps,
|
listDumps,
|
||||||
refreshDumpMetadata,
|
refreshDumpMetadata,
|
||||||
@@ -100,6 +102,17 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Registered ahead of "/:dumpId" so the literal path wins over the parameter.
|
||||||
|
router.get("/by-url", async (ctx) => {
|
||||||
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||||
|
const responseBody: APIResponse<DumpUrlMatch[]> = {
|
||||||
|
success: true,
|
||||||
|
data: findDumpsByUrl(url, requestingUserId),
|
||||||
|
};
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/:dumpId", async (ctx) => {
|
router.get("/:dumpId", async (ctx) => {
|
||||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import {
|
import {
|
||||||
fetchRichContent,
|
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
|
tryFetchRichContent,
|
||||||
} from "../services/rich-content-service.ts";
|
} from "../services/rich-content-service.ts";
|
||||||
import { APIErrorCode } from "../model/interfaces.ts";
|
import { APIErrorCode } from "../model/interfaces.ts";
|
||||||
|
|
||||||
@@ -18,8 +18,15 @@ previewRouter.get("/api/preview", async (ctx) => {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await fetchRichContent(url);
|
// `reached` is reported separately because a failed fetch still yields a
|
||||||
ctx.response.body = { success: true, data: data ?? null };
|
// usable stub (hostname only). Without the flag the create form cannot tell
|
||||||
|
// "this page has no preview" from "this link is dead", and shows the same
|
||||||
|
// bare card for both.
|
||||||
|
const { ok, content } = await tryFetchRichContent(url);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: { reached: ok, richContent: content ?? null },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
138
api/services/bandcamp-stream-service.ts
Normal file
138
api/services/bandcamp-stream-service.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every path out of here that doesn't read the body has to release it, or the
|
||||||
|
// connection stays open until GC. This endpoint is unauthenticated: a caller
|
||||||
|
// hammering random /track/<slug> URLs would otherwise leak one body a request.
|
||||||
|
if (!res.ok) {
|
||||||
|
await res.body?.cancel();
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
502,
|
||||||
|
`Bandcamp returned ${res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
|
||||||
|
await res.body?.cancel();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
type CreateUrlDumpRequest,
|
type CreateUrlDumpRequest,
|
||||||
type Dump,
|
type Dump,
|
||||||
|
type DumpUrlMatch,
|
||||||
type UpdateDumpRequest,
|
type UpdateDumpRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,11 @@ import {
|
|||||||
dumpRowToApi,
|
dumpRowToApi,
|
||||||
isDumpRow,
|
isDumpRow,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
import {
|
||||||
|
fetchRichContent,
|
||||||
|
isValidHttpUrl,
|
||||||
|
tryFetchRichContent,
|
||||||
|
} from "./rich-content-service.ts";
|
||||||
import {
|
import {
|
||||||
broadcastDumpDeleted,
|
broadcastDumpDeleted,
|
||||||
broadcastDumpUpdated,
|
broadcastDumpUpdated,
|
||||||
@@ -24,6 +29,7 @@ import {
|
|||||||
notifyUserFollowersNewDump,
|
notifyUserFollowersNewDump,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||||
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||||
import {
|
import {
|
||||||
DUMP_ALLOWED_MIME_PREFIXES,
|
DUMP_ALLOWED_MIME_PREFIXES,
|
||||||
DUMP_ALLOWED_MIME_TYPES,
|
DUMP_ALLOWED_MIME_TYPES,
|
||||||
@@ -59,13 +65,16 @@ export async function createUrlDump(
|
|||||||
const dumpId = crypto.randomUUID();
|
const dumpId = crypto.randomUUID();
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
const richContent = await fetchRichContent(request.url);
|
const richContent = await fetchRichContent(request.url);
|
||||||
const title = richContent?.title ?? titleFromUrl(request.url);
|
// A title typed on the create form wins over the one scraped from the page:
|
||||||
|
// the poster has seen the preview and is correcting it.
|
||||||
|
const title = request.title?.trim() || richContent?.title ||
|
||||||
|
titleFromUrl(request.url);
|
||||||
const isPrivate = request.isPrivate ?? false;
|
const isPrivate = request.isPrivate ?? false;
|
||||||
const slug = makeSlug(title, dumpId);
|
const slug = makeSlug(title, dumpId);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private)
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
"url",
|
"url",
|
||||||
@@ -75,6 +84,7 @@ export async function createUrlDump(
|
|||||||
userId,
|
userId,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
request.url,
|
request.url,
|
||||||
|
canonicalizeUrl(request.url),
|
||||||
richContent ? JSON.stringify(richContent) : null,
|
richContent ? JSON.stringify(richContent) : null,
|
||||||
isPrivate ? 1 : 0,
|
isPrivate ? 1 : 0,
|
||||||
);
|
);
|
||||||
@@ -290,6 +300,54 @@ export function listDumps(
|
|||||||
return { items, total: totalRow?.count ?? 0 };
|
return { items, total: totalRow?.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dumps that already point at the same thing as `url`, newest first.
|
||||||
|
*
|
||||||
|
* Matched on the canonical key rather than the raw URL, so a link reposted with
|
||||||
|
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
|
||||||
|
* different share shape still counts as the same dump. Private dumps are
|
||||||
|
* visible only to their owner, exactly as everywhere else.
|
||||||
|
*
|
||||||
|
* Returns a small display-only projection: this feeds a hint on the create
|
||||||
|
* form, not a feed.
|
||||||
|
*/
|
||||||
|
export function findDumpsByUrl(
|
||||||
|
url: string,
|
||||||
|
requestingUserId?: string,
|
||||||
|
limit = 3,
|
||||||
|
): DumpUrlMatch[] {
|
||||||
|
const canonical = canonicalizeUrl(url);
|
||||||
|
if (!canonical) return [];
|
||||||
|
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT d.id, d.slug, d.title, d.created_at, d.vote_count, u.username,
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
|
||||||
|
FROM dumps d
|
||||||
|
JOIN users u ON u.id = d.user_id
|
||||||
|
WHERE d.url_canonical = ? AND (d.is_private = 0 OR d.user_id = ?)
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT ?;`,
|
||||||
|
).all(canonical, requestingUserId ?? null, limit) as {
|
||||||
|
id: string;
|
||||||
|
slug: string | null;
|
||||||
|
title: string;
|
||||||
|
created_at: string;
|
||||||
|
vote_count: number;
|
||||||
|
username: string;
|
||||||
|
comment_count: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug ?? undefined,
|
||||||
|
title: row.title,
|
||||||
|
username: row.username,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
voteCount: row.vote_count,
|
||||||
|
commentCount: row.comment_count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateDump(
|
export async function updateDump(
|
||||||
dumpId: string,
|
dumpId: string,
|
||||||
request: UpdateDumpRequest,
|
request: UpdateDumpRequest,
|
||||||
@@ -383,12 +441,13 @@ export async function updateDump(
|
|||||||
|
|
||||||
const row = dumpApiToRow(updatedDump);
|
const row = dumpApiToRow(updatedDump);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(
|
).run(
|
||||||
row.title,
|
row.title,
|
||||||
row.slug,
|
row.slug,
|
||||||
row.comment,
|
row.comment,
|
||||||
row.url,
|
row.url,
|
||||||
|
row.url ? canonicalizeUrl(row.url) : null,
|
||||||
row.rich_content,
|
row.rich_content,
|
||||||
row.is_private,
|
row.is_private,
|
||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
@@ -596,7 +655,19 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const richContent = await fetchRichContent(dump.url);
|
// A failed fetch yields a stub with no title and no thumbnail. Writing that
|
||||||
|
// over a dump that already has good metadata would silently destroy it and
|
||||||
|
// rename the dump to its bare hostname, so bail out instead — the caller
|
||||||
|
// surfaces the error and nothing is lost.
|
||||||
|
const { ok, content: richContent } = await tryFetchRichContent(dump.url);
|
||||||
|
if (!ok) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
502,
|
||||||
|
"Could not reach the page to refresh its metadata",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const title = richContent?.title ?? titleFromUrl(dump.url);
|
const title = richContent?.title ?? titleFromUrl(dump.url);
|
||||||
|
|
||||||
const updatedDump: Dump = { ...dump, title, richContent };
|
const updatedDump: Dump = { ...dump, title, richContent };
|
||||||
@@ -605,6 +676,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
|||||||
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
||||||
).run(row.title, row.rich_content, row.id);
|
).run(row.title, row.rich_content, row.id);
|
||||||
|
|
||||||
|
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||||
|
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import {
|
|||||||
extractFirstContentImage,
|
extractFirstContentImage,
|
||||||
extractJsonLd,
|
extractJsonLd,
|
||||||
extractLargeImage,
|
extractLargeImage,
|
||||||
|
extractMaskIconColor,
|
||||||
extractMetaName,
|
extractMetaName,
|
||||||
extractOgTag,
|
extractOgTag,
|
||||||
extractPageTitle,
|
extractPageTitle,
|
||||||
|
extractThemeColor,
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
|
normalizeCssColor,
|
||||||
} from "../rich-content-service.ts";
|
} from "../rich-content-service.ts";
|
||||||
|
|
||||||
export const genericProvider: RichContentProvider = {
|
export const genericProvider: RichContentProvider = {
|
||||||
@@ -59,21 +62,37 @@ export const genericProvider: RichContentProvider = {
|
|||||||
ld.description ??
|
ld.description ??
|
||||||
extractMetaName(html, "description");
|
extractMetaName(html, "description");
|
||||||
|
|
||||||
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> → first content <img> → best icon → /favicon.ico
|
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> →
|
||||||
|
// first content <img>. The chain deliberately stops there: a favicon is not
|
||||||
|
// artwork, and pretending otherwise means every art-less page gets a 16×16
|
||||||
|
// icon cover-cropped into a 128×72 box. No match here means "no artwork",
|
||||||
|
// and the frontend draws a generated placeholder instead.
|
||||||
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
|
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
|
||||||
extractMetaName(html, "twitter:image") ??
|
extractMetaName(html, "twitter:image") ??
|
||||||
ld.thumbnailUrl ??
|
ld.thumbnailUrl ??
|
||||||
extractLargeImage(html, url) ??
|
extractLargeImage(html, url) ??
|
||||||
extractFirstContentImage(html, url) ??
|
extractFirstContentImage(html, url);
|
||||||
extractBestIcon(html, url) ??
|
|
||||||
|
// Icon and brand color are independent facts about the page, so they're
|
||||||
|
// collected whether or not there's artwork. `/favicon.ico` is a guess; when
|
||||||
|
// it 404s the placeholder falls back to the site's initial.
|
||||||
|
const faviconUrl = extractBestIcon(html, url) ??
|
||||||
`${new URL(url).origin}/favicon.ico`;
|
`${new URL(url).origin}/favicon.ico`;
|
||||||
|
|
||||||
|
const accentColor = normalizeCssColor(
|
||||||
|
extractThemeColor(html) ??
|
||||||
|
extractMetaName(html, "msapplication-TileColor") ??
|
||||||
|
extractMaskIconColor(html),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
|
faviconUrl,
|
||||||
|
accentColor,
|
||||||
siteName,
|
siteName,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import type { RichContentProvider } from "../rich-content-service.ts";
|
|||||||
import { getDump } from "../dump-service.ts";
|
import { getDump } from "../dump-service.ts";
|
||||||
import { getUserByUsername } from "../user-service.ts";
|
import { getUserByUsername } from "../user-service.ts";
|
||||||
import { getPlaylistById } from "../playlist-service.ts";
|
import { getPlaylistById } from "../playlist-service.ts";
|
||||||
import { PUBLIC_URL } from "../../config.ts";
|
import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
|
||||||
|
|
||||||
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
const DUMP_RE = /^\/dumps\/([^/]+)$/;
|
||||||
const USER_RE = /^\/users\/([^/]+)$/;
|
const USER_RE = /^\/users\/([^/]+)$/;
|
||||||
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
|
||||||
|
|
||||||
|
// Internal links carry gerbeur's own branding, so an art-less one still gets a
|
||||||
|
// recognizable placeholder instead of a generic tint.
|
||||||
|
const SELF_BRANDING = {
|
||||||
|
siteName: "gerbeur",
|
||||||
|
faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`,
|
||||||
|
accentColor: THEME_COLOR,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const selfProvider: RichContentProvider = {
|
export const selfProvider: RichContentProvider = {
|
||||||
name: "self",
|
name: "self",
|
||||||
|
|
||||||
@@ -38,7 +46,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: "gerbeur",
|
...SELF_BRANDING,
|
||||||
title: dump.title,
|
title: dump.title,
|
||||||
description: dump.comment,
|
description: dump.comment,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
@@ -54,7 +62,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: "gerbeur",
|
...SELF_BRANDING,
|
||||||
title: user.username,
|
title: user.username,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
@@ -70,7 +78,7 @@ export const selfProvider: RichContentProvider = {
|
|||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
type: "generic",
|
type: "generic",
|
||||||
url,
|
url,
|
||||||
siteName: "gerbeur",
|
...SELF_BRANDING,
|
||||||
title: playlist.title,
|
title: playlist.title,
|
||||||
description: playlist.description,
|
description: playlist.description,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
|
|||||||
@@ -184,6 +184,117 @@ export function extractPageTitle(html: string): string | undefined {
|
|||||||
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
|
return match ? decodeHtmlEntities(match[1].trim()) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Brand color helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the page's `theme-color`.
|
||||||
|
*
|
||||||
|
* Sites routinely ship the tag twice, scoped by `media`, and often list the
|
||||||
|
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
|
||||||
|
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
|
||||||
|
*/
|
||||||
|
export function extractThemeColor(html: string): string | undefined {
|
||||||
|
const tagRe = /<meta\b[^>]*>/gi;
|
||||||
|
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
|
||||||
|
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
|
||||||
|
let unscoped: string | undefined;
|
||||||
|
let light: string | undefined;
|
||||||
|
let any: string | undefined;
|
||||||
|
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = tagRe.exec(html)) !== null) {
|
||||||
|
const tag = m[0];
|
||||||
|
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
|
||||||
|
const content = contentRe.exec(tag)?.[2];
|
||||||
|
if (!content) continue;
|
||||||
|
const media = mediaRe.exec(tag)?.[2];
|
||||||
|
if (!media) unscoped ??= content;
|
||||||
|
else if (/light/i.test(media)) light ??= content;
|
||||||
|
any ??= content;
|
||||||
|
}
|
||||||
|
return unscoped ?? light ?? any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
|
||||||
|
export function extractMaskIconColor(html: string): string | undefined {
|
||||||
|
const linkRe = /<link[^>]+>/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = linkRe.exec(html)) !== null) {
|
||||||
|
const tag = m[0];
|
||||||
|
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
|
||||||
|
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
|
||||||
|
if (color) return color;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handful of named colors that actually show up in `theme-color`. The full
|
||||||
|
// 148-name table isn't worth carrying for the long tail.
|
||||||
|
const NAMED_COLORS: Record<string, string> = {
|
||||||
|
black: "#000000",
|
||||||
|
white: "#ffffff",
|
||||||
|
red: "#ff0000",
|
||||||
|
green: "#008000",
|
||||||
|
blue: "#0000ff",
|
||||||
|
yellow: "#ffff00",
|
||||||
|
orange: "#ffa500",
|
||||||
|
purple: "#800080",
|
||||||
|
gray: "#808080",
|
||||||
|
grey: "#808080",
|
||||||
|
silver: "#c0c0c0",
|
||||||
|
maroon: "#800000",
|
||||||
|
navy: "#000080",
|
||||||
|
teal: "#008080",
|
||||||
|
olive: "#808000",
|
||||||
|
lime: "#00ff00",
|
||||||
|
aqua: "#00ffff",
|
||||||
|
cyan: "#00ffff",
|
||||||
|
fuchsia: "#ff00ff",
|
||||||
|
magenta: "#ff00ff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampByte(n: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
|
||||||
|
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
|
||||||
|
* only ever used as a tint over the app's own surface.
|
||||||
|
*/
|
||||||
|
export function normalizeCssColor(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
const named = NAMED_COLORS[value];
|
||||||
|
if (named) return named;
|
||||||
|
|
||||||
|
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
|
||||||
|
if (hex) {
|
||||||
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
|
const [r, g, b] = [...hex.slice(0, 3)];
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`;
|
||||||
|
}
|
||||||
|
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
|
||||||
|
if (rgb) {
|
||||||
|
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
|
||||||
|
if (parts.length !== 3) return undefined;
|
||||||
|
const bytes = parts.map((p) => {
|
||||||
|
const n = parseFloat(p);
|
||||||
|
if (!Number.isFinite(n)) return NaN;
|
||||||
|
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
|
||||||
|
});
|
||||||
|
if (bytes.some(Number.isNaN)) return undefined;
|
||||||
|
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
|
// ── JSON-LD helpers (file-private) ────────────────────────────────────────────
|
||||||
|
|
||||||
type JsonLdResult = {
|
type JsonLdResult = {
|
||||||
@@ -281,6 +392,10 @@ export function extractLargeImage(
|
|||||||
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
|
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
|
||||||
* them by declared size (largest wins), and return the best resolved URL.
|
* them by declared size (largest wins), and return the best resolved URL.
|
||||||
* Falls back to the first match when no `sizes` attribute is present.
|
* Falls back to the first match when no `sizes` attribute is present.
|
||||||
|
*
|
||||||
|
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
|
||||||
|
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
|
||||||
|
* whatever size the placeholder renders them at.
|
||||||
*/
|
*/
|
||||||
export function extractBestIcon(
|
export function extractBestIcon(
|
||||||
html: string,
|
html: string,
|
||||||
@@ -302,7 +417,13 @@ export function extractBestIcon(
|
|||||||
if (!href) continue;
|
if (!href) continue;
|
||||||
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
|
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
|
||||||
const sm = sizesStr.match(/(\d+)x(\d+)/i);
|
const sm = sizesStr.match(/(\d+)x(\d+)/i);
|
||||||
const area = sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0;
|
const isSvg = /\.svg(\?|$)/i.test(href) ||
|
||||||
|
/\btype=["']image\/svg\+xml["']/i.test(tag);
|
||||||
|
const area = isSvg
|
||||||
|
? Number.MAX_SAFE_INTEGER
|
||||||
|
: sm
|
||||||
|
? parseInt(sm[1]) * parseInt(sm[2])
|
||||||
|
: 0;
|
||||||
try {
|
try {
|
||||||
candidates.push({ href: new URL(href, baseUrl).toString(), area });
|
candidates.push({ href: new URL(href, baseUrl).toString(), area });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -431,24 +552,46 @@ export function isValidHttpUrl(raw: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRichContent(
|
export interface FetchRichContentResult {
|
||||||
|
/** False when the page couldn't be reached at all, as opposed to reached and
|
||||||
|
* found to carry no metadata. Callers that already hold good metadata must
|
||||||
|
* not overwrite it with a failure stub. */
|
||||||
|
ok: boolean;
|
||||||
|
content?: RichContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch metadata for `url`, reporting whether the fetch itself succeeded.
|
||||||
|
*
|
||||||
|
* On failure it still yields a minimal stub so a *new* dump has something
|
||||||
|
* displayable, but `ok: false` lets callers with existing metadata keep it.
|
||||||
|
*/
|
||||||
|
export async function tryFetchRichContent(
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<RichContent | undefined> {
|
): Promise<FetchRichContentResult> {
|
||||||
try {
|
try {
|
||||||
const provider = providers.find((p) => p.matches(url))!;
|
const provider = providers.find((p) => p.matches(url))!;
|
||||||
return await provider.fetch(url);
|
return { ok: true, content: await provider.fetch(url) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
||||||
// Return a minimal stub so the caller always gets something displayable
|
|
||||||
// (e.g. when the site has a bad TLS cert or the fetch times out).
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
type: "generic",
|
ok: false,
|
||||||
url,
|
content: {
|
||||||
siteName: new URL(url).hostname.replace(/^www\./, ""),
|
type: "generic",
|
||||||
|
url,
|
||||||
|
siteName: new URL(url).hostname.replace(/^www\./, ""),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return { ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Metadata for `url`, or a minimal stub when it can't be fetched. */
|
||||||
|
export async function fetchRichContent(
|
||||||
|
url: string,
|
||||||
|
): Promise<RichContent | undefined> {
|
||||||
|
return (await tryFetchRichContent(url)).content;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
457
src/App.css
457
src/App.css
@@ -438,6 +438,222 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Create-dump modal ── */
|
||||||
|
|
||||||
|
/* A file dropped anywhere in the modal is accepted, so the whole body lights up. */
|
||||||
|
.dump-create--drag {
|
||||||
|
outline: 2px dashed var(--color-accent);
|
||||||
|
outline-offset: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-draft,
|
||||||
|
.dump-create-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-draft {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-draft-discard {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-confirm {
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-confirm p {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not reached, or reached with nothing worth showing. */
|
||||||
|
.preview-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "Already dumped" hint ── */
|
||||||
|
.dump-duplicate {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-duplicate-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-duplicate-body {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-duplicate-lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-duplicate-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-duplicate-more,
|
||||||
|
.dump-duplicate-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step indicator ── */
|
||||||
|
.dump-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1.1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connector between steps — drawn on the gap, not on the labels. */
|
||||||
|
.dump-step + .dump-step::before {
|
||||||
|
content: "";
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step-marker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1.5px solid var(--color-border-subtle);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step--current {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step--current .dump-step-marker {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step--done {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-step--done .dump-step-marker {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the current step keeps its label once the modal gets narrow. */
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
.dump-step:not(.dump-step--current) .dump-step-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panels ── */
|
||||||
|
.dump-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem; /* matches .form, so panels and plain forms space identically */
|
||||||
|
animation: dump-panel-in 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-panel--back {
|
||||||
|
animation-name: dump-panel-in-back;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dump-panel-in {
|
||||||
|
from { opacity: 0; transform: translateX(12px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dump-panel-in-back {
|
||||||
|
from { opacity: 0; transform: translateX(-12px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dump-panel {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One line naming the thing the second panel's choices apply to. */
|
||||||
|
.dump-panel-recap {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mode toggle — segmented control ── */
|
/* ── Mode toggle — segmented control ── */
|
||||||
.visibility-toggle {
|
.visibility-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -592,6 +808,41 @@
|
|||||||
background: var(--color-border-subtle);
|
background: var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Upload progress (second panel, while the file is being sent) ── */
|
||||||
|
.dump-upload-progress {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-upload-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
transition: width 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Length unknown, or the body is sent and the server is still thinking. */
|
||||||
|
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
|
||||||
|
width: 40%;
|
||||||
|
animation: dump-upload-slide 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dump-upload-slide {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(250%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
|
||||||
|
width: 100%;
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Local file / URL preview (DumpCreate) ── */
|
/* ── Local file / URL preview (DumpCreate) ── */
|
||||||
.local-preview-image {
|
.local-preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -832,6 +1083,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 +1345,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;
|
||||||
@@ -2534,6 +2915,12 @@ body.has-player .chat-fab {
|
|||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-edit-refresh-error {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.dump-edit-thumbnail-row {
|
.dump-edit-thumbnail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3009,6 +3396,76 @@ body.has-player .chat-fab {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Generated thumbnail placeholder ──
|
||||||
|
Stands in for pages that offer no preview image. The site's own color is
|
||||||
|
mixed *into* the theme surface rather than painted raw, so an arbitrary
|
||||||
|
third-party accent can never break contrast against --color-text — in any
|
||||||
|
style, in either color scheme. Themes tune --thumb-tint-strength alone. */
|
||||||
|
/* Zero-specificity sizing: the placeholder fills its box by default, but any
|
||||||
|
class it shares with the <img> it replaces (.rich-content-thumbnail's 180px,
|
||||||
|
.rich-content-compact-thumbnail's 36px) still wins, so it lands exactly where
|
||||||
|
the image would have. */
|
||||||
|
:where(.thumb-placeholder) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.12)),
|
||||||
|
color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%),
|
||||||
|
var(--color-surface)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder-icon {
|
||||||
|
max-width: 44%;
|
||||||
|
max-height: 44%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder-initials {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.75;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The initial has no intrinsic size, so scale it per surface. */
|
||||||
|
.dump-card-preview .thumb-placeholder-initials,
|
||||||
|
.playlist-card-preview .thumb-placeholder-initials {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-card-image .thumb-placeholder-initials {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content-thumbnail.thumb-placeholder .thumb-placeholder-initials {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `.rich-content-thumbnail` sizes an <img> by width alone and lets the natural
|
||||||
|
aspect supply the height, which a <div> has none of. Stretching to the card's
|
||||||
|
height matches the image; the floor keeps it from collapsing when there's
|
||||||
|
little text, or once the card stacks on narrow screens. */
|
||||||
|
.rich-content-thumbnail.thumb-placeholder {
|
||||||
|
height: auto;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fill the 48×48 preview box and center content for media buttons */
|
/* Fill the 48×48 preview box and center content for media buttons */
|
||||||
.dump-card-preview .rich-content-thumbnail-btn {
|
.dump-card-preview .rich-content-thumbnail-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
12
src/App.tsx
12
src/App.tsx
@@ -4,6 +4,7 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
Routes,
|
Routes,
|
||||||
|
useLocation,
|
||||||
useParams,
|
useParams,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
@@ -85,14 +86,21 @@ function useResolvedDefaultTab() {
|
|||||||
return preferredTab === "followed" && !user ? "hot" : preferredTab;
|
return preferredTab === "followed" && !user ? "hot" : preferredTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The query string has to survive the hop: the Web Share Target posts to "/",
|
||||||
|
// so an Android share arrives here as `/?share_url=…` and the feed below is the
|
||||||
|
// only thing that can act on it.
|
||||||
function IndexRedirect() {
|
function IndexRedirect() {
|
||||||
return <Navigate to={`/~/${useResolvedDefaultTab()}`} replace />;
|
const { search } = useLocation();
|
||||||
|
return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare `/<slug>` lands on that category's default feed tab.
|
// Bare `/<slug>` lands on that category's default feed tab.
|
||||||
function CategoryRedirect() {
|
function CategoryRedirect() {
|
||||||
const { categorySlug } = useParams();
|
const { categorySlug } = useParams();
|
||||||
return <Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}`} replace />;
|
const { search } = useLocation();
|
||||||
|
return (
|
||||||
|
<Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`} replace />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed.
|
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCategories } from "../hooks/useCategories.ts";
|
|||||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||||
import FilePreview from "./FilePreview.tsx";
|
import FilePreview from "./FilePreview.tsx";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
|
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||||
import { VoteButton } from "./VoteButton.tsx";
|
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";
|
||||||
@@ -65,7 +66,7 @@ export function DumpCard(
|
|||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: <span className="dump-card-preview-icon">🔗</span>}
|
: <ThumbnailPlaceholder url={dump.url} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,32 +1,68 @@
|
|||||||
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 } 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";
|
||||||
|
|
||||||
function itemKey(
|
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||||
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
|
|
||||||
) {
|
// The stored embedUrl is the canonical, non-playing form — it is also rendered
|
||||||
if (!item) return null;
|
// outside the player, so the autoplay parameter is added here at playback time
|
||||||
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
|
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
|
||||||
|
// session restored from localStorage has no user gesture behind it, so it stays
|
||||||
|
// paused, matching how MediaPlayer treats file items.
|
||||||
|
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
|
||||||
|
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
|
||||||
|
function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||||
|
if (!autoplay) return item.embedUrl;
|
||||||
|
try {
|
||||||
|
const url = new URL(item.embedUrl);
|
||||||
|
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
|
||||||
|
else if (item.type === "soundcloud") {
|
||||||
|
url.searchParams.set("auto_play", "true");
|
||||||
|
} else return item.embedUrl;
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return item.embedUrl; // malformed stored URL — hand it to the iframe as-is
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,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
|
||||||
@@ -76,21 +123,66 @@ export function GlobalPlayer() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div className="global-player-header">
|
<div className="global-player-header">
|
||||||
{current.dumpHref
|
{current.artworkUrl && (
|
||||||
? (
|
<Thumbnail
|
||||||
<Link to={current.dumpHref} className="global-player-title">
|
src={current.artworkUrl}
|
||||||
{title}
|
className="global-player-artwork"
|
||||||
</Link>
|
placeholder={{ siteName: current.subtitle }}
|
||||||
)
|
placeholderClassName="global-player-artwork"
|
||||||
: <span className="global-player-title">{title}</span>}
|
loading="eager"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="global-player-heading">
|
||||||
|
{current.dumpHref
|
||||||
|
? (
|
||||||
|
<Link to={current.dumpHref} className="global-player-title">
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
: <span className="global-player-title">{title}</span>}
|
||||||
|
{current.subtitle && (
|
||||||
|
<span className="global-player-subtitle">{current.subtitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showQueue && (
|
||||||
|
<div className="global-player-transport">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost"
|
||||||
|
onClick={previous}
|
||||||
|
disabled={!hasPrevious}
|
||||||
|
aria-label={t`Previous track`}
|
||||||
|
>
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<span className="global-player-position">
|
||||||
|
{queueIndex + 1} / {queue.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost"
|
||||||
|
onClick={next}
|
||||||
|
disabled={!hasNext}
|
||||||
|
aria-label={t`Next track`}
|
||||||
|
>
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<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>
|
||||||
@@ -99,14 +191,15 @@ export function GlobalPlayer() {
|
|||||||
? (
|
? (
|
||||||
<div className="global-player-iframe-wrap">
|
<div className="global-player-iframe-wrap">
|
||||||
<iframe
|
<iframe
|
||||||
src={current.embedUrl}
|
src={playbackUrl(current, autoplay)}
|
||||||
className={`global-player-iframe--${current.type}`}
|
className={`global-player-iframe--${current.type}`}
|
||||||
allow="autoplay; encrypted-media"
|
allow="autoplay; encrypted-media"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
</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}
|
||||||
@@ -117,11 +210,71 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function ImagePicker({
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
className="img-picker-img"
|
className="img-picker-img"
|
||||||
style={{ borderRadius }}
|
style={{ borderRadius }}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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";
|
||||||
import { API_URL } from "../config/api.ts";
|
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.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";
|
||||||
@@ -11,7 +9,11 @@ import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayou
|
|||||||
import { VoteButton } from "./VoteButton.tsx";
|
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 { PlayerContext } from "../contexts/PlayerContext.ts";
|
import Thumbnail from "./Thumbnail.tsx";
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -41,30 +43,24 @@ export function JournalCard(
|
|||||||
navigate(dumpUrl(dump));
|
navigate(dumpUrl(dump));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawThumbnail =
|
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
||||||
dump.kind === "file" && dump.fileMime?.startsWith("image/")
|
// page with no streams and no embed to fall back to — the card must still do
|
||||||
? dumpFileUrl(dump, token)
|
// what an unplayable one does rather than swallowing the click.
|
||||||
: dump.thumbnailMime
|
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
||||||
? dumpThumbnailUrl(dump, token)
|
if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate();
|
||||||
: (dump.richContent?.thumbnailUrl ?? null);
|
}
|
||||||
|
|
||||||
// Route external HTTP thumbnails through the server proxy to avoid
|
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||||
// mixed-content blocks when the frontend is served over HTTPS.
|
// still here too, rather than degrading to a text card with a 🎬.
|
||||||
const thumbnailUrl = (() => {
|
const thumbnailUrl = dump.thumbnailMime
|
||||||
if (!rawThumbnail) return null;
|
? dumpThumbnailUrl(dump, token)
|
||||||
try {
|
: dump.kind === "file"
|
||||||
const u = new URL(rawThumbnail);
|
? (dump.fileMime?.startsWith("image/")
|
||||||
if (
|
? dumpFileUrl(dump, token)
|
||||||
u.protocol === "http:" && u.hostname !== "localhost" &&
|
: dump.fileMime?.startsWith("video/")
|
||||||
u.hostname !== "127.0.0.1"
|
? dumpThumbnailUrl(dump, token)
|
||||||
) {
|
: null)
|
||||||
return `${API_URL}/api/proxy-image?url=${
|
: (dump.richContent?.thumbnailUrl ?? null);
|
||||||
encodeURIComponent(rawThumbnail)
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
} catch { /* relative URL */ }
|
|
||||||
return rawThumbnail;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Content mode is independent of grid footprint: a thumbnailed dump reads as
|
// Content mode is independent of grid footprint: a thumbnailed dump reads as
|
||||||
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
|
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
|
||||||
@@ -84,7 +80,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
|
||||||
@@ -155,27 +157,25 @@ export function JournalCard(
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={className}
|
className={className}
|
||||||
onClick={embedUrl
|
onClick={playable
|
||||||
? () =>
|
? () => void handlePlayOrNavigate(playable)
|
||||||
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">
|
||||||
<img
|
<Thumbnail
|
||||||
src={thumbnailUrl ?? undefined}
|
src={thumbnailUrl ?? undefined}
|
||||||
alt=""
|
placeholder={dump.kind === "file"
|
||||||
loading="lazy"
|
// No ffmpeg on the host means no still — the mime glyph says more
|
||||||
onError={(e) => {
|
// about the dump than an initial taken from its filename.
|
||||||
(e.target as HTMLImageElement).style.visibility = "hidden";
|
? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon }
|
||||||
}}
|
: {
|
||||||
|
url: dump.url,
|
||||||
|
accentColor: dump.richContent?.accentColor,
|
||||||
|
faviconUrl: dump.richContent?.faviconUrl,
|
||||||
|
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>
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ function preprocessMentions(text: string): string {
|
|||||||
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static components object — defined once at module scope to avoid recreation on every render
|
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
||||||
const MARKDOWN_COMPONENTS: React.ComponentProps<
|
|
||||||
typeof ReactMarkdown
|
// Static components objects — defined once at module scope to avoid recreation on every render
|
||||||
>["components"] = {
|
const MARKDOWN_COMPONENTS: Components = {
|
||||||
a: ({ href, children: linkChildren }) => {
|
a: ({ href, children: linkChildren }) => {
|
||||||
if (href?.startsWith("/users/")) {
|
if (href?.startsWith("/users/")) {
|
||||||
return <Link to={href}>{linkChildren}</Link>;
|
return <Link to={href}>{linkChildren}</Link>;
|
||||||
@@ -32,6 +32,14 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inline renderings are line-clamped teasers inside cards, where an embedded
|
||||||
|
// image blows the layout apart. Drop images there — the full description is
|
||||||
|
// one click away on the detail page.
|
||||||
|
const INLINE_MARKDOWN_COMPONENTS: Components = {
|
||||||
|
...MARKDOWN_COMPONENTS,
|
||||||
|
img: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
export function Markdown(
|
export function Markdown(
|
||||||
{ children, className, inline = false }: MarkdownProps,
|
{ children, className, inline = false }: MarkdownProps,
|
||||||
) {
|
) {
|
||||||
@@ -45,7 +53,9 @@ export function Markdown(
|
|||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={REMARK_PLUGINS}
|
remarkPlugins={REMARK_PLUGINS}
|
||||||
components={MARKDOWN_COMPONENTS}
|
components={inline
|
||||||
|
? INLINE_MARKDOWN_COMPONENTS
|
||||||
|
: MARKDOWN_COMPONENTS}
|
||||||
>
|
>
|
||||||
{processed}
|
{processed}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@@ -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}
|
||||||
<input
|
className={kind === "audio" ? "audio-player-track--stream" : undefined}
|
||||||
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,4 +1,4 @@
|
|||||||
import { type ReactNode, useEffect, useRef } from "react";
|
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
@@ -7,11 +7,24 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
|
/**
|
||||||
|
* Runs before every dismissal the user did not aim at the content — Escape,
|
||||||
|
* the backdrop, the ✕. Returning `false` cancels it, which lets a modal
|
||||||
|
* holding an unsaved draft ask first instead of throwing the work away.
|
||||||
|
*/
|
||||||
|
onBeforeClose?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
export function Modal(
|
||||||
|
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
|
||||||
|
) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const requestClose = useCallback(() => {
|
||||||
|
if (onBeforeClose?.() === false) return;
|
||||||
|
onClose();
|
||||||
|
}, [onBeforeClose, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
@@ -21,18 +34,18 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
if (e.key === "Escape" && !e.defaultPrevented) requestClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handler);
|
document.addEventListener("keydown", handler);
|
||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [onClose]);
|
}, [requestClose]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal-backdrop"
|
||||||
ref={backdropRef}
|
ref={backdropRef}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === backdropRef.current) onClose();
|
if (e.target === backdropRef.current) requestClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
||||||
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="modal-close-btn"
|
className="modal-close-btn"
|
||||||
onClick={onClose}
|
onClick={requestClose}
|
||||||
aria-label={t`Close`}
|
aria-label={t`Close`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
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 { API_URL } from "../config/api.ts";
|
import {
|
||||||
|
canPlayRichContent,
|
||||||
/** Route HTTP thumbnail URLs through the server proxy to avoid mixed-content blocks. */
|
usePlayRichContent,
|
||||||
function proxyIfHttp(url: string): string {
|
} from "../hooks/usePlayRichContent.ts";
|
||||||
try {
|
import Thumbnail from "./Thumbnail.tsx";
|
||||||
const u = new URL(url);
|
|
||||||
if (
|
|
||||||
u.protocol === "http:" && u.hostname !== "localhost" &&
|
|
||||||
u.hostname !== "127.0.0.1"
|
|
||||||
) {
|
|
||||||
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
|
|
||||||
}
|
|
||||||
} catch { /* relative URL — leave as-is */ }
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RichContentCardProps {
|
interface RichContentCardProps {
|
||||||
richContent: RichContent;
|
richContent: RichContent;
|
||||||
@@ -30,48 +20,68 @@ 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, togglePlay } = useContext(PlayerContext);
|
||||||
const thumbnailSrc = thumbnailOverrideUrl ??
|
const { playRichContent, pending } = usePlayRichContent();
|
||||||
(richContent.thumbnailUrl
|
|
||||||
? proxyIfHttp(richContent.thumbnailUrl)
|
const canPlay = canPlayRichContent(richContent);
|
||||||
: undefined);
|
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
|
||||||
|
// The dump's own thumbnail overrides the provider's, in the player header too.
|
||||||
|
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
|
||||||
|
// Nothing to play and no embed to fall back to: the card body is a link to
|
||||||
|
// the source, so the thumbnail behaves like one rather than going dead.
|
||||||
|
const playOrOpen = async () => {
|
||||||
|
if (!await playRichContent(playable, dumpHref)) {
|
||||||
|
globalThis.open(richContent.url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholder = {
|
||||||
|
url: richContent.url,
|
||||||
|
accentColor: richContent.accentColor,
|
||||||
|
faviconUrl: richContent.faviconUrl,
|
||||||
|
siteName: richContent.siteName,
|
||||||
|
};
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
if (richContent.embedUrl) {
|
const thumbnail = (
|
||||||
const isActive = current?.kind === "embed" &&
|
<Thumbnail
|
||||||
current.embedUrl === richContent.embedUrl;
|
src={thumbnailSrc}
|
||||||
|
alt={richContent.title ?? ""}
|
||||||
|
className="rich-content-compact-thumbnail"
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderClassName="rich-content-compact-thumbnail"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (canPlay) {
|
||||||
|
// A native Bandcamp queue is the same card as its embed, matched on the
|
||||||
|
// source page rather than a stream URL that changes on every resolve.
|
||||||
|
const isActive = current != null &&
|
||||||
|
((current.kind === "embed" &&
|
||||||
|
current.embedUrl === richContent.embedUrl) ||
|
||||||
|
(current.kind === "stream" &&
|
||||||
|
current.resolveUrl === richContent.url));
|
||||||
const isPlaying = isActive && playing;
|
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({
|
// The button says "Pause" once this card is the one playing, so it
|
||||||
kind: "embed",
|
// has to pause. Re-playing would re-resolve the album and lose the
|
||||||
embedUrl: richContent.embedUrl!,
|
// queue position. Embeds have no transport of their own, so they
|
||||||
title: richContent.title,
|
// keep restarting, as before.
|
||||||
type: richContent.type,
|
if (isActive && current?.kind === "stream") togglePlay();
|
||||||
dumpHref,
|
else void playOrOpen();
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
>
|
>
|
||||||
{thumbnailSrc
|
{thumbnail}
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-compact-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: <span className="rich-content-compact-icon">▶</span>}
|
|
||||||
<span className="rich-content-play-overlay">
|
<span className="rich-content-play-overlay">
|
||||||
{isPlaying ? "⏸" : "▶"}
|
{isPlaying ? "⏸" : "▶"}
|
||||||
</span>
|
</span>
|
||||||
@@ -87,63 +97,36 @@ export default function RichContentCard(
|
|||||||
className="rich-content-compact"
|
className="rich-content-compact"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{thumbnailSrc
|
{thumbnail}
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-compact-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: <span className="rich-content-compact-icon">🔗</span>}
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canPlay = !!richContent.embedUrl;
|
const thumbnailImg = (
|
||||||
|
<Thumbnail
|
||||||
const thumbnail = thumbnailSrc && (
|
src={thumbnailSrc}
|
||||||
canPlay
|
alt={richContent.title ?? ""}
|
||||||
? (
|
className="rich-content-thumbnail"
|
||||||
<button
|
placeholder={placeholder}
|
||||||
type="button"
|
placeholderClassName="rich-content-thumbnail"
|
||||||
className="rich-content-thumbnail-btn"
|
/>
|
||||||
onClick={() =>
|
|
||||||
play({
|
|
||||||
kind: "embed",
|
|
||||||
embedUrl: richContent.embedUrl!,
|
|
||||||
title: richContent.title,
|
|
||||||
type: richContent.type,
|
|
||||||
dumpHref,
|
|
||||||
})}
|
|
||||||
aria-label="Play"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="rich-content-play-overlay">▶</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<img
|
|
||||||
src={thumbnailSrc}
|
|
||||||
alt={richContent.title ?? ""}
|
|
||||||
className="rich-content-thumbnail"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLImageElement).style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const thumbnail = canPlay
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void playOrOpen()}
|
||||||
|
aria-label="Play"
|
||||||
|
>
|
||||||
|
{thumbnailImg}
|
||||||
|
<span className="rich-content-play-overlay">▶</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: thumbnailImg;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
||||||
{thumbnail}
|
{thumbnail}
|
||||||
|
|||||||
73
src/components/Thumbnail.tsx
Normal file
73
src/components/Thumbnail.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts";
|
||||||
|
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||||
|
|
||||||
|
interface ThumbnailProps {
|
||||||
|
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
className?: string;
|
||||||
|
loading?: "lazy" | "eager";
|
||||||
|
/** Passed through to the placeholder when there's nothing to show. */
|
||||||
|
placeholder: {
|
||||||
|
url?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
faviconUrl?: string;
|
||||||
|
siteName?: string;
|
||||||
|
};
|
||||||
|
/** Extra class for the placeholder element only. */
|
||||||
|
placeholderClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external thumbnail that always renders *something*.
|
||||||
|
*
|
||||||
|
* Three attempts, in order:
|
||||||
|
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
|
||||||
|
* one click) answers a cross-site referrer with 403, and an empty one with
|
||||||
|
* 200;
|
||||||
|
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
|
||||||
|
* that reject an empty referrer too;
|
||||||
|
* 3. fall back to the generated placeholder.
|
||||||
|
*
|
||||||
|
* Before this existed each of these failures ended at `display: none`, leaving
|
||||||
|
* an empty framed box and no signal anywhere that a load had failed.
|
||||||
|
*/
|
||||||
|
export default function Thumbnail(
|
||||||
|
{ src, alt = "", className, loading = "lazy", placeholder, placeholderClassName }:
|
||||||
|
ThumbnailProps,
|
||||||
|
) {
|
||||||
|
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
|
||||||
|
"direct",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new src is a fresh thing to try — reset during render rather than in an
|
||||||
|
// effect (same shape as Avatar.tsx).
|
||||||
|
const [prevSrc, setPrevSrc] = useState(src);
|
||||||
|
if (prevSrc !== src) {
|
||||||
|
setPrevSrc(src);
|
||||||
|
setAttempt("direct");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src || attempt === "failed") {
|
||||||
|
return (
|
||||||
|
<ThumbnailPlaceholder {...placeholder} className={placeholderClassName} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={attempt === "proxied"
|
||||||
|
? proxyImageUrl(src, { force: true })
|
||||||
|
: proxyImageUrl(src)}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
loading={loading}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() =>
|
||||||
|
setAttempt(
|
||||||
|
attempt === "direct" && canProxyImage(src) ? "proxied" : "failed",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/ThumbnailPlaceholder.tsx
Normal file
70
src/components/ThumbnailPlaceholder.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { proxyImageUrl } from "../utils/proxyImage.ts";
|
||||||
|
import { initialsFor, tintFor } from "../utils/thumbnailTint.ts";
|
||||||
|
|
||||||
|
interface ThumbnailPlaceholderProps {
|
||||||
|
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||||
|
url?: string;
|
||||||
|
/** Explicit hue seed for dumps with no URL to hash (file dumps). */
|
||||||
|
seed?: string;
|
||||||
|
/** The target page's declared brand color, if it had one. */
|
||||||
|
accentColor?: string;
|
||||||
|
/** The target page's icon, drawn contained over the tint. */
|
||||||
|
faviconUrl?: string;
|
||||||
|
/** Preferred source for the initial when there's no favicon. */
|
||||||
|
siteName?: string;
|
||||||
|
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
|
||||||
|
glyph?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in artwork for pages that offer no preview image: the site's own color
|
||||||
|
* washed over the theme surface, with its favicon centered on top — or its
|
||||||
|
* initial when the favicon is missing or fails to load.
|
||||||
|
*
|
||||||
|
* Fills whatever box it's placed in, so the same component serves the 36px
|
||||||
|
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
|
||||||
|
*/
|
||||||
|
export default function ThumbnailPlaceholder(
|
||||||
|
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
|
||||||
|
ThumbnailPlaceholderProps,
|
||||||
|
) {
|
||||||
|
const [iconFailed, setIconFailed] = useState(false);
|
||||||
|
|
||||||
|
// A different icon is a fresh thing to try — clear the prior failure during
|
||||||
|
// render rather than in an effect (same shape as Avatar.tsx).
|
||||||
|
const [prevIcon, setPrevIcon] = useState(faviconUrl);
|
||||||
|
if (prevIcon !== faviconUrl) {
|
||||||
|
setPrevIcon(faviconUrl);
|
||||||
|
setIconFailed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = { "--thumb-tint": tintFor({ accentColor, url, seed }) } as
|
||||||
|
React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`thumb-placeholder${className ? ` ${className}` : ""}`}
|
||||||
|
style={style}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{faviconUrl && !iconFailed
|
||||||
|
? (
|
||||||
|
<img
|
||||||
|
src={proxyImageUrl(faviconUrl)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="thumb-placeholder-icon"
|
||||||
|
onError={() => setIconFailed(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span className="thumb-placeholder-initials">
|
||||||
|
{glyph ?? initialsFor(siteName, url)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,14 +64,17 @@ export function SegmentedField<T extends FieldValues, V>({
|
|||||||
*/
|
*/
|
||||||
export function VisibilityToggle<T extends FieldValues>({
|
export function VisibilityToggle<T extends FieldValues>({
|
||||||
name,
|
name,
|
||||||
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
name: Path<T>;
|
name: Path<T>;
|
||||||
|
label?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SegmentedField<T, boolean>
|
<SegmentedField<T, boolean>
|
||||||
name={name}
|
name={name}
|
||||||
|
label={label}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
options={[
|
options={[
|
||||||
{ value: true, label: <Trans>Public</Trans> },
|
{ value: true, label: <Trans>Public</Trans> },
|
||||||
|
|||||||
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,41 @@ 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) => {
|
// Bumped whenever the player is moved somewhere new — a fresh queue, a queue
|
||||||
|
// step, a stop. An async re-resolve compares it against the value it captured
|
||||||
|
// to tell whether it still owns the player by the time it lands.
|
||||||
|
const playGen = useRef(0);
|
||||||
|
|
||||||
|
// 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, queue, queueIndex });
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = { current, currentTime, playing, queue, queueIndex };
|
||||||
|
});
|
||||||
|
|
||||||
|
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
playGen.current++;
|
||||||
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) => {
|
||||||
|
playGen.current++;
|
||||||
|
suppressUpdates.current = true;
|
||||||
|
setQueueIndex(index);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setStartTime(0);
|
setStartTime(0);
|
||||||
@@ -54,7 +130,9 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
setCurrent(null);
|
playGen.current++;
|
||||||
|
setQueue([]);
|
||||||
|
setQueueIndex(0);
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
@@ -70,6 +148,34 @@ 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);
|
||||||
|
// Rewinding alone would make the click look like a no-op on a row that
|
||||||
|
// has ended, or that the user paused. Start it again.
|
||||||
|
if (!stateRef.current.playing) togglePlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
advanceTo(index);
|
||||||
|
}, [queue.length, queueIndex, advanceTo, seekTo, togglePlay]);
|
||||||
|
|
||||||
|
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 +187,158 @@ 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]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A track that stays dead after a re-resolve is one track, not one album:
|
||||||
|
* step over it and keep the queue. Only the last one falls back to the embed.
|
||||||
|
*/
|
||||||
|
const skipOrFallBack = useCallback((
|
||||||
|
item: Extract<PlayerItem, { kind: "stream" }>,
|
||||||
|
) => {
|
||||||
|
const { queue: q, queueIndex: i } = stateRef.current;
|
||||||
|
if (i + 1 < q.length) {
|
||||||
|
advanceTo(i + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fallBackToEmbed(item);
|
||||||
|
}, [advanceTo, fallBackToEmbed]);
|
||||||
|
|
||||||
|
const resolveSeq = useRef(0);
|
||||||
|
|
||||||
|
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.
|
||||||
|
skipOrFallBack(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastReresolve.current = { key, at: now };
|
||||||
|
|
||||||
|
// Resolving is a network round-trip against Bandcamp. The user can pick
|
||||||
|
// another dump, skip on, or close the player while it's in flight — and a
|
||||||
|
// result that lands after that must not drag the player back here.
|
||||||
|
const gen = playGen.current;
|
||||||
|
const stillOurs = () => playGen.current === gen;
|
||||||
|
// Separate from `gen`: the spinner belongs to the newest resolve, whichever
|
||||||
|
// item that one is for, so a superseded resolve must not clear it.
|
||||||
|
const seq = ++resolveSeq.current;
|
||||||
|
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const items = await resolveStreamQueue(item);
|
||||||
|
if (!stillOurs()) return;
|
||||||
|
const found = items.findIndex((i) =>
|
||||||
|
i.kind === "stream" && i.resolveIndex === item.resolveIndex
|
||||||
|
);
|
||||||
|
// The track can be gone from the release, or no longer streamable once
|
||||||
|
// Bandcamp's free-play cap kicks in. Falling back to the first track is
|
||||||
|
// fine; carrying the old offset onto it would seek past its end.
|
||||||
|
const index = found < 0 ? 0 : found;
|
||||||
|
const resume = found < 0 ? 0 : resumeAt;
|
||||||
|
suppressUpdates.current = true;
|
||||||
|
setQueue(items);
|
||||||
|
setQueueIndex(index);
|
||||||
|
setCurrentTime(resume);
|
||||||
|
setStartTime(resume);
|
||||||
|
setDuration(0);
|
||||||
|
setAutoplay(shouldAutoplay);
|
||||||
|
setPlaying(false);
|
||||||
|
} catch {
|
||||||
|
// The resolve itself failed (offline, endpoint down) — that's the whole
|
||||||
|
// album, not one track, so the embed is the right fallback.
|
||||||
|
if (stillOurs()) fallBackToEmbed(item);
|
||||||
|
} finally {
|
||||||
|
if (resolveSeq.current === seq) setResolving(false);
|
||||||
|
}
|
||||||
|
}, [fallBackToEmbed, skipOrFallBack]);
|
||||||
|
|
||||||
|
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 +357,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 +376,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 (
|
||||||
|
|||||||
@@ -59,22 +59,77 @@ export const useAuth = () => {
|
|||||||
return res;
|
return res;
|
||||||
}, [authResponse?.token, logout]);
|
}, [authResponse?.token, logout]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `authFetch` for multipart uploads that need a progress bar.
|
||||||
|
*
|
||||||
|
* `fetch` reports nothing until the whole body is on the wire, which for a
|
||||||
|
* 50 MB dump on a phone is a long silence — so this goes through XHR, whose
|
||||||
|
* `upload.progress` events fire as bytes leave. The result is wrapped back
|
||||||
|
* into a real `Response` so callers keep using `expectOk` unchanged.
|
||||||
|
*
|
||||||
|
* `onProgress` receives a 0–1 fraction, or `null` once the body is fully sent
|
||||||
|
* and we are waiting on the server (the length is unknown for chunked bodies,
|
||||||
|
* and "100%, still waiting" reads as a stall).
|
||||||
|
*/
|
||||||
|
const authUpload = useCallback((
|
||||||
|
url: string,
|
||||||
|
body: FormData,
|
||||||
|
onProgress: (fraction: number | null) => void,
|
||||||
|
method = "POST",
|
||||||
|
): Promise<Response> => {
|
||||||
|
const token = authResponse?.token;
|
||||||
|
|
||||||
|
if (token && isTokenExpired(token)) {
|
||||||
|
logout();
|
||||||
|
return Promise.resolve(new Response(null, { status: 401 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(method, url);
|
||||||
|
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||||
|
// Content-Type is left to the browser so the multipart boundary is set.
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("progress", (e) => {
|
||||||
|
onProgress(e.lengthComputable ? e.loaded / e.total : null);
|
||||||
|
});
|
||||||
|
xhr.upload.addEventListener("load", () => onProgress(null));
|
||||||
|
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
if (xhr.status === 401) logout();
|
||||||
|
resolve(
|
||||||
|
new Response(xhr.responseText, {
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Mirrors what `fetch` throws on a network failure, so the shared
|
||||||
|
// `friendlyFetchError` path renders it the same way.
|
||||||
|
xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch")));
|
||||||
|
xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch")));
|
||||||
|
|
||||||
|
xhr.send(body);
|
||||||
|
});
|
||||||
|
}, [authResponse?.token, logout]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: authResponse?.user ?? null,
|
user: authResponse?.user ?? null,
|
||||||
token: authResponse?.token ?? null,
|
token: authResponse?.token ?? null,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
authFetch,
|
authFetch,
|
||||||
|
authUpload,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRequiredAuth = () => {
|
export const useRequiredAuth = () => {
|
||||||
const { user, token, login, logout, authFetch } = useAuth();
|
const { user, token, login, logout, authFetch, authUpload } = useAuth();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invariant: useRequiredAuth called outside a protected route",
|
"Invariant: useRequiredAuth called outside a protected route",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { user, token, login, logout, authFetch };
|
return { user, token, login, logout, authFetch, authUpload };
|
||||||
};
|
};
|
||||||
|
|||||||
88
src/hooks/usePlayRichContent.ts
Normal file
88
src/hooks/usePlayRichContent.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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 — falls back to the embed.
|
||||||
|
*
|
||||||
|
* Returns whether playback actually started. A native-mode page with no stored
|
||||||
|
* embedUrl (an artist root, a /music index, a preorder) has nothing to fall
|
||||||
|
* back to, and the caller has to be told so the click isn't swallowed.
|
||||||
|
*/
|
||||||
|
export function usePlayRichContent() {
|
||||||
|
const { play, playQueue } = useContext(PlayerContext);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const playEmbed = useCallback(
|
||||||
|
(rc: PlayableRichContent, dumpHref?: string): boolean => {
|
||||||
|
if (!rc.embedUrl) return false;
|
||||||
|
play({
|
||||||
|
kind: "embed",
|
||||||
|
embedUrl: rc.embedUrl,
|
||||||
|
title: rc.title,
|
||||||
|
type: rc.type,
|
||||||
|
dumpHref,
|
||||||
|
artworkUrl: rc.thumbnailUrl,
|
||||||
|
subtitle: rc.siteName,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[play],
|
||||||
|
);
|
||||||
|
|
||||||
|
const playRichContent = useCallback(
|
||||||
|
async (rc: PlayableRichContent, dumpHref?: string): Promise<boolean> => {
|
||||||
|
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
|
||||||
|
if (!native) return playEmbed(rc, dumpHref);
|
||||||
|
|
||||||
|
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);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("bandcamp: native playback unavailable, using embed", err);
|
||||||
|
return 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:114
|
||||||
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}}"
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
|
|||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: rest.length
|
||||||
|
#: src/components/DumpCreateModal.tsx:246
|
||||||
|
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
|
||||||
|
msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
|
||||||
|
|
||||||
#. placeholder {0}: names[0]
|
#. placeholder {0}: names[0]
|
||||||
#. placeholder {1}: names[1]
|
#. placeholder {1}: names[1]
|
||||||
#: src/components/ChatModal.tsx:531
|
#: src/components/ChatModal.tsx:531
|
||||||
@@ -53,14 +58,15 @@ msgstr "{label} ({count})"
|
|||||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:935
|
||||||
#: src/pages/PlaylistDetail.tsx:570
|
#: src/pages/PlaylistDetail.tsx:570
|
||||||
#: src/pages/UserPublicProfile.tsx:749
|
#: src/pages/UserPublicProfile.tsx:749
|
||||||
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 +84,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"
|
||||||
|
|
||||||
@@ -154,7 +160,7 @@ msgid "Add email…"
|
|||||||
msgstr "Add email…"
|
msgstr "Add email…"
|
||||||
|
|
||||||
#: src/components/AddToPlaylistModal.tsx:64
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
msgid "Add to playlist"
|
msgid "Add to playlist"
|
||||||
msgstr "Add to playlist"
|
msgstr "Add to playlist"
|
||||||
|
|
||||||
@@ -167,6 +173,12 @@ msgstr "Admin"
|
|||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "All"
|
msgstr "All"
|
||||||
|
|
||||||
|
#. placeholder {0}: first.username
|
||||||
|
#. placeholder {1}: relativeTime(first.createdAt)
|
||||||
|
#: src/components/DumpCreateModal.tsx:233
|
||||||
|
msgid "Already dumped by {0} {1}"
|
||||||
|
msgstr "Already dumped by {0} {1}"
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:156
|
#: src/pages/UserRegister.tsx:156
|
||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Already have an account? <0>Log in</0>"
|
msgstr "Already have an account? <0>Log in</0>"
|
||||||
@@ -201,9 +213,10 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
|||||||
#: src/components/ChatModal.tsx:229
|
#: src/components/ChatModal.tsx:229
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
|
#: src/components/DumpCreateModal.tsx:945
|
||||||
#: 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
|
||||||
@@ -262,10 +275,26 @@ msgid "Checking invite…"
|
|||||||
msgstr "Checking invite…"
|
msgstr "Checking invite…"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:56
|
#: src/components/ChangePasswordModal.tsx:56
|
||||||
#: src/components/Modal.tsx:45
|
#: src/components/Modal.tsx:58
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Close"
|
msgstr "Close"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:690
|
||||||
|
msgid "Close anyway"
|
||||||
|
msgstr "Close anyway"
|
||||||
|
|
||||||
|
#: src/components/GlobalPlayer.tsx:184
|
||||||
|
msgid "Close player"
|
||||||
|
msgstr "Close player"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:680
|
||||||
|
msgid "Close without posting? The attached file won't be kept — everything you typed will."
|
||||||
|
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
|
||||||
|
|
||||||
|
#: 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,10 +320,14 @@ 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"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:802
|
||||||
|
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||||
|
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:87
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Create"
|
msgstr "Create"
|
||||||
@@ -346,8 +379,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 +394,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."
|
||||||
|
|
||||||
@@ -383,7 +416,7 @@ msgstr "deleted message"
|
|||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (optional)"
|
msgstr "Description (optional)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:488
|
#: src/components/DumpCreateModal.tsx:741
|
||||||
msgid "Done"
|
msgid "Done"
|
||||||
msgstr "Done"
|
msgstr "Done"
|
||||||
|
|
||||||
@@ -391,7 +424,11 @@ 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/components/DumpCreateModal.tsx:847
|
||||||
|
msgid "Drop a file here, or paste one"
|
||||||
|
msgstr "Drop a file here, or paste one"
|
||||||
|
|
||||||
|
#: 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"
|
||||||
|
|
||||||
@@ -399,11 +436,11 @@ msgstr "Drop a replacement here"
|
|||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Dump"
|
msgstr "Dump"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:458
|
#: src/components/DumpCreateModal.tsx:958
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Dump it"
|
msgstr "Dump it"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:469
|
#: src/components/DumpCreateModal.tsx:718
|
||||||
msgid "Dumped!"
|
msgid "Dumped!"
|
||||||
msgstr "Dumped!"
|
msgstr "Dumped!"
|
||||||
|
|
||||||
@@ -426,7 +463,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"
|
||||||
@@ -436,7 +473,7 @@ msgstr "Edit"
|
|||||||
msgid "Edit {0}"
|
msgid "Edit {0}"
|
||||||
msgstr "Edit {0}"
|
msgstr "Edit {0}"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:420
|
#: src/components/DumpCreateModal.tsx:197
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Edit title"
|
msgstr "Edit title"
|
||||||
|
|
||||||
@@ -445,7 +482,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 +492,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 +514,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"
|
||||||
@@ -506,7 +547,7 @@ msgstr "Failed to generate invite"
|
|||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Failed to load"
|
msgstr "Failed to load"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:337
|
#: src/components/DumpCreateModal.tsx:750
|
||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Failed to post"
|
msgstr "Failed to post"
|
||||||
|
|
||||||
@@ -544,19 +585,11 @@ msgstr "Failed to update role"
|
|||||||
msgid "Feeds"
|
msgid "Feeds"
|
||||||
msgstr "Feeds"
|
msgstr "Feeds"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:369
|
#: src/components/DumpCreateModal.tsx:789
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Fetching preview…"
|
msgstr "Fetching preview…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:455
|
#: src/components/DumpCreateModal.tsx:571
|
||||||
msgid "Fetching…"
|
|
||||||
msgstr "Fetching…"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:331
|
|
||||||
msgid "File"
|
|
||||||
msgstr "File"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:239
|
|
||||||
msgid "File too large (max 50 MB)."
|
msgid "File too large (max 50 MB)."
|
||||||
msgstr "File too large (max 50 MB)."
|
msgstr "File too large (max 50 MB)."
|
||||||
|
|
||||||
@@ -632,7 +665,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"
|
||||||
|
|
||||||
@@ -658,6 +691,10 @@ msgstr "Invitees"
|
|||||||
msgid "Journal"
|
msgid "Journal"
|
||||||
msgstr "Journal"
|
msgstr "Journal"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:687
|
||||||
|
msgid "Keep editing"
|
||||||
|
msgstr "Keep editing"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1205
|
#: src/pages/UserPublicProfile.tsx:1205
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Light"
|
msgstr "Light"
|
||||||
@@ -666,6 +703,14 @@ msgstr "Light"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Like"
|
msgstr "Like"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:764
|
||||||
|
msgid "Link"
|
||||||
|
msgstr "Link"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:66
|
||||||
|
msgid "Link or file"
|
||||||
|
msgstr "Link or file"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:575
|
#: src/contexts/WSProvider.tsx:575
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
@@ -683,8 +728,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…"
|
||||||
|
|
||||||
@@ -783,7 +828,7 @@ msgstr "new"
|
|||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
#: src/components/DumpFab.tsx:65
|
#: src/components/DumpFab.tsx:65
|
||||||
#: src/components/DumpFab.tsx:66
|
#: src/components/DumpFab.tsx:66
|
||||||
#: src/pages/UserDumps.tsx:88
|
#: src/pages/UserDumps.tsx:88
|
||||||
@@ -801,6 +846,14 @@ msgstr "New password"
|
|||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "New playlist"
|
msgstr "New playlist"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:957
|
||||||
|
msgid "Next →"
|
||||||
|
msgstr "Next →"
|
||||||
|
|
||||||
|
#: 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."
|
||||||
@@ -907,11 +960,20 @@ msgstr "Password updated"
|
|||||||
msgid "Passwords do not match"
|
msgid "Passwords do not match"
|
||||||
msgstr "Passwords do not match"
|
msgstr "Passwords do not match"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:782
|
||||||
|
msgid "Paste a link…"
|
||||||
|
msgstr "Paste a link…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:699
|
||||||
|
msgid "Picked up where you left off."
|
||||||
|
msgstr "Picked up where you left off."
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:863
|
#: src/pages/PlaylistDetail.tsx:863
|
||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Playlist title"
|
msgstr "Playlist title"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:86
|
#: src/components/AppHeader.tsx:86
|
||||||
|
#: src/components/DumpCreateModal.tsx:68
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPlaylists.tsx:371
|
#: src/pages/UserPlaylists.tsx:371
|
||||||
@@ -925,10 +987,6 @@ msgstr "Playlists"
|
|||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Playlists ({0}{1})"
|
msgstr "Playlists ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:236
|
|
||||||
msgid "Please select a file."
|
|
||||||
msgstr "Please select a file."
|
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:472
|
#: src/components/CommentThread.tsx:472
|
||||||
msgid "Post comment"
|
msgid "Post comment"
|
||||||
msgstr "Post comment"
|
msgstr "Post comment"
|
||||||
@@ -939,19 +997,24 @@ msgstr "Post reply"
|
|||||||
|
|
||||||
#: src/components/CommentThread.tsx:396
|
#: src/components/CommentThread.tsx:396
|
||||||
#: src/components/CommentThread.tsx:473
|
#: src/components/CommentThread.tsx:473
|
||||||
|
#: src/components/DumpCreateModal.tsx:951
|
||||||
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:123
|
||||||
#: 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"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:78
|
#: src/components/form/SegmentedField.tsx:81
|
||||||
#: src/pages/PlaylistDetail.tsx:902
|
#: src/pages/PlaylistDetail.tsx:902
|
||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Private"
|
msgstr "Private"
|
||||||
@@ -961,16 +1024,20 @@ msgstr "Private"
|
|||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:77
|
#: src/components/form/SegmentedField.tsx:80
|
||||||
#: src/pages/PlaylistDetail.tsx:895
|
#: src/pages/PlaylistDetail.tsx:895
|
||||||
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 +1055,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 +1075,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 +1101,18 @@ 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/components/DumpCreateModal.tsx:208
|
||||||
#: src/pages/DumpEdit.tsx:174
|
#: src/components/DumpCreateModal.tsx:209
|
||||||
|
msgid "Reset to the suggested title"
|
||||||
|
msgstr "Reset to the suggested title"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:285
|
||||||
|
#: src/pages/DumpEdit.tsx:180
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Retry"
|
msgstr "Retry"
|
||||||
|
|
||||||
@@ -1050,8 +1122,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 +1132,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
|
||||||
@@ -1124,6 +1196,10 @@ msgstr "slug"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:706
|
||||||
|
msgid "Start fresh"
|
||||||
|
msgstr "Start fresh"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1155
|
#: src/pages/UserPublicProfile.tsx:1155
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
@@ -1152,17 +1228,17 @@ 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:174
|
||||||
#: 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"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:237
|
#: src/components/DumpCreateModal.tsx:569
|
||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Title is required."
|
msgstr "Title is required."
|
||||||
|
|
||||||
@@ -1191,7 +1267,12 @@ msgstr "Unfollow playlist"
|
|||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Upload failed"
|
msgstr "Upload failed"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:456
|
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
|
||||||
|
#: src/components/DumpCreateModal.tsx:642
|
||||||
|
msgid "Uploading {0}%"
|
||||||
|
msgstr "Uploading {0}%"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:954
|
||||||
msgid "Uploading…"
|
msgid "Uploading…"
|
||||||
msgstr "Uploading…"
|
msgstr "Uploading…"
|
||||||
|
|
||||||
@@ -1209,12 +1290,11 @@ msgstr "Upvoted"
|
|||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Upvoted ({0}{1})"
|
msgstr "Upvoted ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:344
|
#: src/pages/DumpEdit.tsx:421
|
||||||
#: src/pages/DumpEdit.tsx:412
|
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:223
|
#: src/components/DumpCreateModal.tsx:553
|
||||||
msgid "URL is required."
|
msgid "URL is required."
|
||||||
msgstr "URL is required."
|
msgstr "URL is required."
|
||||||
|
|
||||||
@@ -1251,12 +1331,12 @@ msgstr "Users"
|
|||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "View all →"
|
msgstr "View all →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:471
|
#: src/components/DumpCreateModal.tsx:720
|
||||||
msgid "View dump →"
|
msgid "View dump →"
|
||||||
msgstr "View dump →"
|
msgstr "View dump →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:434
|
#: src/components/DumpCreateModal.tsx:904
|
||||||
#: 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?"
|
||||||
|
|
||||||
@@ -1265,8 +1345,16 @@ msgstr "What makes it worth it?"
|
|||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Who am I?"
|
msgstr "Who am I?"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:433
|
#: src/components/DumpCreateModal.tsx:920
|
||||||
#: src/pages/DumpEdit.tsx:436
|
msgid "Who can see it"
|
||||||
|
msgstr "Who can see it"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:67
|
||||||
|
msgid "Why & where"
|
||||||
|
msgstr "Why & where"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:903
|
||||||
|
#: src/pages/DumpEdit.tsx:445
|
||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Why?"
|
msgstr "Why?"
|
||||||
|
|
||||||
@@ -1279,6 +1367,10 @@ msgstr "Write a reply…"
|
|||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Yesterday"
|
msgstr "Yesterday"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:254
|
||||||
|
msgid "You can post it anyway."
|
||||||
|
msgstr "You can post it anyway."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:384
|
#: src/pages/Notifications.tsx:384
|
||||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
|
|||||||
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:114
|
||||||
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}}"
|
||||||
|
|
||||||
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
|||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
msgstr "{0, plural, one {# reco} other {# recos}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: rest.length
|
||||||
|
#: src/components/DumpCreateModal.tsx:246
|
||||||
|
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
|
||||||
|
msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus anciennes}}"
|
||||||
|
|
||||||
#. placeholder {0}: names[0]
|
#. placeholder {0}: names[0]
|
||||||
#. placeholder {1}: names[1]
|
#. placeholder {1}: names[1]
|
||||||
#: src/components/ChatModal.tsx:531
|
#: src/components/ChatModal.tsx:531
|
||||||
@@ -53,14 +58,15 @@ msgstr "{label} ({count})"
|
|||||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:935
|
||||||
#: src/pages/PlaylistDetail.tsx:570
|
#: src/pages/PlaylistDetail.tsx:570
|
||||||
#: src/pages/UserPublicProfile.tsx:749
|
#: src/pages/UserPublicProfile.tsx:749
|
||||||
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 +84,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"
|
||||||
|
|
||||||
@@ -154,7 +160,7 @@ msgid "Add email…"
|
|||||||
msgstr "Ajouter un e-mail…"
|
msgstr "Ajouter un e-mail…"
|
||||||
|
|
||||||
#: src/components/AddToPlaylistModal.tsx:64
|
#: src/components/AddToPlaylistModal.tsx:64
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
msgid "Add to playlist"
|
msgid "Add to playlist"
|
||||||
msgstr "Ajouter à la collection"
|
msgstr "Ajouter à la collection"
|
||||||
|
|
||||||
@@ -167,6 +173,12 @@ msgstr "Administrateur"
|
|||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "Tout"
|
msgstr "Tout"
|
||||||
|
|
||||||
|
#. placeholder {0}: first.username
|
||||||
|
#. placeholder {1}: relativeTime(first.createdAt)
|
||||||
|
#: src/components/DumpCreateModal.tsx:233
|
||||||
|
msgid "Already dumped by {0} {1}"
|
||||||
|
msgstr "Déjà recommandé par {0} {1}"
|
||||||
|
|
||||||
#: src/pages/UserRegister.tsx:156
|
#: src/pages/UserRegister.tsx:156
|
||||||
msgid "Already have an account? <0>Log in</0>"
|
msgid "Already have an account? <0>Log in</0>"
|
||||||
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
|
||||||
@@ -201,9 +213,10 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
|||||||
#: src/components/ChatModal.tsx:229
|
#: src/components/ChatModal.tsx:229
|
||||||
#: src/components/CommentThread.tsx:124
|
#: src/components/CommentThread.tsx:124
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:32
|
||||||
|
#: src/components/DumpCreateModal.tsx:945
|
||||||
#: 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
|
||||||
@@ -262,10 +275,26 @@ msgid "Checking invite…"
|
|||||||
msgstr "Vérification de l'invitation…"
|
msgstr "Vérification de l'invitation…"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:56
|
#: src/components/ChangePasswordModal.tsx:56
|
||||||
#: src/components/Modal.tsx:45
|
#: src/components/Modal.tsx:58
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:690
|
||||||
|
msgid "Close anyway"
|
||||||
|
msgstr "Fermer quand même"
|
||||||
|
|
||||||
|
#: src/components/GlobalPlayer.tsx:184
|
||||||
|
msgid "Close player"
|
||||||
|
msgstr "Fermer le lecteur"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:680
|
||||||
|
msgid "Close without posting? The attached file won't be kept — everything you typed will."
|
||||||
|
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
|
||||||
|
|
||||||
|
#: 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,10 +320,14 @@ 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"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:802
|
||||||
|
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||||
|
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
|
||||||
|
|
||||||
#: src/components/PlaylistCreateForm.tsx:87
|
#: src/components/PlaylistCreateForm.tsx:87
|
||||||
msgid "Create"
|
msgid "Create"
|
||||||
msgstr "Créer"
|
msgstr "Créer"
|
||||||
@@ -346,8 +379,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 +394,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."
|
||||||
|
|
||||||
@@ -383,7 +416,7 @@ msgstr "message supprimé"
|
|||||||
msgid "Description (optional)"
|
msgid "Description (optional)"
|
||||||
msgstr "Description (facultatif)"
|
msgstr "Description (facultatif)"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:488
|
#: src/components/DumpCreateModal.tsx:741
|
||||||
msgid "Done"
|
msgid "Done"
|
||||||
msgstr "Terminé"
|
msgstr "Terminé"
|
||||||
|
|
||||||
@@ -391,7 +424,11 @@ 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/components/DumpCreateModal.tsx:847
|
||||||
|
msgid "Drop a file here, or paste one"
|
||||||
|
msgstr "Déposez un fichier ici, ou collez-en un"
|
||||||
|
|
||||||
|
#: 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"
|
||||||
|
|
||||||
@@ -399,11 +436,11 @@ msgstr "Déposez un fichier de remplacement ici"
|
|||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Reco"
|
msgstr "Reco"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:458
|
#: src/components/DumpCreateModal.tsx:958
|
||||||
msgid "Dump it"
|
msgid "Dump it"
|
||||||
msgstr "Recommander"
|
msgstr "Recommander"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:469
|
#: src/components/DumpCreateModal.tsx:718
|
||||||
msgid "Dumped!"
|
msgid "Dumped!"
|
||||||
msgstr "Recommandé !"
|
msgstr "Recommandé !"
|
||||||
|
|
||||||
@@ -426,7 +463,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"
|
||||||
@@ -436,7 +473,7 @@ msgstr "Modifier"
|
|||||||
msgid "Edit {0}"
|
msgid "Edit {0}"
|
||||||
msgstr "Modifier {0}"
|
msgstr "Modifier {0}"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:420
|
#: src/components/DumpCreateModal.tsx:197
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Modifier le titre"
|
msgstr "Modifier le titre"
|
||||||
|
|
||||||
@@ -445,7 +482,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 +492,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 +514,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"
|
||||||
@@ -506,7 +547,7 @@ msgstr "Impossible de générer une invitation"
|
|||||||
msgid "Failed to load"
|
msgid "Failed to load"
|
||||||
msgstr "Chargement échoué"
|
msgstr "Chargement échoué"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:337
|
#: src/components/DumpCreateModal.tsx:750
|
||||||
msgid "Failed to post"
|
msgid "Failed to post"
|
||||||
msgstr "Publication échouée"
|
msgstr "Publication échouée"
|
||||||
|
|
||||||
@@ -544,19 +585,11 @@ msgstr "Erreur lors de la mise à jour du rôle"
|
|||||||
msgid "Feeds"
|
msgid "Feeds"
|
||||||
msgstr "Flux"
|
msgstr "Flux"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:369
|
#: src/components/DumpCreateModal.tsx:789
|
||||||
msgid "Fetching preview…"
|
msgid "Fetching preview…"
|
||||||
msgstr "Récupération de l'aperçu…"
|
msgstr "Récupération de l'aperçu…"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:455
|
#: src/components/DumpCreateModal.tsx:571
|
||||||
msgid "Fetching…"
|
|
||||||
msgstr "Récupération…"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:331
|
|
||||||
msgid "File"
|
|
||||||
msgstr "Fichier"
|
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:239
|
|
||||||
msgid "File too large (max 50 MB)."
|
msgid "File too large (max 50 MB)."
|
||||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||||
|
|
||||||
@@ -632,7 +665,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"
|
||||||
|
|
||||||
@@ -658,6 +691,10 @@ msgstr "Invités"
|
|||||||
msgid "Journal"
|
msgid "Journal"
|
||||||
msgstr "Journal"
|
msgstr "Journal"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:687
|
||||||
|
msgid "Keep editing"
|
||||||
|
msgstr "Continuer l'édition"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1205
|
#: src/pages/UserPublicProfile.tsx:1205
|
||||||
msgid "Light"
|
msgid "Light"
|
||||||
msgstr "Clair"
|
msgstr "Clair"
|
||||||
@@ -666,6 +703,14 @@ msgstr "Clair"
|
|||||||
msgid "Like"
|
msgid "Like"
|
||||||
msgstr "Aimer"
|
msgstr "Aimer"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:764
|
||||||
|
msgid "Link"
|
||||||
|
msgstr "Lien"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:66
|
||||||
|
msgid "Link or file"
|
||||||
|
msgstr "Lien ou fichier"
|
||||||
|
|
||||||
#: src/contexts/WSProvider.tsx:575
|
#: src/contexts/WSProvider.tsx:575
|
||||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
@@ -683,8 +728,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…"
|
||||||
|
|
||||||
@@ -783,7 +828,7 @@ msgstr "nouveau"
|
|||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:301
|
#: src/components/DumpCreateModal.tsx:659
|
||||||
#: src/components/DumpFab.tsx:65
|
#: src/components/DumpFab.tsx:65
|
||||||
#: src/components/DumpFab.tsx:66
|
#: src/components/DumpFab.tsx:66
|
||||||
#: src/pages/UserDumps.tsx:88
|
#: src/pages/UserDumps.tsx:88
|
||||||
@@ -801,6 +846,14 @@ msgstr "Nouveau mot de passe"
|
|||||||
msgid "New playlist"
|
msgid "New playlist"
|
||||||
msgstr "Nouvelle collection"
|
msgstr "Nouvelle collection"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:957
|
||||||
|
msgid "Next →"
|
||||||
|
msgstr "Suivant →"
|
||||||
|
|
||||||
|
#: 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."
|
||||||
@@ -907,11 +960,20 @@ msgstr "Mot de passe mis à jour"
|
|||||||
msgid "Passwords do not match"
|
msgid "Passwords do not match"
|
||||||
msgstr "Les mots de passe ne correspondent pas"
|
msgstr "Les mots de passe ne correspondent pas"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:782
|
||||||
|
msgid "Paste a link…"
|
||||||
|
msgstr "Collez un lien…"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:699
|
||||||
|
msgid "Picked up where you left off."
|
||||||
|
msgstr "Reprise de votre brouillon."
|
||||||
|
|
||||||
#: src/pages/PlaylistDetail.tsx:863
|
#: src/pages/PlaylistDetail.tsx:863
|
||||||
msgid "Playlist title"
|
msgid "Playlist title"
|
||||||
msgstr "Titre de la collection"
|
msgstr "Titre de la collection"
|
||||||
|
|
||||||
#: src/components/AppHeader.tsx:86
|
#: src/components/AppHeader.tsx:86
|
||||||
|
#: src/components/DumpCreateModal.tsx:68
|
||||||
#: src/components/UserMenu.tsx:62
|
#: src/components/UserMenu.tsx:62
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:177
|
||||||
#: src/pages/UserPlaylists.tsx:371
|
#: src/pages/UserPlaylists.tsx:371
|
||||||
@@ -925,10 +987,6 @@ msgstr "Collections"
|
|||||||
msgid "Playlists ({0}{1})"
|
msgid "Playlists ({0}{1})"
|
||||||
msgstr "Collections ({0}{1})"
|
msgstr "Collections ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:236
|
|
||||||
msgid "Please select a file."
|
|
||||||
msgstr "Veuillez sélectionner un fichier."
|
|
||||||
|
|
||||||
#: src/components/CommentThread.tsx:472
|
#: src/components/CommentThread.tsx:472
|
||||||
msgid "Post comment"
|
msgid "Post comment"
|
||||||
msgstr "Publier le commentaire"
|
msgstr "Publier le commentaire"
|
||||||
@@ -939,19 +997,24 @@ msgstr "Publier la réponse"
|
|||||||
|
|
||||||
#: src/components/CommentThread.tsx:396
|
#: src/components/CommentThread.tsx:396
|
||||||
#: src/components/CommentThread.tsx:473
|
#: src/components/CommentThread.tsx:473
|
||||||
|
#: src/components/DumpCreateModal.tsx:951
|
||||||
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:123
|
||||||
#: 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é"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:78
|
#: src/components/form/SegmentedField.tsx:81
|
||||||
#: src/pages/PlaylistDetail.tsx:902
|
#: src/pages/PlaylistDetail.tsx:902
|
||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Privé"
|
msgstr "Privé"
|
||||||
@@ -961,16 +1024,20 @@ msgstr "Privé"
|
|||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
|
|
||||||
#: src/components/form/SegmentedField.tsx:77
|
#: src/components/form/SegmentedField.tsx:80
|
||||||
#: src/pages/PlaylistDetail.tsx:895
|
#: src/pages/PlaylistDetail.tsx:895
|
||||||
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 +1055,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 +1075,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 +1101,18 @@ 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/components/DumpCreateModal.tsx:208
|
||||||
#: src/pages/DumpEdit.tsx:174
|
#: src/components/DumpCreateModal.tsx:209
|
||||||
|
msgid "Reset to the suggested title"
|
||||||
|
msgstr "Rétablir le titre suggéré"
|
||||||
|
|
||||||
|
#: src/pages/Dump.tsx:285
|
||||||
|
#: src/pages/DumpEdit.tsx:180
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
|
|
||||||
@@ -1050,8 +1122,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 +1132,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
|
||||||
@@ -1124,6 +1196,10 @@ msgstr "identifiant"
|
|||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:706
|
||||||
|
msgid "Start fresh"
|
||||||
|
msgstr "Repartir de zéro"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:1155
|
#: src/pages/UserPublicProfile.tsx:1155
|
||||||
msgid "Style"
|
msgid "Style"
|
||||||
msgstr "Style"
|
msgstr "Style"
|
||||||
@@ -1152,17 +1228,17 @@ 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:174
|
||||||
#: 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"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:237
|
#: src/components/DumpCreateModal.tsx:569
|
||||||
msgid "Title is required."
|
msgid "Title is required."
|
||||||
msgstr "Un titre est requis."
|
msgstr "Un titre est requis."
|
||||||
|
|
||||||
@@ -1191,7 +1267,12 @@ msgstr "Ne plus suivre la collection"
|
|||||||
msgid "Upload failed"
|
msgid "Upload failed"
|
||||||
msgstr "Envoi échoué"
|
msgstr "Envoi échoué"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:456
|
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
|
||||||
|
#: src/components/DumpCreateModal.tsx:642
|
||||||
|
msgid "Uploading {0}%"
|
||||||
|
msgstr "Envoi {0} %"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:954
|
||||||
msgid "Uploading…"
|
msgid "Uploading…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
@@ -1209,12 +1290,11 @@ msgstr "Voté"
|
|||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Votés ({0}{1})"
|
msgstr "Votés ({0}{1})"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:344
|
#: src/pages/DumpEdit.tsx:421
|
||||||
#: src/pages/DumpEdit.tsx:412
|
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:223
|
#: src/components/DumpCreateModal.tsx:553
|
||||||
msgid "URL is required."
|
msgid "URL is required."
|
||||||
msgstr "L'URL est obligatoire."
|
msgstr "L'URL est obligatoire."
|
||||||
|
|
||||||
@@ -1251,12 +1331,12 @@ msgstr "Utilisateurs"
|
|||||||
msgid "View all →"
|
msgid "View all →"
|
||||||
msgstr "Tout voir →"
|
msgstr "Tout voir →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:471
|
#: src/components/DumpCreateModal.tsx:720
|
||||||
msgid "View dump →"
|
msgid "View dump →"
|
||||||
msgstr "Voir la reco →"
|
msgstr "Voir la reco →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:434
|
#: src/components/DumpCreateModal.tsx:904
|
||||||
#: 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 ?"
|
||||||
|
|
||||||
@@ -1265,8 +1345,16 @@ msgstr "Pourquoi on en voudrait ?"
|
|||||||
msgid "Who am I?"
|
msgid "Who am I?"
|
||||||
msgstr "Qui suis-je ?"
|
msgstr "Qui suis-je ?"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:433
|
#: src/components/DumpCreateModal.tsx:920
|
||||||
#: src/pages/DumpEdit.tsx:436
|
msgid "Who can see it"
|
||||||
|
msgstr "Qui peut la voir"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:67
|
||||||
|
msgid "Why & where"
|
||||||
|
msgstr "Pourquoi & où"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:903
|
||||||
|
#: src/pages/DumpEdit.tsx:445
|
||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Pourquoi ?"
|
msgstr "Pourquoi ?"
|
||||||
|
|
||||||
@@ -1279,6 +1367,10 @@ msgstr "Écrire une réponse…"
|
|||||||
msgid "Yesterday"
|
msgid "Yesterday"
|
||||||
msgstr "Hier"
|
msgstr "Hier"
|
||||||
|
|
||||||
|
#: src/components/DumpCreateModal.tsx:254
|
||||||
|
msgid "You can post it anyway."
|
||||||
|
msgstr "Vous pouvez la publier quand même."
|
||||||
|
|
||||||
#: src/pages/Notifications.tsx:384
|
#: src/pages/Notifications.tsx:384
|
||||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||||
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||||
|
|||||||
33
src/model.ts
33
src/model.ts
@@ -41,7 +41,12 @@ export interface RichContent {
|
|||||||
siteName?: string;
|
siteName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** A real preview image — never a favicon. Absent means "no artwork". */
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
/** The page's own icon, used as the placeholder's glyph. */
|
||||||
|
faviconUrl?: string;
|
||||||
|
/** The page's declared brand color, normalized to `#rrggbb`. */
|
||||||
|
accentColor?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
embedUrl?: string;
|
embedUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -69,6 +74,32 @@ export interface Dump {
|
|||||||
|
|
||||||
export type RawDump = WithStringDate<Dump>;
|
export type RawDump = WithStringDate<Dump>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/preview`. `reached` is false when the page could not be fetched, in
|
||||||
|
* which case `richContent` is a hostname-only stub rather than real metadata.
|
||||||
|
*/
|
||||||
|
export interface UrlPreviewResponse {
|
||||||
|
reached: boolean;
|
||||||
|
richContent: RichContent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An existing dump on the same URL, as returned by `GET /api/dumps/by-url`. */
|
||||||
|
export interface DumpUrlMatch {
|
||||||
|
id: string;
|
||||||
|
slug?: string;
|
||||||
|
title: string;
|
||||||
|
username: string;
|
||||||
|
createdAt: Date;
|
||||||
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RawDumpUrlMatch = WithStringDate<DumpUrlMatch>;
|
||||||
|
|
||||||
|
export function deserializeDumpUrlMatch(raw: RawDumpUrlMatch): DumpUrlMatch {
|
||||||
|
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||||
|
}
|
||||||
|
|
||||||
export function deserializeDump(raw: RawDump): Dump {
|
export function deserializeDump(raw: RawDump): Dump {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
@@ -666,6 +697,8 @@ export interface RegisterRequest {
|
|||||||
|
|
||||||
export interface CreateUrlDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||||
|
title?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
categoryIds?: string[];
|
categoryIds?: string[];
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function DumpEdit() {
|
|||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||||
const [thumbUploading, setThumbUploading] = useState(false);
|
const [thumbUploading, setThumbUploading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,15 +86,20 @@ export function DumpEdit() {
|
|||||||
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
||||||
|
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
setRefreshError(null);
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(
|
const res = await authFetch(
|
||||||
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
|
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||||
|
} else {
|
||||||
|
setRefreshError(apiResponse.error.message);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setRefreshError(friendlyFetchError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
@@ -235,6 +241,9 @@ export function DumpEdit() {
|
|||||||
: <Trans>Refresh metadata</Trans>}
|
: <Trans>Refresh metadata</Trans>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{refreshError && (
|
||||||
|
<p className="dump-edit-refresh-error">{refreshError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DumpEditForm
|
<DumpEditForm
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -315,6 +315,20 @@
|
|||||||
[data-style="geocities"] .playlist-card-delete-btn,
|
[data-style="geocities"] .playlist-card-delete-btn,
|
||||||
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
||||||
[data-style="geocities"] .emoji-picker-close-btn,
|
[data-style="geocities"] .emoji-picker-close-btn,
|
||||||
|
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
|
||||||
|
unstyled gap in a theme built on saturation. */
|
||||||
|
[data-style="geocities"] {
|
||||||
|
--thumb-tint-strength: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="geocities"] .thumb-placeholder {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(255, 255, 255, 0.14) 0 8px,
|
||||||
|
rgba(0, 0, 0, 0.14) 8px 16px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[data-style="geocities"] .btn--ghost,
|
[data-style="geocities"] .btn--ghost,
|
||||||
[data-style="geocities"] .new-item-toggle,
|
[data-style="geocities"] .new-item-toggle,
|
||||||
[data-style="geocities"] .audio-player-btn,
|
[data-style="geocities"] .audio-player-btn,
|
||||||
@@ -482,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,
|
||||||
@@ -587,10 +590,17 @@
|
|||||||
[data-style="nyt"] .rich-content-compact-icon,
|
[data-style="nyt"] .rich-content-compact-icon,
|
||||||
[data-style="nyt"] .journal-card-glyph,
|
[data-style="nyt"] .journal-card-glyph,
|
||||||
[data-style="nyt"] .fdz__file-icon,
|
[data-style="nyt"] .fdz__file-icon,
|
||||||
|
[data-style="nyt"] .thumb-placeholder-icon,
|
||||||
[data-style="nyt"] .empty-state {
|
[data-style="nyt"] .empty-state {
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Monochrome by design — a dumped site's brand color doesn't get to tint the
|
||||||
|
page, so the placeholder is the theme's own surface with a gray glyph. */
|
||||||
|
[data-style="nyt"] {
|
||||||
|
--thumb-tint-strength: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tighter, continuous ruled feed (no inter-item gap; the hairline
|
/* ── Tighter, continuous ruled feed (no inter-item gap; the hairline
|
||||||
bottom rules read as one column). Even vertical padding per item. ── */
|
bottom rules read as one column). Even vertical padding per item. ── */
|
||||||
[data-style="nyt"] .dump-feed {
|
[data-style="nyt"] .dump-feed {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
85
src/utils/dumpDraft.ts
Normal file
85
src/utils/dumpDraft.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Persistence for the half-written dump in the create modal.
|
||||||
|
*
|
||||||
|
* The modal is dismissable by Escape, a backdrop click and the ✕, and it holds
|
||||||
|
* a URL, a title, a rich-text "why", categories and a visibility choice — so a
|
||||||
|
* stray keystroke used to destroy a few minutes of writing. Everything except
|
||||||
|
* the attached file (a `File` cannot survive a reload) is mirrored here on each
|
||||||
|
* change and restored the next time the modal opens; a successful post clears
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* Deliberately a single draft rather than one per tab: someone who closed the
|
||||||
|
* modal by accident wants the thing they were just writing, wherever they
|
||||||
|
* reopen it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gerbeur.dumpDraft";
|
||||||
|
|
||||||
|
export interface DumpDraft {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
/** Whether `title` was typed by the poster, rather than taken from the preview. */
|
||||||
|
titleFromUser: boolean;
|
||||||
|
comment: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
categoryIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DUMP_DRAFT: DumpDraft = {
|
||||||
|
url: "",
|
||||||
|
title: "",
|
||||||
|
titleFromUser: false,
|
||||||
|
comment: "",
|
||||||
|
isPublic: true,
|
||||||
|
categoryIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether a draft holds anything worth restoring or warning about. */
|
||||||
|
export function isDumpDraftEmpty(draft: DumpDraft): boolean {
|
||||||
|
return !draft.url.trim() && !draft.comment.trim() &&
|
||||||
|
!(draft.titleFromUser && draft.title.trim()) &&
|
||||||
|
draft.categoryIds.length === 0 && draft.isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDumpDraft(): DumpDraft | null {
|
||||||
|
let raw: string | null;
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null; // Storage disabled (private mode, blocked cookies) — no drafts.
|
||||||
|
}
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<DumpDraft>;
|
||||||
|
const draft: DumpDraft = {
|
||||||
|
url: typeof parsed.url === "string" ? parsed.url : "",
|
||||||
|
title: typeof parsed.title === "string" ? parsed.title : "",
|
||||||
|
titleFromUser: parsed.titleFromUser === true,
|
||||||
|
comment: typeof parsed.comment === "string" ? parsed.comment : "",
|
||||||
|
isPublic: parsed.isPublic !== false,
|
||||||
|
categoryIds: Array.isArray(parsed.categoryIds)
|
||||||
|
? parsed.categoryIds.filter((id): id is string => typeof id === "string")
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
return isDumpDraftEmpty(draft) ? null : draft;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDumpDraft(draft: DumpDraft): void {
|
||||||
|
try {
|
||||||
|
if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY);
|
||||||
|
else localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
|
||||||
|
} catch {
|
||||||
|
// Storage full or unavailable — the draft is a convenience, never a
|
||||||
|
// precondition for posting, so a failure here is silent by design.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDumpDraft(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch { /* see saveDumpDraft */ }
|
||||||
|
}
|
||||||
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")}`;
|
||||||
|
}
|
||||||
@@ -3,3 +3,14 @@ export function formatBytes(bytes: number): string {
|
|||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file's name as a readable dump title: the extension goes (it is noise in a
|
||||||
|
* feed, and the dump already carries its MIME type), and separator runs become
|
||||||
|
* spaces, so `holiday_photo-01.final.jpg` reads as `holiday photo-01.final`.
|
||||||
|
* Names that are *only* an extension (`.gitignore`) are left alone.
|
||||||
|
*/
|
||||||
|
export function titleFromFileName(name: string): string {
|
||||||
|
const withoutExt = name.replace(/(?!^)\.[^.]+$/, "");
|
||||||
|
return withoutExt.replace(/_+/g, " ").trim() || name;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export interface JournalEntry {
|
|||||||
|
|
||||||
/** A dump that can carry a real preview image (file image or rich thumbnail). */
|
/** A dump that can carry a real preview image (file image or rich thumbnail). */
|
||||||
export function hasThumbnail(dump: Dump): boolean {
|
export function hasThumbnail(dump: Dump): boolean {
|
||||||
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true;
|
if (dump.kind === "file") {
|
||||||
|
const mime = dump.fileMime ?? "";
|
||||||
|
// Videos count: GET /api/thumbnails/:dumpId grabs a still with ffmpeg on
|
||||||
|
// first request and caches it, so there's art to show without any upload.
|
||||||
|
if (mime.startsWith("image/") || mime.startsWith("video/")) return true;
|
||||||
|
}
|
||||||
if (dump.thumbnailMime) return true;
|
if (dump.thumbnailMime) return true;
|
||||||
return !!dump.richContent?.thumbnailUrl;
|
return !!dump.richContent?.thumbnailUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/utils/proxyImage.ts
Normal file
57
src/utils/proxyImage.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
|
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
|
||||||
|
function apiOrigin(): string {
|
||||||
|
try {
|
||||||
|
return new URL(API_URL, location.href).origin;
|
||||||
|
} catch {
|
||||||
|
return location.origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `url` points at a third party, i.e. whether proxying it would
|
||||||
|
* actually change anything. Our own files and thumbnails are served with no
|
||||||
|
* `Referer` checks, so retrying them through the proxy is pure waste.
|
||||||
|
*/
|
||||||
|
export function canProxyImage(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, location.href);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||||
|
return u.origin !== apiOrigin() && u.origin !== location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyImageOptions {
|
||||||
|
/**
|
||||||
|
* Proxy even when the URL is already HTTPS. Used as a retry after a direct
|
||||||
|
* load fails — typically hotlink protection rejecting our cross-site
|
||||||
|
* `Referer`, which the server-side fetch doesn't send.
|
||||||
|
*/
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route an external image through `GET /api/proxy-image`.
|
||||||
|
*
|
||||||
|
* By default only HTTP URLs are proxied, so they don't trigger mixed-content
|
||||||
|
* blocks when the frontend is served over HTTPS. `force` opts an HTTPS URL in
|
||||||
|
* as well.
|
||||||
|
*/
|
||||||
|
export function proxyImageUrl(url: string, opts: ProxyImageOptions = {}): string {
|
||||||
|
let u: URL;
|
||||||
|
try {
|
||||||
|
u = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return url; // relative or same-origin path — nothing to proxy
|
||||||
|
}
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") return url;
|
||||||
|
|
||||||
|
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||||
|
if (isLocal) return url;
|
||||||
|
if (!opts.force && u.protocol !== "http:") return url;
|
||||||
|
|
||||||
|
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
68
src/utils/thumbnailTint.ts
Normal file
68
src/utils/thumbnailTint.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Color and lettering for generated thumbnail placeholders.
|
||||||
|
*
|
||||||
|
* The tint is never painted on its own — it's mixed into the theme's surface
|
||||||
|
* color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for
|
||||||
|
* any site color, in every style, in both light and dark.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `accentColor` comes from a dumped page's `theme-color`, so it's
|
||||||
|
* attacker-controlled. Custom properties bypass CSS value parsing and get
|
||||||
|
* substituted verbatim wherever the var is used, so the canonical form is
|
||||||
|
* re-checked here before it ever reaches a style object.
|
||||||
|
*/
|
||||||
|
export function isSafeHex(value: string | undefined): value is string {
|
||||||
|
return !!value && /^#[0-9a-f]{6}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a — small, stable, and well spread across the hue circle. */
|
||||||
|
function hashString(seed: string): number {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash ^= seed.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable hue for a string, so the same site always gets the same tint. */
|
||||||
|
export function hueFromString(seed: string): string {
|
||||||
|
return `hsl(${hashString(seed) % 360} 55% 55%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnameOf(url: string | undefined): string | undefined {
|
||||||
|
if (!url) return undefined;
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's own brand color when it declared a usable one, otherwise a hue
|
||||||
|
* derived from its hostname — so every card gets a tint, including rows saved
|
||||||
|
* before accent extraction existed. File dumps have no hostname to hash and
|
||||||
|
* pass `seed` (their filename) instead.
|
||||||
|
*/
|
||||||
|
export function tintFor(
|
||||||
|
{ accentColor, url, seed }: {
|
||||||
|
accentColor?: string;
|
||||||
|
url?: string;
|
||||||
|
seed?: string;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
if (isSafeHex(accentColor)) return accentColor;
|
||||||
|
return hueFromString(seed ?? hostnameOf(url) ?? url ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The letter drawn when there's no favicon to show. */
|
||||||
|
export function initialsFor(
|
||||||
|
siteName: string | undefined,
|
||||||
|
url: string | undefined,
|
||||||
|
): string {
|
||||||
|
const source = siteName?.trim() || hostnameOf(url) || url || "";
|
||||||
|
const letter = [...source].find((ch) => /\p{L}|\p{N}/u.test(ch));
|
||||||
|
return (letter ?? "?").toUpperCase();
|
||||||
|
}
|
||||||
@@ -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