Compare commits
3 Commits
e2ed493bdd
...
d70761ecae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70761ecae | ||
|
|
084ebcb627 | ||
|
|
e9ebc3fee6 |
@@ -10,6 +10,7 @@ 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 bandcampRouter from "./routes/bandcamp.ts";
|
||||||
|
import youtubeRouter from "./routes/youtube.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";
|
||||||
@@ -76,6 +77,10 @@ app.use(
|
|||||||
bandcampRouter.routes(),
|
bandcampRouter.routes(),
|
||||||
bandcampRouter.allowedMethods(),
|
bandcampRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
youtubeRouter.routes(),
|
||||||
|
youtubeRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(
|
app.use(
|
||||||
playlistsRouter.routes(),
|
playlistsRouter.routes(),
|
||||||
playlistsRouter.allowedMethods(),
|
playlistsRouter.allowedMethods(),
|
||||||
|
|||||||
20
api/routes/youtube.ts
Normal file
20
api/routes/youtube.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { resolveYouTubePlaylist } from "../services/youtube-playlist-service.ts";
|
||||||
|
|
||||||
|
const youtubeRouter = new Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a YouTube playlist ID to its tracklist (titles and lengths).
|
||||||
|
*
|
||||||
|
* Public, like /api/bandcamp/tracks: it only reads a public page. The client
|
||||||
|
* treats any failure as "show the embed without a tracklist".
|
||||||
|
*/
|
||||||
|
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
|
||||||
|
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
||||||
|
const playlist = await resolveYouTubePlaylist(listId);
|
||||||
|
|
||||||
|
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
||||||
|
ctx.response.body = { success: true, data: playlist };
|
||||||
|
});
|
||||||
|
|
||||||
|
export default youtubeRouter;
|
||||||
199
api/services/youtube-playlist-service.ts
Normal file
199
api/services/youtube-playlist-service.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a YouTube playlist to its tracklist, at play time.
|
||||||
|
*
|
||||||
|
* Playback itself stays in the iframe embed — YouTube plays the list and
|
||||||
|
* advances through it on its own. This only supplies what the embed doesn't
|
||||||
|
* expose: every entry's title and length, so the global player can draw a
|
||||||
|
* tracklist the way it does for a Bandcamp album. The client syncs the
|
||||||
|
* highlighted row and row clicks through the IFrame Player API.
|
||||||
|
*
|
||||||
|
* The data comes from the `ytInitialData` blob on the public playlist page. Its
|
||||||
|
* shape is undocumented and changes, so the parse is best-effort and accepts
|
||||||
|
* both the current `lockupViewModel` rows and the older `playlistVideoRenderer`
|
||||||
|
* ones. Only the first page (100 entries) is read; the client appends any
|
||||||
|
* further IDs the player reports, untitled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface YouTubePlaylistTrack {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
/** Seconds. */
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubePlaylist {
|
||||||
|
listId: string;
|
||||||
|
title?: string;
|
||||||
|
tracks: YouTubePlaylistTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titles and lengths rarely change; a stale entry only mislabels a row. */
|
||||||
|
const TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const MAX_ENTRIES = 200;
|
||||||
|
|
||||||
|
const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
|
||||||
|
|
||||||
|
/** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */
|
||||||
|
const LIST_ID_RE = /^[\w-]{2,64}$/;
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
|
"Accept": "text/html",
|
||||||
|
"Accept-Language": "en-US,en;q=0.8",
|
||||||
|
// Pre-answered consent: without it EU requests are redirected to
|
||||||
|
// consent.youtube.com and never see the playlist.
|
||||||
|
"Cookie": "SOCS=CAI",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "3:55", "1:02:03" → seconds. */
|
||||||
|
function parseClock(text: string): number | undefined {
|
||||||
|
if (!/^\d+(?::\d{1,2}){1,2}$/.test(text)) return undefined;
|
||||||
|
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first walk collecting every value stored under `key`. */
|
||||||
|
function collect(node: unknown, key: string, out: unknown[]): void {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) collect(child, key, out);
|
||||||
|
} else if (node && typeof node === "object") {
|
||||||
|
for (const [k, v] of Object.entries(node)) {
|
||||||
|
if (k === key) out.push(v);
|
||||||
|
else collect(v, key, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstString(node: unknown, key: string): string | undefined {
|
||||||
|
const found: unknown[] = [];
|
||||||
|
collect(node, key, found);
|
||||||
|
return found.find((v): v is string => typeof v === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
type Json = any;
|
||||||
|
|
||||||
|
function fromLockup(lockup: Json): YouTubePlaylistTrack | null {
|
||||||
|
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
|
||||||
|
const videoId = lockup.contentId;
|
||||||
|
const title = lockup.metadata?.lockupMetadataViewModel?.title?.content;
|
||||||
|
if (typeof videoId !== "string" || typeof title !== "string") return null;
|
||||||
|
const badges: unknown[] = [];
|
||||||
|
collect(lockup.contentImage, "thumbnailBadgeViewModel", badges);
|
||||||
|
const duration = badges
|
||||||
|
.map((b) => (b as Json)?.text)
|
||||||
|
.filter((t): t is string => typeof t === "string")
|
||||||
|
.map(parseClock)
|
||||||
|
.find((d) => d !== undefined);
|
||||||
|
return { videoId, title, duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null {
|
||||||
|
const videoId = r?.videoId;
|
||||||
|
const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText;
|
||||||
|
if (typeof videoId !== "string" || typeof title !== "string") return null;
|
||||||
|
const secs = Number(r.lengthSeconds);
|
||||||
|
return {
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
duration: Number.isFinite(secs) && secs > 0 ? secs : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlaylistPage(
|
||||||
|
html: string,
|
||||||
|
listId: string,
|
||||||
|
): YouTubePlaylist | null {
|
||||||
|
const start = html.indexOf("var ytInitialData = ");
|
||||||
|
if (start < 0) return null;
|
||||||
|
const end = html.indexOf(";</script>", start);
|
||||||
|
if (end < 0) return null;
|
||||||
|
|
||||||
|
let data: Json;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockups: unknown[] = [];
|
||||||
|
collect(data?.contents, "lockupViewModel", lockups);
|
||||||
|
const legacy: unknown[] = [];
|
||||||
|
collect(data?.contents, "playlistVideoRenderer", legacy);
|
||||||
|
|
||||||
|
// A video can appear in a playlist twice; both rows are kept, so the list
|
||||||
|
// stays positional and matches what the embed plays.
|
||||||
|
const tracks = [
|
||||||
|
...lockups.map(fromLockup),
|
||||||
|
...legacy.map(fromLegacyRenderer),
|
||||||
|
].filter((t): t is YouTubePlaylistTrack => t !== null);
|
||||||
|
if (tracks.length === 0) return null;
|
||||||
|
|
||||||
|
const title = data?.metadata?.playlistMetadataRenderer?.title ??
|
||||||
|
firstString(data?.header, "content");
|
||||||
|
return { listId, title, tracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveYouTubePlaylist(
|
||||||
|
listId: string,
|
||||||
|
): Promise<YouTubePlaylist> {
|
||||||
|
if (!LIST_ID_RE.test(listId)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Invalid playlist ID",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = cache.get(listId);
|
||||||
|
if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
|
||||||
|
if (hit) cache.delete(listId);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 8000);
|
||||||
|
let html: string;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`,
|
||||||
|
{ headers: HEADERS, signal: controller.signal },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
await res.body?.cancel();
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
502,
|
||||||
|
`YouTube returned ${res.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html = await res.text();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof APIException) throw err;
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
502,
|
||||||
|
"Could not reach YouTube",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parsePlaylistPage(html, listId);
|
||||||
|
if (!parsed) {
|
||||||
|
// Private, deleted, or auto-generated mixes (RD…) that have no playlist
|
||||||
|
// page — the client just shows the embed without a tracklist.
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"No tracks on this playlist",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache.size >= MAX_ENTRIES) {
|
||||||
|
const oldest = cache.keys().next().value;
|
||||||
|
if (oldest !== undefined) cache.delete(oldest);
|
||||||
|
}
|
||||||
|
cache.set(listId, { value: parsed, at: Date.now() });
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
95
src/App.css
95
src/App.css
@@ -95,8 +95,8 @@
|
|||||||
.dump-post-header,
|
.dump-post-header,
|
||||||
.dump-rich-content,
|
.dump-rich-content,
|
||||||
.form-page-header,
|
.form-page-header,
|
||||||
.dump-edit-preview,
|
.dump-edit-panel,
|
||||||
.form-page .form,
|
.dump-edit-footer,
|
||||||
.playlist-detail-header,
|
.playlist-detail-header,
|
||||||
.comment-node-inner {
|
.comment-node-inner {
|
||||||
box-shadow: var(--panel-ring), var(--card-shadow);
|
box-shadow: var(--panel-ring), var(--card-shadow);
|
||||||
@@ -1407,6 +1407,10 @@ a.global-player-title:hover {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The video already claims most of the viewport, so its list stays shorter. */
|
||||||
|
.global-player--youtube .global-player-tracks {
|
||||||
|
max-height: 30vh;
|
||||||
|
}
|
||||||
.global-player-tracks {
|
.global-player-tracks {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1434,6 +1438,14 @@ a.global-player-title:hover {
|
|||||||
.global-player-track > button:hover {
|
.global-player-track > button:hover {
|
||||||
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
|
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
|
||||||
}
|
}
|
||||||
|
/* A YouTube tracklist is only clickable once its player API answers; until
|
||||||
|
then the rows are a table of contents. */
|
||||||
|
.global-player-track > button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.global-player-track > button:disabled:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
/* An accent border rather than a filled pill, so no theme has to un-round it. */
|
/* An accent border rather than a filled pill, so no theme has to un-round it. */
|
||||||
.global-player-track.is-current > button {
|
.global-player-track.is-current > button {
|
||||||
border-left-color: var(--color-accent);
|
border-left-color: var(--color-accent);
|
||||||
@@ -3032,20 +3044,38 @@ body.has-player .chat-fab {
|
|||||||
animation: page-enter 0.2s ease both;
|
animation: page-enter 0.2s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The edit form spans the page grid and lays out its own panels: the dump
|
||||||
|
itself (preview, thumbnail, source) beside its presentation (title, why,
|
||||||
|
categories, visibility), with a full-width action bar underneath so both
|
||||||
|
columns end on the same line. Only the header and footer are rounded. */
|
||||||
|
.dump-edit-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 860px) {
|
@media (min-width: 860px) {
|
||||||
.form-page--two-col {
|
.form-page--two-col,
|
||||||
|
.dump-edit-form {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
.form-page--two-col .form-page-header {
|
.form-page--two-col .form-page-header,
|
||||||
|
.dump-edit-form,
|
||||||
|
.dump-edit-footer {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.form-page--two-col .dump-edit-preview {
|
/* "Why?" soaks up whatever height the source column adds, so a tall
|
||||||
border-radius: 0 0 0 var(--radius-xl);
|
preview doesn't leave a hole under the details. */
|
||||||
|
.dump-edit-panel--details .form-field:has(.mention-textarea-wrap) {
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
/* Higher specificity than the generic `.form-page .form` below so the
|
.dump-edit-panel--details .mention-textarea-wrap {
|
||||||
form's bottom-left stays square where it meets the preview. */
|
flex: 1 1 auto;
|
||||||
.form-page.form-page--two-col .form {
|
display: flex;
|
||||||
border-radius: 0 0 var(--radius-xl) 0;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.dump-edit-panel--details .mention-textarea-wrap textarea {
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3074,13 +3104,31 @@ body.has-player .chat-fab {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-edit-preview {
|
.dump-edit-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-edit-preview {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-edit-footer {
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
background: var(--color-surface);
|
||||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-edit-footer .form-actions {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dump-edit-refresh {
|
.dump-edit-refresh {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -3097,12 +3145,6 @@ body.has-player .chat-fab {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-page .form {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6170,6 +6212,25 @@ body.has-player .chat-fab {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: var(--z-toast);
|
z-index: var(--z-toast);
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
|
/* The inline `transform` places it above the anchor, so the entrance rides
|
||||||
|
on the independent `translate`/`scale` properties, which compose with it.
|
||||||
|
A short delay keeps it from flashing while the pointer sweeps across. */
|
||||||
|
transform-origin: 50% 100%;
|
||||||
|
animation: tooltip-in 160ms cubic-bezier(0.2, 0.7, 0.3, 1) 120ms both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltip-in {
|
||||||
|
from { opacity: 0; translate: 0 4px; scale: 0.96; }
|
||||||
|
to { opacity: 1; translate: 0 0; scale: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tooltip {
|
||||||
|
animation-name: tooltip-fade;
|
||||||
|
}
|
||||||
|
@keyframes tooltip-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Search bar (in header center slot) ── */
|
/* ── Search bar (in header center slot) ── */
|
||||||
|
|||||||
@@ -10,9 +10,40 @@ import {
|
|||||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
import { fmt } from "../utils/duration.ts";
|
import { fmt } from "../utils/duration.ts";
|
||||||
import Thumbnail from "./Thumbnail.tsx";
|
import Thumbnail from "./Thumbnail.tsx";
|
||||||
|
import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts";
|
||||||
|
import { embedListId, withJsApi } from "../utils/youtube.ts";
|
||||||
|
|
||||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||||
|
|
||||||
|
/** One row of the tracklist, whether it comes from the queue or from a
|
||||||
|
* YouTube playlist the embed is holding. */
|
||||||
|
function TrackRow(
|
||||||
|
{ num, title, duration, isCurrent, disabled, onPlay, rowRef }: {
|
||||||
|
num: number | string;
|
||||||
|
title: string;
|
||||||
|
duration?: number;
|
||||||
|
isCurrent: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPlay: () => void;
|
||||||
|
rowRef?: React.Ref<HTMLLIElement>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={rowRef}
|
||||||
|
className={`global-player-track${isCurrent ? " is-current" : ""}`}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={onPlay} disabled={disabled}>
|
||||||
|
<span className="global-player-track-num">{num}</span>
|
||||||
|
<span className="global-player-track-title">{title}</span>
|
||||||
|
{duration !== undefined && (
|
||||||
|
<span className="global-player-track-dur">{fmt(duration)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The stored embedUrl is the canonical, non-playing form — it is also rendered
|
// The stored embedUrl is the canonical, non-playing form — it is also rendered
|
||||||
// outside the player, so the autoplay parameter is added here at playback time
|
// outside the player, so the autoplay parameter is added here at playback time
|
||||||
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
|
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
|
||||||
@@ -21,16 +52,20 @@ type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
|||||||
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
|
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
|
||||||
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
|
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
|
||||||
function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||||
if (!autoplay) return item.embedUrl;
|
// A playlist embed is also driven from the outside — see useYouTubePlaylist.
|
||||||
|
const base = item.type === "youtube" && embedListId(item.embedUrl)
|
||||||
|
? withJsApi(item.embedUrl)
|
||||||
|
: item.embedUrl;
|
||||||
|
if (!autoplay) return base;
|
||||||
try {
|
try {
|
||||||
const url = new URL(item.embedUrl);
|
const url = new URL(base);
|
||||||
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
|
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
|
||||||
else if (item.type === "soundcloud") {
|
else if (item.type === "soundcloud") {
|
||||||
url.searchParams.set("auto_play", "true");
|
url.searchParams.set("auto_play", "true");
|
||||||
} else return item.embedUrl;
|
} else return base;
|
||||||
return url.toString();
|
return url.toString();
|
||||||
} catch {
|
} catch {
|
||||||
return item.embedUrl; // malformed stored URL — hand it to the iframe as-is
|
return base; // malformed stored URL — hand it to the iframe as-is
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +91,19 @@ export function GlobalPlayer() {
|
|||||||
onError,
|
onError,
|
||||||
} = useContext(PlayerContext);
|
} = useContext(PlayerContext);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const currentRowRef = useRef<HTMLLIElement>(null);
|
const currentRowRef = useRef<HTMLLIElement>(null);
|
||||||
const [reduced, setReduced] = useState(false);
|
const [reduced, setReduced] = useState(false);
|
||||||
const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
|
const [prevKey, setPrevKey] = useState(
|
||||||
|
current ? playerItemKey(current) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A YouTube playlist keeps its own list inside the iframe, so it is asked
|
||||||
|
// for one rather than being expanded into the queue.
|
||||||
|
const ytEmbedUrl = current?.kind === "embed" && current.type === "youtube"
|
||||||
|
? current.embedUrl
|
||||||
|
: null;
|
||||||
|
const yt = useYouTubePlaylist(ytEmbedUrl, iframeRef);
|
||||||
|
|
||||||
const currentKey = current ? playerItemKey(current) : null;
|
const currentKey = current ? playerItemKey(current) : null;
|
||||||
if (prevKey !== currentKey) {
|
if (prevKey !== currentKey) {
|
||||||
@@ -93,10 +138,10 @@ export function GlobalPlayer() {
|
|||||||
};
|
};
|
||||||
}, [current]);
|
}, [current]);
|
||||||
|
|
||||||
// Keep the playing row visible as the queue advances on its own.
|
// Keep the playing row visible as the list advances on its own.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentRowRef.current?.scrollIntoView({ block: "nearest" });
|
currentRowRef.current?.scrollIntoView({ block: "nearest" });
|
||||||
}, [queueIndex]);
|
}, [queueIndex, yt.index]);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
@@ -106,14 +151,23 @@ export function GlobalPlayer() {
|
|||||||
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
|
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
|
||||||
: current.type;
|
: current.type;
|
||||||
|
|
||||||
const title = current.title ??
|
const showQueue = queue.length > 1;
|
||||||
|
const ytTracks = !showQueue && yt.rows.length > 1 ? yt.rows : null;
|
||||||
|
const showTransport = showQueue || ytTracks !== null;
|
||||||
|
|
||||||
|
// A playing playlist names the video in the header, the way a Bandcamp queue
|
||||||
|
// names the track, and demotes the dump's own title to the line below.
|
||||||
|
const ytRow = ytTracks && yt.index >= 0 ? ytTracks[yt.index] : null;
|
||||||
|
const itemTitle = current.title ??
|
||||||
(current.kind === "embed"
|
(current.kind === "embed"
|
||||||
? current.embedUrl
|
? current.embedUrl
|
||||||
: current.kind === "file"
|
: current.kind === "file"
|
||||||
? current.fileUrl
|
? current.fileUrl
|
||||||
: current.streamUrl);
|
: current.streamUrl);
|
||||||
|
const title = ytRow?.title ?? itemTitle;
|
||||||
const showQueue = queue.length > 1;
|
const subtitle = ytRow?.title && ytRow.title !== itemTitle
|
||||||
|
? itemTitle
|
||||||
|
: current.subtitle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -140,29 +194,33 @@ export function GlobalPlayer() {
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
: <span className="global-player-title">{title}</span>}
|
: <span className="global-player-title">{title}</span>}
|
||||||
{current.subtitle && (
|
{subtitle && (
|
||||||
<span className="global-player-subtitle">{current.subtitle}</span>
|
<span className="global-player-subtitle">{subtitle}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showQueue && (
|
{showTransport && (
|
||||||
<div className="global-player-transport">
|
<div className="global-player-transport">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--ghost"
|
className="btn btn--ghost"
|
||||||
onClick={previous}
|
onClick={ytTracks ? yt.previous : previous}
|
||||||
disabled={!hasPrevious}
|
disabled={ytTracks ? !yt.ready || yt.index === 0 : !hasPrevious}
|
||||||
aria-label={t`Previous track`}
|
aria-label={t`Previous track`}
|
||||||
>
|
>
|
||||||
⏮
|
⏮
|
||||||
</button>
|
</button>
|
||||||
<span className="global-player-position">
|
<span className="global-player-position">
|
||||||
{queueIndex + 1} / {queue.length}
|
{ytTracks
|
||||||
|
? `${yt.index >= 0 ? yt.index + 1 : "–"} / ${ytTracks.length}`
|
||||||
|
: `${queueIndex + 1} / ${queue.length}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--ghost"
|
className="btn btn--ghost"
|
||||||
onClick={next}
|
onClick={ytTracks ? yt.next : next}
|
||||||
disabled={!hasNext}
|
disabled={ytTracks
|
||||||
|
? !yt.ready || yt.index === ytTracks.length - 1
|
||||||
|
: !hasNext}
|
||||||
aria-label={t`Next track`}
|
aria-label={t`Next track`}
|
||||||
>
|
>
|
||||||
⏭
|
⏭
|
||||||
@@ -189,8 +247,14 @@ export function GlobalPlayer() {
|
|||||||
<div className="global-player-body">
|
<div className="global-player-body">
|
||||||
{current.kind === "embed"
|
{current.kind === "embed"
|
||||||
? (
|
? (
|
||||||
<div className="global-player-iframe-wrap">
|
<div
|
||||||
|
className="global-player-iframe-wrap"
|
||||||
|
// Keyed so switching embeds swaps this wrapper rather than the
|
||||||
|
// iframe itself, which the YouTube player API may have removed.
|
||||||
|
key={current.embedUrl}
|
||||||
|
>
|
||||||
<iframe
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
src={playbackUrl(current, autoplay)}
|
src={playbackUrl(current, autoplay)}
|
||||||
className={`global-player-iframe--${current.type}`}
|
className={`global-player-iframe--${current.type}`}
|
||||||
allow="autoplay; encrypted-media"
|
allow="autoplay; encrypted-media"
|
||||||
@@ -243,36 +307,35 @@ export function GlobalPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showQueue && !reduced && (
|
{!reduced && (showQueue || ytTracks) && (
|
||||||
<ol className="global-player-tracks">
|
<ol className="global-player-tracks">
|
||||||
{queue.map((item, i) => {
|
{ytTracks
|
||||||
const isCurrent = i === queueIndex;
|
? ytTracks.map((row, i) => (
|
||||||
const num = item.kind === "stream" ? item.trackNum : undefined;
|
<TrackRow
|
||||||
const dur = item.kind === "stream" ? item.duration : undefined;
|
key={`${row.videoId}-${i}`}
|
||||||
return (
|
rowRef={i === yt.index ? currentRowRef : undefined}
|
||||||
<li
|
num={i + 1}
|
||||||
|
title={row.title ?? t`Track ${i + 1}`}
|
||||||
|
duration={row.duration}
|
||||||
|
isCurrent={i === yt.index}
|
||||||
|
// Without the player API the list is a table of contents:
|
||||||
|
// nothing on our side can make the embed jump.
|
||||||
|
disabled={!yt.ready}
|
||||||
|
onPlay={() => yt.playAt(i)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: queue.map((item, i) => (
|
||||||
|
<TrackRow
|
||||||
key={playerItemKey(item)}
|
key={playerItemKey(item)}
|
||||||
ref={isCurrent ? currentRowRef : undefined}
|
rowRef={i === queueIndex ? currentRowRef : undefined}
|
||||||
className={`global-player-track${
|
num={(item.kind === "stream" ? item.trackNum : undefined) ??
|
||||||
isCurrent ? " is-current" : ""
|
i + 1}
|
||||||
}`}
|
title={item.title ?? ""}
|
||||||
>
|
duration={item.kind === "stream" ? item.duration : undefined}
|
||||||
<button type="button" onClick={() => playAt(i)}>
|
isCurrent={i === queueIndex}
|
||||||
<span className="global-player-track-num">
|
onPlay={() => playAt(i)}
|
||||||
{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>
|
</ol>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
259
src/hooks/useYouTubePlaylist.ts
Normal file
259
src/hooks/useYouTubePlaylist.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
embedListId,
|
||||||
|
fetchPlaylistTracks,
|
||||||
|
type YouTubeTrack,
|
||||||
|
} from "../utils/youtube.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracklist and transport for a YouTube playlist embed.
|
||||||
|
*
|
||||||
|
* A Bandcamp album becomes a real queue of streams, but a YouTube playlist
|
||||||
|
* stays inside the iframe: YouTube holds the list and advances through it. So
|
||||||
|
* the two halves come from two places — the titles and lengths from the server
|
||||||
|
* (the embed exposes neither), the current position and the controls from the
|
||||||
|
* IFrame Player API attached to the iframe we already render.
|
||||||
|
*
|
||||||
|
* Both halves are optional. Without the server's titles there is no list; with
|
||||||
|
* a list but no API (script blocked, embedding restricted) the rows are shown
|
||||||
|
* but inert, since nothing can be told to play.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface YTPlayer {
|
||||||
|
playVideo(): void;
|
||||||
|
playVideoAt(index: number): void;
|
||||||
|
nextVideo(): void;
|
||||||
|
previousVideo(): void;
|
||||||
|
getPlaylist(): string[] | null;
|
||||||
|
getPlaylistIndex(): number;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YTStateEvent {
|
||||||
|
/** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */
|
||||||
|
data: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YTNamespace {
|
||||||
|
Player: new (
|
||||||
|
el: HTMLIFrameElement,
|
||||||
|
opts: {
|
||||||
|
events: {
|
||||||
|
onReady?: () => void;
|
||||||
|
onStateChange?: (event: YTStateEvent) => void;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) => YTPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAUSED = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An embed that has `enablejsapi` on pauses itself when the page goes to the
|
||||||
|
* background — something a plain embed does not do, and nothing on our side
|
||||||
|
* asked for: it breaks listening while working in another tab. A pause landing
|
||||||
|
* in the moment the tab is hidden is therefore undone. The window is narrow
|
||||||
|
* and the retries few on purpose, so a pause that comes later (a media key, a
|
||||||
|
* phone call, the user coming back and hitting space) stands.
|
||||||
|
*/
|
||||||
|
const RESUME_WINDOW_MS = 4000;
|
||||||
|
const MAX_RESUMES = 3;
|
||||||
|
|
||||||
|
interface YTGlobal {
|
||||||
|
YT?: YTNamespace;
|
||||||
|
onYouTubeIframeAPIReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_SRC = "https://www.youtube.com/iframe_api";
|
||||||
|
|
||||||
|
let apiPromise: Promise<YTNamespace> | null = null;
|
||||||
|
|
||||||
|
/** Load YouTube's IFrame API once per document, whoever asks first. */
|
||||||
|
function loadPlayerApi(): Promise<YTNamespace> {
|
||||||
|
if (apiPromise) return apiPromise;
|
||||||
|
apiPromise = new Promise((resolve, reject) => {
|
||||||
|
const w = globalThis as unknown as YTGlobal;
|
||||||
|
if (w.YT?.Player) {
|
||||||
|
resolve(w.YT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The API calls this global when it finishes loading; chain any existing
|
||||||
|
// one rather than clobbering it.
|
||||||
|
const previous = w.onYouTubeIframeAPIReady;
|
||||||
|
w.onYouTubeIframeAPIReady = () => {
|
||||||
|
previous?.();
|
||||||
|
if (w.YT?.Player) resolve(w.YT);
|
||||||
|
else reject(new Error("YouTube IFrame API loaded without a Player"));
|
||||||
|
};
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = API_SRC;
|
||||||
|
script.async = true;
|
||||||
|
script.onerror = () => reject(new Error("YouTube IFrame API failed"));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return apiPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistRow {
|
||||||
|
videoId: string;
|
||||||
|
title?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubePlaylistControls {
|
||||||
|
rows: PlaylistRow[];
|
||||||
|
/** Position in `rows`, or -1 while the player hasn't reported one. */
|
||||||
|
index: number;
|
||||||
|
/** Whether the embed can be driven — rows are clickable only then. */
|
||||||
|
ready: boolean;
|
||||||
|
playAt(index: number): void;
|
||||||
|
next(): void;
|
||||||
|
previous(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useYouTubePlaylist(
|
||||||
|
embedUrl: string | null,
|
||||||
|
iframeRef: RefObject<HTMLIFrameElement | null>,
|
||||||
|
): YouTubePlaylistControls {
|
||||||
|
const listId = embedUrl ? embedListId(embedUrl) : null;
|
||||||
|
|
||||||
|
const [tracks, setTracks] = useState<YouTubeTrack[]>([]);
|
||||||
|
// Video IDs as the player itself sees them: the authority on order and
|
||||||
|
// length, and the only source for a playlist the server couldn't read.
|
||||||
|
const [playerIds, setPlayerIds] = useState<string[] | null>(null);
|
||||||
|
const [index, setIndex] = useState(-1);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const playerRef = useRef<YTPlayer | null>(null);
|
||||||
|
const hiddenAt = useRef(0);
|
||||||
|
const resumes = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibility = () => {
|
||||||
|
hiddenAt.current = document.hidden ? Date.now() : 0;
|
||||||
|
if (!document.hidden) resumes.current = 0;
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Everything below is about one embed; when that changes, drop it all in the
|
||||||
|
// same render rather than letting a stale list flash under the new video.
|
||||||
|
const [shownUrl, setShownUrl] = useState(embedUrl);
|
||||||
|
if (shownUrl !== embedUrl) {
|
||||||
|
setShownUrl(embedUrl);
|
||||||
|
setTracks([]);
|
||||||
|
setPlayerIds(null);
|
||||||
|
setIndex(-1);
|
||||||
|
setReady(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchPlaylistTracks(listId)
|
||||||
|
.then((t) => {
|
||||||
|
if (!cancelled) setTracks(t);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("youtube: no tracklist for this playlist", err);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [listId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listId || !embedUrl) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let player: YTPlayer | null = null;
|
||||||
|
|
||||||
|
loadPlayerApi()
|
||||||
|
.then((YT) => {
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
if (cancelled || !iframe) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (!player) return;
|
||||||
|
const list = player.getPlaylist?.();
|
||||||
|
setPlayerIds(Array.isArray(list) && list.length > 0 ? list : null);
|
||||||
|
const at = player.getPlaylistIndex?.();
|
||||||
|
setIndex(typeof at === "number" ? at : -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
player = new YT.Player(iframe, {
|
||||||
|
// onStateChange is what reports the list advancing on its own.
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setReady(true);
|
||||||
|
sync();
|
||||||
|
},
|
||||||
|
onStateChange: (event) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
sync();
|
||||||
|
if (
|
||||||
|
event.data === PAUSED && document.hidden &&
|
||||||
|
Date.now() - hiddenAt.current < RESUME_WINDOW_MS &&
|
||||||
|
resumes.current < MAX_RESUMES
|
||||||
|
) {
|
||||||
|
resumes.current += 1;
|
||||||
|
player?.playVideo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
playerRef.current = player;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("youtube: player API unavailable", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
playerRef.current = null;
|
||||||
|
// destroy() takes the iframe with it, which is why the element is keyed
|
||||||
|
// on the embed URL: React only ever removes its wrapper, never a node
|
||||||
|
// that has gone out from under it.
|
||||||
|
try {
|
||||||
|
player?.destroy();
|
||||||
|
} catch {
|
||||||
|
// already gone with the unmounted subtree
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [listId, embedUrl, iframeRef]);
|
||||||
|
|
||||||
|
const rows = useMemo<PlaylistRow[]>(() => {
|
||||||
|
if (!playerIds) {
|
||||||
|
return tracks.map((t) => ({
|
||||||
|
videoId: t.videoId,
|
||||||
|
title: t.title,
|
||||||
|
duration: t.duration,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Positions usually line up with the scrape; fall back to matching by ID
|
||||||
|
// so a list that gained or lost an entry since still reads correctly.
|
||||||
|
const byId = new Map(tracks.map((t) => [t.videoId, t]));
|
||||||
|
return playerIds.map((videoId, i) => {
|
||||||
|
const track = tracks[i]?.videoId === videoId
|
||||||
|
? tracks[i]
|
||||||
|
: byId.get(videoId);
|
||||||
|
return { videoId, title: track?.title, duration: track?.duration };
|
||||||
|
});
|
||||||
|
}, [playerIds, tracks]);
|
||||||
|
|
||||||
|
const playAt = useCallback((i: number) => {
|
||||||
|
playerRef.current?.playVideoAt(i);
|
||||||
|
}, []);
|
||||||
|
const next = useCallback(() => playerRef.current?.nextVideo(), []);
|
||||||
|
const previous = useCallback(() => playerRef.current?.previousVideo(), []);
|
||||||
|
|
||||||
|
return { rows, index, ready, playAt, next, previous };
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -212,11 +212,11 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
|
|||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
#: src/components/ChatModal.tsx:231
|
#: src/components/ChatModal.tsx:231
|
||||||
#: src/components/CommentThread.tsx:127
|
#: src/components/CommentThread.tsx:127
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:34
|
||||||
#: src/components/DumpCreateModal.tsx:946
|
#: src/components/DumpCreateModal.tsx:946
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:406
|
#: src/pages/Dump.tsx:406
|
||||||
#: src/pages/DumpEdit.tsx:470
|
#: src/pages/DumpEdit.tsx:484
|
||||||
#: src/pages/PlaylistDetail.tsx:923
|
#: src/pages/PlaylistDetail.tsx:923
|
||||||
#: src/pages/UserPublicProfile.tsx:1733
|
#: src/pages/UserPublicProfile.tsx:1733
|
||||||
#: src/pages/UserPublicProfile.tsx:1806
|
#: src/pages/UserPublicProfile.tsx:1806
|
||||||
@@ -275,7 +275,7 @@ msgid "Checking invite…"
|
|||||||
msgstr "Checking invite…"
|
msgstr "Checking invite…"
|
||||||
|
|
||||||
#: src/components/ChangePasswordModal.tsx:56
|
#: src/components/ChangePasswordModal.tsx:56
|
||||||
#: src/components/Modal.tsx:58
|
#: src/components/Modal.tsx:54
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Close"
|
msgstr "Close"
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ msgstr "Close"
|
|||||||
msgid "Close anyway"
|
msgid "Close anyway"
|
||||||
msgstr "Close anyway"
|
msgstr "Close anyway"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:184
|
#: src/components/GlobalPlayer.tsx:242
|
||||||
msgid "Close player"
|
msgid "Close player"
|
||||||
msgstr "Close player"
|
msgstr "Close player"
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ msgstr "Close player"
|
|||||||
msgid "Close without posting? The attached file won't be kept — everything you typed will."
|
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."
|
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:176
|
#: src/components/GlobalPlayer.tsx:234
|
||||||
msgid "Collapse player"
|
msgid "Collapse player"
|
||||||
msgstr "Collapse player"
|
msgstr "Collapse player"
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ msgstr "Could not change password"
|
|||||||
msgid "Could not load."
|
msgid "Could not load."
|
||||||
msgstr "Could not load."
|
msgstr "Could not load."
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:371
|
#: src/pages/DumpEdit.tsx:434
|
||||||
msgid "Could not save"
|
msgid "Could not save"
|
||||||
msgstr "Could not save"
|
msgstr "Could not save"
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ msgstr "Default tab"
|
|||||||
#: src/components/ChatModal.tsx:241
|
#: src/components/ChatModal.tsx:241
|
||||||
#: src/components/CommentThread.tsx:390
|
#: src/components/CommentThread.tsx:390
|
||||||
#: src/components/CommentThread.tsx:396
|
#: src/components/CommentThread.tsx:396
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:17
|
||||||
#: src/pages/PlaylistDetail.tsx:919
|
#: src/pages/PlaylistDetail.tsx:919
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Delete"
|
msgstr "Delete"
|
||||||
@@ -379,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:264
|
#: src/pages/DumpEdit.tsx:265
|
||||||
#: src/pages/DumpEdit.tsx:466
|
#: src/pages/DumpEdit.tsx:480
|
||||||
msgid "Delete dump"
|
msgid "Delete dump"
|
||||||
msgstr "Delete dump"
|
msgstr "Delete dump"
|
||||||
|
|
||||||
@@ -394,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:263
|
#: src/pages/DumpEdit.tsx:264
|
||||||
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."
|
||||||
|
|
||||||
@@ -428,7 +428,7 @@ msgstr "Drop a file here"
|
|||||||
msgid "Drop a file here, or paste one"
|
msgid "Drop a file here, or paste one"
|
||||||
msgstr "Drop a file here, or paste one"
|
msgstr "Drop a file here, or paste one"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:438
|
#: src/pages/DumpEdit.tsx:426
|
||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
@@ -514,7 +514,7 @@ 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
|
#: src/components/GlobalPlayer.tsx:234
|
||||||
msgid "Expand player"
|
msgid "Expand player"
|
||||||
msgstr "Expand player"
|
msgstr "Expand player"
|
||||||
|
|
||||||
@@ -855,7 +855,7 @@ msgstr "New playlist"
|
|||||||
msgid "Next →"
|
msgid "Next →"
|
||||||
msgstr "Next →"
|
msgstr "Next →"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:166
|
#: src/components/GlobalPlayer.tsx:224
|
||||||
msgid "Next track"
|
msgid "Next track"
|
||||||
msgstr "Next track"
|
msgstr "Next track"
|
||||||
|
|
||||||
@@ -1006,7 +1006,7 @@ msgstr "Post reply"
|
|||||||
msgid "Posting…"
|
msgid "Posting…"
|
||||||
msgstr "Posting…"
|
msgstr "Posting…"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:154
|
#: src/components/GlobalPlayer.tsx:208
|
||||||
msgid "Previous track"
|
msgid "Previous track"
|
||||||
msgstr "Previous track"
|
msgstr "Previous track"
|
||||||
|
|
||||||
@@ -1034,15 +1034,15 @@ msgstr "public"
|
|||||||
msgid "Public"
|
msgid "Public"
|
||||||
msgstr "Public"
|
msgstr "Public"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:241
|
#: src/pages/DumpEdit.tsx:245
|
||||||
msgid "Refresh metadata"
|
msgid "Refresh metadata"
|
||||||
msgstr "Refresh metadata"
|
msgstr "Refresh metadata"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:240
|
#: src/components/GlobalPlayer.tsx:304
|
||||||
msgid "Refreshing stream…"
|
msgid "Refreshing stream…"
|
||||||
msgstr "Refreshing stream…"
|
msgstr "Refreshing stream…"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:240
|
#: src/pages/DumpEdit.tsx:244
|
||||||
msgid "Refreshing…"
|
msgid "Refreshing…"
|
||||||
msgstr "Refreshing…"
|
msgstr "Refreshing…"
|
||||||
|
|
||||||
@@ -1080,7 +1080,7 @@ msgstr "Remove like"
|
|||||||
msgid "Remove vote"
|
msgid "Remove vote"
|
||||||
msgstr "Remove vote"
|
msgstr "Remove vote"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:430
|
#: src/pages/DumpEdit.tsx:418
|
||||||
msgid "Replace file"
|
msgid "Replace file"
|
||||||
msgstr "Replace file"
|
msgstr "Replace file"
|
||||||
|
|
||||||
@@ -1106,8 +1106,8 @@ msgstr "Reset failed"
|
|||||||
msgid "Reset password"
|
msgid "Reset password"
|
||||||
msgstr "Reset password"
|
msgstr "Reset password"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:391
|
#: src/pages/DumpEdit.tsx:399
|
||||||
#: src/pages/DumpEdit.tsx:409
|
#: src/pages/DumpEdit.tsx:448
|
||||||
msgid "Reset to default"
|
msgid "Reset to default"
|
||||||
msgstr "Reset to default"
|
msgstr "Reset to default"
|
||||||
|
|
||||||
@@ -1128,7 +1128,7 @@ msgstr "Role"
|
|||||||
#: src/components/ChatModal.tsx:224
|
#: src/components/ChatModal.tsx:224
|
||||||
#: src/components/CommentThread.tsx:341
|
#: src/components/CommentThread.tsx:341
|
||||||
#: src/pages/Dump.tsx:398
|
#: src/pages/Dump.tsx:398
|
||||||
#: src/pages/DumpEdit.tsx:473
|
#: src/pages/DumpEdit.tsx:487
|
||||||
#: src/pages/PlaylistDetail.tsx:930
|
#: src/pages/PlaylistDetail.tsx:930
|
||||||
#: src/pages/UserPublicProfile.tsx:1725
|
#: src/pages/UserPublicProfile.tsx:1725
|
||||||
#: src/pages/UserPublicProfile.tsx:1798
|
#: src/pages/UserPublicProfile.tsx:1798
|
||||||
@@ -1237,13 +1237,13 @@ msgstr "This page does not exist."
|
|||||||
msgid "This reset link is missing or malformed."
|
msgid "This reset link is missing or malformed."
|
||||||
msgstr "This reset link is missing or malformed."
|
msgstr "This reset link is missing or malformed."
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:375
|
#: src/pages/DumpEdit.tsx:383
|
||||||
msgid "Thumbnail"
|
msgid "Thumbnail"
|
||||||
msgstr "Thumbnail"
|
msgstr "Thumbnail"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:174
|
#: src/components/DumpCreateModal.tsx:174
|
||||||
#: src/components/PlaylistCreateForm.tsx:74
|
#: src/components/PlaylistCreateForm.tsx:74
|
||||||
#: src/pages/DumpEdit.tsx:399
|
#: src/pages/DumpEdit.tsx:438
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr "Title"
|
msgstr "Title"
|
||||||
|
|
||||||
@@ -1256,6 +1256,11 @@ msgstr "Title is required."
|
|||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Today"
|
msgstr "Today"
|
||||||
|
|
||||||
|
#. placeholder {0}: i + 1
|
||||||
|
#: src/components/GlobalPlayer.tsx:318
|
||||||
|
msgid "Track {0}"
|
||||||
|
msgstr "Track {0}"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:637
|
#: src/components/ChatModal.tsx:637
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Type a message…"
|
msgstr "Type a message…"
|
||||||
@@ -1299,7 +1304,7 @@ msgstr "Upvoted"
|
|||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Upvoted ({0}{1})"
|
msgstr "Upvoted ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:422
|
#: src/pages/DumpEdit.tsx:410
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
@@ -1345,7 +1350,7 @@ msgid "View dump →"
|
|||||||
msgstr "View dump →"
|
msgstr "View dump →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:905
|
#: src/components/DumpCreateModal.tsx:905
|
||||||
#: src/pages/DumpEdit.tsx:447
|
#: src/pages/DumpEdit.tsx:459
|
||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "What makes it worth it?"
|
msgstr "What makes it worth it?"
|
||||||
|
|
||||||
@@ -1363,7 +1368,7 @@ msgid "Why & where"
|
|||||||
msgstr "Why & where"
|
msgstr "Why & where"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:904
|
#: src/components/DumpCreateModal.tsx:904
|
||||||
#: src/pages/DumpEdit.tsx:446
|
#: src/pages/DumpEdit.tsx:458
|
||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Why?"
|
msgstr "Why?"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -212,11 +212,11 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
|
|||||||
#: src/components/CategoryManager.tsx:211
|
#: src/components/CategoryManager.tsx:211
|
||||||
#: src/components/ChatModal.tsx:231
|
#: src/components/ChatModal.tsx:231
|
||||||
#: src/components/CommentThread.tsx:127
|
#: src/components/CommentThread.tsx:127
|
||||||
#: src/components/ConfirmModal.tsx:32
|
#: src/components/ConfirmModal.tsx:34
|
||||||
#: src/components/DumpCreateModal.tsx:946
|
#: src/components/DumpCreateModal.tsx:946
|
||||||
#: src/components/form/FormActions.tsx:32
|
#: src/components/form/FormActions.tsx:32
|
||||||
#: src/pages/Dump.tsx:406
|
#: src/pages/Dump.tsx:406
|
||||||
#: src/pages/DumpEdit.tsx:470
|
#: src/pages/DumpEdit.tsx:484
|
||||||
#: src/pages/PlaylistDetail.tsx:923
|
#: src/pages/PlaylistDetail.tsx:923
|
||||||
#: src/pages/UserPublicProfile.tsx:1733
|
#: src/pages/UserPublicProfile.tsx:1733
|
||||||
#: src/pages/UserPublicProfile.tsx:1806
|
#: src/pages/UserPublicProfile.tsx:1806
|
||||||
@@ -275,7 +275,7 @@ 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:58
|
#: src/components/Modal.tsx:54
|
||||||
msgid "Close"
|
msgid "Close"
|
||||||
msgstr "Fermer"
|
msgstr "Fermer"
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ msgstr "Fermer"
|
|||||||
msgid "Close anyway"
|
msgid "Close anyway"
|
||||||
msgstr "Fermer quand même"
|
msgstr "Fermer quand même"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:184
|
#: src/components/GlobalPlayer.tsx:242
|
||||||
msgid "Close player"
|
msgid "Close player"
|
||||||
msgstr "Fermer le lecteur"
|
msgstr "Fermer le lecteur"
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ msgstr "Fermer le lecteur"
|
|||||||
msgid "Close without posting? The attached file won't be kept — everything you typed will."
|
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é."
|
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:176
|
#: src/components/GlobalPlayer.tsx:234
|
||||||
msgid "Collapse player"
|
msgid "Collapse player"
|
||||||
msgstr "Réduire le lecteur"
|
msgstr "Réduire le lecteur"
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ msgstr "Impossible de changer le mot de passe"
|
|||||||
msgid "Could not load."
|
msgid "Could not load."
|
||||||
msgstr "Impossible de charger."
|
msgstr "Impossible de charger."
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:371
|
#: src/pages/DumpEdit.tsx:434
|
||||||
msgid "Could not save"
|
msgid "Could not save"
|
||||||
msgstr "Sauvegarde impossible"
|
msgstr "Sauvegarde impossible"
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ msgstr "Onglet par défaut"
|
|||||||
#: src/components/ChatModal.tsx:241
|
#: src/components/ChatModal.tsx:241
|
||||||
#: src/components/CommentThread.tsx:390
|
#: src/components/CommentThread.tsx:390
|
||||||
#: src/components/CommentThread.tsx:396
|
#: src/components/CommentThread.tsx:396
|
||||||
#: src/components/ConfirmModal.tsx:16
|
#: src/components/ConfirmModal.tsx:17
|
||||||
#: src/pages/PlaylistDetail.tsx:919
|
#: src/pages/PlaylistDetail.tsx:919
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Supprimer"
|
msgstr "Supprimer"
|
||||||
@@ -379,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:264
|
#: src/pages/DumpEdit.tsx:265
|
||||||
#: src/pages/DumpEdit.tsx:466
|
#: src/pages/DumpEdit.tsx:480
|
||||||
msgid "Delete dump"
|
msgid "Delete dump"
|
||||||
msgstr "Supprimer la reco"
|
msgstr "Supprimer la reco"
|
||||||
|
|
||||||
@@ -394,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:263
|
#: src/pages/DumpEdit.tsx:264
|
||||||
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."
|
||||||
|
|
||||||
@@ -428,7 +428,7 @@ msgstr "Déposez un fichier ici"
|
|||||||
msgid "Drop a file here, or paste one"
|
msgid "Drop a file here, or paste one"
|
||||||
msgstr "Déposez un fichier ici, ou collez-en un"
|
msgstr "Déposez un fichier ici, ou collez-en un"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:438
|
#: src/pages/DumpEdit.tsx:426
|
||||||
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"
|
||||||
|
|
||||||
@@ -514,7 +514,7 @@ 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
|
#: src/components/GlobalPlayer.tsx:234
|
||||||
msgid "Expand player"
|
msgid "Expand player"
|
||||||
msgstr "Agrandir le lecteur"
|
msgstr "Agrandir le lecteur"
|
||||||
|
|
||||||
@@ -855,7 +855,7 @@ msgstr "Nouvelle collection"
|
|||||||
msgid "Next →"
|
msgid "Next →"
|
||||||
msgstr "Suivant →"
|
msgstr "Suivant →"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:166
|
#: src/components/GlobalPlayer.tsx:224
|
||||||
msgid "Next track"
|
msgid "Next track"
|
||||||
msgstr "Piste suivante"
|
msgstr "Piste suivante"
|
||||||
|
|
||||||
@@ -1006,7 +1006,7 @@ msgstr "Publier la réponse"
|
|||||||
msgid "Posting…"
|
msgid "Posting…"
|
||||||
msgstr "Publication…"
|
msgstr "Publication…"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:154
|
#: src/components/GlobalPlayer.tsx:208
|
||||||
msgid "Previous track"
|
msgid "Previous track"
|
||||||
msgstr "Piste précédente"
|
msgstr "Piste précédente"
|
||||||
|
|
||||||
@@ -1034,15 +1034,15 @@ msgstr "public"
|
|||||||
msgid "Public"
|
msgid "Public"
|
||||||
msgstr "Public"
|
msgstr "Public"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:241
|
#: src/pages/DumpEdit.tsx:245
|
||||||
msgid "Refresh metadata"
|
msgid "Refresh metadata"
|
||||||
msgstr "Actualiser les métadonnées"
|
msgstr "Actualiser les métadonnées"
|
||||||
|
|
||||||
#: src/components/GlobalPlayer.tsx:240
|
#: src/components/GlobalPlayer.tsx:304
|
||||||
msgid "Refreshing stream…"
|
msgid "Refreshing stream…"
|
||||||
msgstr "Actualisation du flux…"
|
msgstr "Actualisation du flux…"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:240
|
#: src/pages/DumpEdit.tsx:244
|
||||||
msgid "Refreshing…"
|
msgid "Refreshing…"
|
||||||
msgstr "Actualisation…"
|
msgstr "Actualisation…"
|
||||||
|
|
||||||
@@ -1080,7 +1080,7 @@ msgstr "Retirer le j'aime"
|
|||||||
msgid "Remove vote"
|
msgid "Remove vote"
|
||||||
msgstr "Retirer le vote"
|
msgstr "Retirer le vote"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:430
|
#: src/pages/DumpEdit.tsx:418
|
||||||
msgid "Replace file"
|
msgid "Replace file"
|
||||||
msgstr "Remplacer le fichier"
|
msgstr "Remplacer le fichier"
|
||||||
|
|
||||||
@@ -1106,8 +1106,8 @@ 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:391
|
#: src/pages/DumpEdit.tsx:399
|
||||||
#: src/pages/DumpEdit.tsx:409
|
#: src/pages/DumpEdit.tsx:448
|
||||||
msgid "Reset to default"
|
msgid "Reset to default"
|
||||||
msgstr "Réinitialiser par défaut"
|
msgstr "Réinitialiser par défaut"
|
||||||
|
|
||||||
@@ -1128,7 +1128,7 @@ msgstr "Rôle"
|
|||||||
#: src/components/ChatModal.tsx:224
|
#: src/components/ChatModal.tsx:224
|
||||||
#: src/components/CommentThread.tsx:341
|
#: src/components/CommentThread.tsx:341
|
||||||
#: src/pages/Dump.tsx:398
|
#: src/pages/Dump.tsx:398
|
||||||
#: src/pages/DumpEdit.tsx:473
|
#: src/pages/DumpEdit.tsx:487
|
||||||
#: src/pages/PlaylistDetail.tsx:930
|
#: src/pages/PlaylistDetail.tsx:930
|
||||||
#: src/pages/UserPublicProfile.tsx:1725
|
#: src/pages/UserPublicProfile.tsx:1725
|
||||||
#: src/pages/UserPublicProfile.tsx:1798
|
#: src/pages/UserPublicProfile.tsx:1798
|
||||||
@@ -1237,13 +1237,13 @@ msgstr "Rien à voir, circulez."
|
|||||||
msgid "This reset link is missing or malformed."
|
msgid "This reset link is missing or malformed."
|
||||||
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:375
|
#: src/pages/DumpEdit.tsx:383
|
||||||
msgid "Thumbnail"
|
msgid "Thumbnail"
|
||||||
msgstr "Miniature"
|
msgstr "Miniature"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:174
|
#: src/components/DumpCreateModal.tsx:174
|
||||||
#: src/components/PlaylistCreateForm.tsx:74
|
#: src/components/PlaylistCreateForm.tsx:74
|
||||||
#: src/pages/DumpEdit.tsx:399
|
#: src/pages/DumpEdit.tsx:438
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
@@ -1256,6 +1256,11 @@ msgstr "Un titre est requis."
|
|||||||
msgid "Today"
|
msgid "Today"
|
||||||
msgstr "Aujourd'hui"
|
msgstr "Aujourd'hui"
|
||||||
|
|
||||||
|
#. placeholder {0}: i + 1
|
||||||
|
#: src/components/GlobalPlayer.tsx:318
|
||||||
|
msgid "Track {0}"
|
||||||
|
msgstr "Piste {0}"
|
||||||
|
|
||||||
#: src/components/ChatModal.tsx:637
|
#: src/components/ChatModal.tsx:637
|
||||||
msgid "Type a message…"
|
msgid "Type a message…"
|
||||||
msgstr "Écrivez un message…"
|
msgstr "Écrivez un message…"
|
||||||
@@ -1299,7 +1304,7 @@ msgstr "Voté"
|
|||||||
msgid "Upvoted ({0}{1})"
|
msgid "Upvoted ({0}{1})"
|
||||||
msgstr "Votés ({0}{1})"
|
msgstr "Votés ({0}{1})"
|
||||||
|
|
||||||
#: src/pages/DumpEdit.tsx:422
|
#: src/pages/DumpEdit.tsx:410
|
||||||
msgid "URL"
|
msgid "URL"
|
||||||
msgstr "URL"
|
msgstr "URL"
|
||||||
|
|
||||||
@@ -1345,7 +1350,7 @@ msgid "View dump →"
|
|||||||
msgstr "Voir la reco →"
|
msgstr "Voir la reco →"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:905
|
#: src/components/DumpCreateModal.tsx:905
|
||||||
#: src/pages/DumpEdit.tsx:447
|
#: src/pages/DumpEdit.tsx:459
|
||||||
msgid "What makes it worth it?"
|
msgid "What makes it worth it?"
|
||||||
msgstr "Pourquoi on en voudrait ?"
|
msgstr "Pourquoi on en voudrait ?"
|
||||||
|
|
||||||
@@ -1363,7 +1368,7 @@ msgid "Why & where"
|
|||||||
msgstr "Pourquoi & où"
|
msgstr "Pourquoi & où"
|
||||||
|
|
||||||
#: src/components/DumpCreateModal.tsx:904
|
#: src/components/DumpCreateModal.tsx:904
|
||||||
#: src/pages/DumpEdit.tsx:446
|
#: src/pages/DumpEdit.tsx:458
|
||||||
msgid "Why?"
|
msgid "Why?"
|
||||||
msgstr "Pourquoi ?"
|
msgstr "Pourquoi ?"
|
||||||
|
|
||||||
|
|||||||
@@ -214,41 +214,42 @@ export function DumpEdit() {
|
|||||||
<h1 className="form-page-title">{dump.title}</h1>
|
<h1 className="form-page-title">{dump.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-edit-preview">
|
|
||||||
{dump.kind === "file"
|
|
||||||
? <FilePreview dump={dump} />
|
|
||||||
: dump.richContent
|
|
||||||
? <RichContentCard richContent={dump.richContent} />
|
|
||||||
: dump.url && (
|
|
||||||
<a
|
|
||||||
href={dump.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="dump-url-link"
|
|
||||||
>
|
|
||||||
{dump.url}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{dump.kind === "url" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary dump-edit-refresh"
|
|
||||||
onClick={handleRefreshMetadata}
|
|
||||||
disabled={refreshing}
|
|
||||||
>
|
|
||||||
{refreshing
|
|
||||||
? <Trans>Refreshing…</Trans>
|
|
||||||
: <Trans>Refresh metadata</Trans>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{refreshError && (
|
|
||||||
<p className="dump-edit-refresh-error">{refreshError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DumpEditForm
|
<DumpEditForm
|
||||||
key={dump.id}
|
key={dump.id}
|
||||||
dump={dump}
|
dump={dump}
|
||||||
|
preview={
|
||||||
|
<div className="dump-edit-preview">
|
||||||
|
{dump.kind === "file"
|
||||||
|
? <FilePreview dump={dump} />
|
||||||
|
: dump.richContent
|
||||||
|
? <RichContentCard richContent={dump.richContent} />
|
||||||
|
: dump.url && (
|
||||||
|
<a
|
||||||
|
href={dump.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="dump-url-link"
|
||||||
|
>
|
||||||
|
{dump.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{dump.kind === "url" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary dump-edit-refresh"
|
||||||
|
onClick={handleRefreshMetadata}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
{refreshing
|
||||||
|
? <Trans>Refreshing…</Trans>
|
||||||
|
: <Trans>Refresh metadata</Trans>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{refreshError && (
|
||||||
|
<p className="dump-edit-refresh-error">{refreshError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
currentThumbnailSrc={currentThumbnailSrc}
|
currentThumbnailSrc={currentThumbnailSrc}
|
||||||
thumbUploading={thumbUploading}
|
thumbUploading={thumbUploading}
|
||||||
onThumbnailChange={handleThumbnailChange}
|
onThumbnailChange={handleThumbnailChange}
|
||||||
@@ -272,6 +273,7 @@ export function DumpEdit() {
|
|||||||
|
|
||||||
interface DumpEditFormProps {
|
interface DumpEditFormProps {
|
||||||
dump: Dump;
|
dump: Dump;
|
||||||
|
preview: React.ReactNode;
|
||||||
currentThumbnailSrc: string | null;
|
currentThumbnailSrc: string | null;
|
||||||
thumbUploading: boolean;
|
thumbUploading: boolean;
|
||||||
onThumbnailChange: (file: File) => void;
|
onThumbnailChange: (file: File) => void;
|
||||||
@@ -290,6 +292,7 @@ interface EditValues {
|
|||||||
|
|
||||||
function DumpEditForm({
|
function DumpEditForm({
|
||||||
dump,
|
dump,
|
||||||
|
preview,
|
||||||
currentThumbnailSrc,
|
currentThumbnailSrc,
|
||||||
thumbUploading,
|
thumbUploading,
|
||||||
onThumbnailChange,
|
onThumbnailChange,
|
||||||
@@ -367,111 +370,123 @@ function DumpEditForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form className="form" onSubmit={onSubmit}>
|
<form className="form dump-edit-form" onSubmit={onSubmit}>
|
||||||
<FormError title={t`Could not save`} />
|
{
|
||||||
|
/* Left: the dumped thing itself — what it looks like and where it
|
||||||
|
comes from. Right: how it's presented and shared. */
|
||||||
|
}
|
||||||
|
<div className="dump-edit-panel dump-edit-panel--source">
|
||||||
|
{preview}
|
||||||
|
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<span className="form-label">
|
<span className="form-label">
|
||||||
<Trans>Thumbnail</Trans>
|
<Trans>Thumbnail</Trans>
|
||||||
</span>
|
</span>
|
||||||
<div className="dump-edit-thumbnail-row">
|
<div className="dump-edit-thumbnail-row">
|
||||||
<ImagePicker
|
<ImagePicker
|
||||||
src={currentThumbnailSrc}
|
src={currentThumbnailSrc}
|
||||||
size={64}
|
size={64}
|
||||||
onChange={onThumbnailChange}
|
onChange={onThumbnailChange}
|
||||||
uploading={thumbUploading}
|
uploading={thumbUploading}
|
||||||
/>
|
/>
|
||||||
{dump.thumbnailMime && (
|
{dump.thumbnailMime && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-border"
|
className="btn-border"
|
||||||
onClick={onThumbnailReset}
|
onClick={onThumbnailReset}
|
||||||
disabled={thumbUploading}
|
disabled={thumbUploading}
|
||||||
>
|
>
|
||||||
<Trans>Reset to default</Trans>
|
<Trans>Reset to default</Trans>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{dump.kind === "url"
|
||||||
|
? (
|
||||||
|
<TextField<EditValues>
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
label={t`URL`}
|
||||||
|
placeholder="https://..."
|
||||||
|
rules={{ required: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="form-field">
|
||||||
|
<span className="form-label">
|
||||||
|
<Trans>Replace file</Trans>
|
||||||
|
</span>
|
||||||
|
<p className="dump-file-notice">
|
||||||
|
<strong>{dump.fileName}</strong>
|
||||||
|
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||||
|
</p>
|
||||||
|
<FileField<EditValues>
|
||||||
|
name="newFile"
|
||||||
|
hint={t`Drop a replacement here`}
|
||||||
|
showLimit={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextField<EditValues>
|
<div className="dump-edit-panel dump-edit-panel--details">
|
||||||
name="title"
|
<FormError title={t`Could not save`} />
|
||||||
label={t`Title`}
|
|
||||||
labelAction={metaTitle
|
|
||||||
? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="form-label-action"
|
|
||||||
onClick={() =>
|
|
||||||
form.setValue("title", metaTitle, { shouldDirty: true })}
|
|
||||||
disabled={currentTitle.trim() === metaTitle}
|
|
||||||
>
|
|
||||||
<Trans>Reset to default</Trans>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
|
||||||
rules={{ required: true }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{dump.kind === "url"
|
<TextField<EditValues>
|
||||||
? (
|
name="title"
|
||||||
<TextField<EditValues>
|
label={t`Title`}
|
||||||
name="url"
|
labelAction={metaTitle
|
||||||
type="url"
|
? (
|
||||||
label={t`URL`}
|
<button
|
||||||
placeholder="https://..."
|
type="button"
|
||||||
rules={{ required: true }}
|
className="form-label-action"
|
||||||
/>
|
onClick={() =>
|
||||||
)
|
form.setValue("title", metaTitle, { shouldDirty: true })}
|
||||||
: (
|
disabled={currentTitle.trim() === metaTitle}
|
||||||
<div className="form-field">
|
>
|
||||||
<span className="form-label">
|
<Trans>Reset to default</Trans>
|
||||||
<Trans>Replace file</Trans>
|
</button>
|
||||||
</span>
|
)
|
||||||
<p className="dump-file-notice">
|
: undefined}
|
||||||
<strong>{dump.fileName}</strong>
|
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||||
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
rules={{ required: true }}
|
||||||
</p>
|
/>
|
||||||
<FileField<EditValues>
|
|
||||||
name="newFile"
|
<RichTextField<EditValues>
|
||||||
hint={t`Drop a replacement here`}
|
name="comment"
|
||||||
showLimit={false}
|
label={t`Why?`}
|
||||||
/>
|
placeholder={t`What makes it worth it?`}
|
||||||
|
rows={3}
|
||||||
|
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||||
|
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategorySelect
|
||||||
|
selected={selectedCategoryIds}
|
||||||
|
onToggle={toggleCategory}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VisibilityToggle<EditValues> name="isPublic" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dump-edit-footer">
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRequestDelete}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
<Trans>Delete dump</Trans>
|
||||||
|
</button>
|
||||||
|
<div className="form-actions-right">
|
||||||
|
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Link>
|
||||||
|
<SubmitButton>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</SubmitButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<RichTextField<EditValues>
|
|
||||||
name="comment"
|
|
||||||
label={t`Why?`}
|
|
||||||
placeholder={t`What makes it worth it?`}
|
|
||||||
rows={3}
|
|
||||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
|
||||||
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CategorySelect
|
|
||||||
selected={selectedCategoryIds}
|
|
||||||
onToggle={toggleCategory}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VisibilityToggle<EditValues> name="isPublic" />
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRequestDelete}
|
|
||||||
className="btn-danger"
|
|
||||||
>
|
|
||||||
<Trans>Delete dump</Trans>
|
|
||||||
</button>
|
|
||||||
<div className="form-actions-right">
|
|
||||||
<Link to={dumpUrl(dump)} className="form-cancel">
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Link>
|
|
||||||
<SubmitButton>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</SubmitButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
58
src/utils/youtube.ts
Normal file
58
src/utils/youtube.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
|
/** Mirrors one entry of GET /api/youtube/playlist. */
|
||||||
|
export interface YouTubeTrack {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
/** Seconds. */
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playlist an embed URL plays, if any.
|
||||||
|
*
|
||||||
|
* Covers both embed shapes the provider produces: `/embed/videoseries?list=…`
|
||||||
|
* for a bare playlist, and `/embed/<id>?list=…` for a video opened in one.
|
||||||
|
*/
|
||||||
|
export function embedListId(embedUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(embedUrl).searchParams.get("list");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the parameters the IFrame Player API needs to talk to the embed.
|
||||||
|
*
|
||||||
|
* `enablejsapi` has to be in the src the iframe was created with — it cannot be
|
||||||
|
* turned on afterwards — so it is added for every playlist embed, whether or
|
||||||
|
* not the API script ends up loading.
|
||||||
|
*/
|
||||||
|
export function withJsApi(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.searchParams.set("enablejsapi", "1");
|
||||||
|
u.searchParams.set("origin", globalThis.location.origin);
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titles and lengths for a playlist. Throws; callers fall back to no list. */
|
||||||
|
export async function fetchPlaylistTracks(
|
||||||
|
listId: string,
|
||||||
|
): Promise<YouTubeTrack[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/youtube/playlist?list=${encodeURIComponent(listId)}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`playlist fetch failed: ${res.status}`);
|
||||||
|
const body = await res.json() as {
|
||||||
|
success: boolean;
|
||||||
|
data?: { tracks?: YouTubeTrack[] };
|
||||||
|
};
|
||||||
|
const tracks = body.data?.tracks;
|
||||||
|
if (!body.success || !tracks?.length) throw new Error("no tracks");
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user