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 previewRouter from "./routes/preview.ts";
|
||||
import bandcampRouter from "./routes/bandcamp.ts";
|
||||
import youtubeRouter from "./routes/youtube.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
import chatRouter from "./routes/chat.ts";
|
||||
@@ -76,6 +77,10 @@ app.use(
|
||||
bandcampRouter.routes(),
|
||||
bandcampRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
youtubeRouter.routes(),
|
||||
youtubeRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
playlistsRouter.routes(),
|
||||
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-rich-content,
|
||||
.form-page-header,
|
||||
.dump-edit-preview,
|
||||
.form-page .form,
|
||||
.dump-edit-panel,
|
||||
.dump-edit-footer,
|
||||
.playlist-detail-header,
|
||||
.comment-node-inner {
|
||||
box-shadow: var(--panel-ring), var(--card-shadow);
|
||||
@@ -1407,6 +1407,10 @@ a.global-player-title:hover {
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -1434,6 +1438,14 @@ a.global-player-title:hover {
|
||||
.global-player-track > button:hover {
|
||||
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. */
|
||||
.global-player-track.is-current > button {
|
||||
border-left-color: var(--color-accent);
|
||||
@@ -3032,20 +3044,38 @@ body.has-player .chat-fab {
|
||||
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) {
|
||||
.form-page--two-col {
|
||||
.form-page--two-col,
|
||||
.dump-edit-form {
|
||||
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;
|
||||
}
|
||||
.form-page--two-col .dump-edit-preview {
|
||||
border-radius: 0 0 0 var(--radius-xl);
|
||||
/* "Why?" soaks up whatever height the source column adds, so a tall
|
||||
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
|
||||
form's bottom-left stays square where it meets the preview. */
|
||||
.form-page.form-page--two-col .form {
|
||||
border-radius: 0 0 var(--radius-xl) 0;
|
||||
.dump-edit-panel--details .mention-textarea-wrap {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.dump-edit-preview {
|
||||
.dump-edit-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.dump-edit-preview {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dump-edit-footer {
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: var(--color-surface);
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@@ -3097,12 +3145,6 @@ body.has-player .chat-fab {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6170,6 +6212,25 @@ body.has-player .chat-fab {
|
||||
pointer-events: none;
|
||||
z-index: var(--z-toast);
|
||||
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) ── */
|
||||
|
||||
@@ -10,9 +10,40 @@ import {
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../utils/youtube.ts";
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -21,16 +52,20 @@ type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
// 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;
|
||||
// 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 {
|
||||
const url = new URL(item.embedUrl);
|
||||
const url = new URL(base);
|
||||
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
|
||||
else if (item.type === "soundcloud") {
|
||||
url.searchParams.set("auto_play", "true");
|
||||
} else return item.embedUrl;
|
||||
} else return base;
|
||||
return url.toString();
|
||||
} 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,
|
||||
} = useContext(PlayerContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const currentRowRef = useRef<HTMLLIElement>(null);
|
||||
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;
|
||||
if (prevKey !== currentKey) {
|
||||
@@ -93,10 +138,10 @@ export function GlobalPlayer() {
|
||||
};
|
||||
}, [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(() => {
|
||||
currentRowRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [queueIndex]);
|
||||
}, [queueIndex, yt.index]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
@@ -106,14 +151,23 @@ export function GlobalPlayer() {
|
||||
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
|
||||
: 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.embedUrl
|
||||
: current.kind === "file"
|
||||
? current.fileUrl
|
||||
: current.streamUrl);
|
||||
|
||||
const showQueue = queue.length > 1;
|
||||
const title = ytRow?.title ?? itemTitle;
|
||||
const subtitle = ytRow?.title && ytRow.title !== itemTitle
|
||||
? itemTitle
|
||||
: current.subtitle;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -140,29 +194,33 @@ export function GlobalPlayer() {
|
||||
</Link>
|
||||
)
|
||||
: <span className="global-player-title">{title}</span>}
|
||||
{current.subtitle && (
|
||||
<span className="global-player-subtitle">{current.subtitle}</span>
|
||||
{subtitle && (
|
||||
<span className="global-player-subtitle">{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
{showQueue && (
|
||||
{showTransport && (
|
||||
<div className="global-player-transport">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={previous}
|
||||
disabled={!hasPrevious}
|
||||
onClick={ytTracks ? yt.previous : previous}
|
||||
disabled={ytTracks ? !yt.ready || yt.index === 0 : !hasPrevious}
|
||||
aria-label={t`Previous track`}
|
||||
>
|
||||
⏮
|
||||
</button>
|
||||
<span className="global-player-position">
|
||||
{queueIndex + 1} / {queue.length}
|
||||
{ytTracks
|
||||
? `${yt.index >= 0 ? yt.index + 1 : "–"} / ${ytTracks.length}`
|
||||
: `${queueIndex + 1} / ${queue.length}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={next}
|
||||
disabled={!hasNext}
|
||||
onClick={ytTracks ? yt.next : next}
|
||||
disabled={ytTracks
|
||||
? !yt.ready || yt.index === ytTracks.length - 1
|
||||
: !hasNext}
|
||||
aria-label={t`Next track`}
|
||||
>
|
||||
⏭
|
||||
@@ -189,8 +247,14 @@ export function GlobalPlayer() {
|
||||
<div className="global-player-body">
|
||||
{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
|
||||
ref={iframeRef}
|
||||
src={playbackUrl(current, autoplay)}
|
||||
className={`global-player-iframe--${current.type}`}
|
||||
allow="autoplay; encrypted-media"
|
||||
@@ -243,36 +307,35 @@ export function GlobalPlayer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQueue && !reduced && (
|
||||
{!reduced && (showQueue || ytTracks) && (
|
||||
<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
|
||||
{ytTracks
|
||||
? ytTracks.map((row, i) => (
|
||||
<TrackRow
|
||||
key={`${row.videoId}-${i}`}
|
||||
rowRef={i === yt.index ? currentRowRef : undefined}
|
||||
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)}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
rowRef={i === queueIndex ? currentRowRef : undefined}
|
||||
num={(item.kind === "stream" ? item.trackNum : undefined) ??
|
||||
i + 1}
|
||||
title={item.title ?? ""}
|
||||
duration={item.kind === "stream" ? item.duration : undefined}
|
||||
isCurrent={i === queueIndex}
|
||||
onPlay={() => playAt(i)}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</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/ChatModal.tsx:231
|
||||
#: src/components/CommentThread.tsx:127
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/ConfirmModal.tsx:34
|
||||
#: src/components/DumpCreateModal.tsx:946
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:406
|
||||
#: src/pages/DumpEdit.tsx:470
|
||||
#: src/pages/DumpEdit.tsx:484
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/UserPublicProfile.tsx:1733
|
||||
#: src/pages/UserPublicProfile.tsx:1806
|
||||
@@ -275,7 +275,7 @@ msgid "Checking invite…"
|
||||
msgstr "Checking invite…"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:56
|
||||
#: src/components/Modal.tsx:58
|
||||
#: src/components/Modal.tsx:54
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
@@ -283,7 +283,7 @@ msgstr "Close"
|
||||
msgid "Close anyway"
|
||||
msgstr "Close anyway"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:184
|
||||
#: src/components/GlobalPlayer.tsx:242
|
||||
msgid "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."
|
||||
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"
|
||||
msgstr "Collapse player"
|
||||
|
||||
@@ -320,7 +320,7 @@ msgstr "Could not change password"
|
||||
msgid "Could not load."
|
||||
msgstr "Could not load."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:371
|
||||
#: src/pages/DumpEdit.tsx:434
|
||||
msgid "Could not save"
|
||||
msgstr "Could not save"
|
||||
|
||||
@@ -365,7 +365,7 @@ msgstr "Default tab"
|
||||
#: src/components/ChatModal.tsx:241
|
||||
#: src/components/CommentThread.tsx:390
|
||||
#: src/components/CommentThread.tsx:396
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
#: src/components/ConfirmModal.tsx:17
|
||||
#: src/pages/PlaylistDetail.tsx:919
|
||||
msgid "Delete"
|
||||
msgstr "Delete"
|
||||
@@ -379,8 +379,8 @@ msgstr "Delete category"
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Delete category \"{0}\"? This cannot be undone."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:264
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/DumpEdit.tsx:265
|
||||
#: src/pages/DumpEdit.tsx:480
|
||||
msgid "Delete dump"
|
||||
msgstr "Delete dump"
|
||||
|
||||
@@ -394,7 +394,7 @@ msgstr "Delete playlist"
|
||||
msgid "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."
|
||||
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"
|
||||
msgstr "Drop a file here, or paste one"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:438
|
||||
#: src/pages/DumpEdit.tsx:426
|
||||
msgid "Drop a replacement here"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
@@ -514,7 +514,7 @@ msgstr "Email address"
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Enter a query to search."
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:176
|
||||
#: src/components/GlobalPlayer.tsx:234
|
||||
msgid "Expand player"
|
||||
msgstr "Expand player"
|
||||
|
||||
@@ -855,7 +855,7 @@ msgstr "New playlist"
|
||||
msgid "Next →"
|
||||
msgstr "Next →"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:166
|
||||
#: src/components/GlobalPlayer.tsx:224
|
||||
msgid "Next track"
|
||||
msgstr "Next track"
|
||||
|
||||
@@ -1006,7 +1006,7 @@ msgstr "Post reply"
|
||||
msgid "Posting…"
|
||||
msgstr "Posting…"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:154
|
||||
#: src/components/GlobalPlayer.tsx:208
|
||||
msgid "Previous track"
|
||||
msgstr "Previous track"
|
||||
|
||||
@@ -1034,15 +1034,15 @@ msgstr "public"
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:241
|
||||
#: src/pages/DumpEdit.tsx:245
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Refresh metadata"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:240
|
||||
#: src/components/GlobalPlayer.tsx:304
|
||||
msgid "Refreshing stream…"
|
||||
msgstr "Refreshing stream…"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:240
|
||||
#: src/pages/DumpEdit.tsx:244
|
||||
msgid "Refreshing…"
|
||||
msgstr "Refreshing…"
|
||||
|
||||
@@ -1080,7 +1080,7 @@ msgstr "Remove like"
|
||||
msgid "Remove vote"
|
||||
msgstr "Remove vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:430
|
||||
#: src/pages/DumpEdit.tsx:418
|
||||
msgid "Replace file"
|
||||
msgstr "Replace file"
|
||||
|
||||
@@ -1106,8 +1106,8 @@ msgstr "Reset failed"
|
||||
msgid "Reset password"
|
||||
msgstr "Reset password"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:391
|
||||
#: src/pages/DumpEdit.tsx:409
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
#: src/pages/DumpEdit.tsx:448
|
||||
msgid "Reset to default"
|
||||
msgstr "Reset to default"
|
||||
|
||||
@@ -1128,7 +1128,7 @@ msgstr "Role"
|
||||
#: src/components/ChatModal.tsx:224
|
||||
#: src/components/CommentThread.tsx:341
|
||||
#: src/pages/Dump.tsx:398
|
||||
#: src/pages/DumpEdit.tsx:473
|
||||
#: src/pages/DumpEdit.tsx:487
|
||||
#: src/pages/PlaylistDetail.tsx:930
|
||||
#: src/pages/UserPublicProfile.tsx:1725
|
||||
#: src/pages/UserPublicProfile.tsx:1798
|
||||
@@ -1237,13 +1237,13 @@ msgstr "This page does not exist."
|
||||
msgid "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"
|
||||
msgstr "Thumbnail"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:174
|
||||
#: src/components/PlaylistCreateForm.tsx:74
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
#: src/pages/DumpEdit.tsx:438
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
|
||||
@@ -1256,6 +1256,11 @@ msgstr "Title is required."
|
||||
msgid "Today"
|
||||
msgstr "Today"
|
||||
|
||||
#. placeholder {0}: i + 1
|
||||
#: src/components/GlobalPlayer.tsx:318
|
||||
msgid "Track {0}"
|
||||
msgstr "Track {0}"
|
||||
|
||||
#: src/components/ChatModal.tsx:637
|
||||
msgid "Type a message…"
|
||||
msgstr "Type a message…"
|
||||
@@ -1299,7 +1304,7 @@ msgstr "Upvoted"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Upvoted ({0}{1})"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:422
|
||||
#: src/pages/DumpEdit.tsx:410
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
@@ -1345,7 +1350,7 @@ msgid "View dump →"
|
||||
msgstr "View dump →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:905
|
||||
#: src/pages/DumpEdit.tsx:447
|
||||
#: src/pages/DumpEdit.tsx:459
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "What makes it worth it?"
|
||||
|
||||
@@ -1363,7 +1368,7 @@ msgid "Why & where"
|
||||
msgstr "Why & where"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:904
|
||||
#: src/pages/DumpEdit.tsx:446
|
||||
#: src/pages/DumpEdit.tsx:458
|
||||
msgid "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/ChatModal.tsx:231
|
||||
#: src/components/CommentThread.tsx:127
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/ConfirmModal.tsx:34
|
||||
#: src/components/DumpCreateModal.tsx:946
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:406
|
||||
#: src/pages/DumpEdit.tsx:470
|
||||
#: src/pages/DumpEdit.tsx:484
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/UserPublicProfile.tsx:1733
|
||||
#: src/pages/UserPublicProfile.tsx:1806
|
||||
@@ -275,7 +275,7 @@ msgid "Checking invite…"
|
||||
msgstr "Vérification de l'invitation…"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:56
|
||||
#: src/components/Modal.tsx:58
|
||||
#: src/components/Modal.tsx:54
|
||||
msgid "Close"
|
||||
msgstr "Fermer"
|
||||
|
||||
@@ -283,7 +283,7 @@ msgstr "Fermer"
|
||||
msgid "Close anyway"
|
||||
msgstr "Fermer quand même"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:184
|
||||
#: src/components/GlobalPlayer.tsx:242
|
||||
msgid "Close player"
|
||||
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."
|
||||
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"
|
||||
msgstr "Réduire le lecteur"
|
||||
|
||||
@@ -320,7 +320,7 @@ msgstr "Impossible de changer le mot de passe"
|
||||
msgid "Could not load."
|
||||
msgstr "Impossible de charger."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:371
|
||||
#: src/pages/DumpEdit.tsx:434
|
||||
msgid "Could not save"
|
||||
msgstr "Sauvegarde impossible"
|
||||
|
||||
@@ -365,7 +365,7 @@ msgstr "Onglet par défaut"
|
||||
#: src/components/ChatModal.tsx:241
|
||||
#: src/components/CommentThread.tsx:390
|
||||
#: src/components/CommentThread.tsx:396
|
||||
#: src/components/ConfirmModal.tsx:16
|
||||
#: src/components/ConfirmModal.tsx:17
|
||||
#: src/pages/PlaylistDetail.tsx:919
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
@@ -379,8 +379,8 @@ msgstr "Supprimer la catégorie"
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:264
|
||||
#: src/pages/DumpEdit.tsx:466
|
||||
#: src/pages/DumpEdit.tsx:265
|
||||
#: src/pages/DumpEdit.tsx:480
|
||||
msgid "Delete dump"
|
||||
msgstr "Supprimer la reco"
|
||||
|
||||
@@ -394,7 +394,7 @@ msgstr "Supprimer la collection"
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Supprimer ce commentaire ?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:263
|
||||
#: src/pages/DumpEdit.tsx:264
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
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"
|
||||
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"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
@@ -514,7 +514,7 @@ msgstr "Adresse e-mail"
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Saisissez une recherche."
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:176
|
||||
#: src/components/GlobalPlayer.tsx:234
|
||||
msgid "Expand player"
|
||||
msgstr "Agrandir le lecteur"
|
||||
|
||||
@@ -855,7 +855,7 @@ msgstr "Nouvelle collection"
|
||||
msgid "Next →"
|
||||
msgstr "Suivant →"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:166
|
||||
#: src/components/GlobalPlayer.tsx:224
|
||||
msgid "Next track"
|
||||
msgstr "Piste suivante"
|
||||
|
||||
@@ -1006,7 +1006,7 @@ msgstr "Publier la réponse"
|
||||
msgid "Posting…"
|
||||
msgstr "Publication…"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:154
|
||||
#: src/components/GlobalPlayer.tsx:208
|
||||
msgid "Previous track"
|
||||
msgstr "Piste précédente"
|
||||
|
||||
@@ -1034,15 +1034,15 @@ msgstr "public"
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:241
|
||||
#: src/pages/DumpEdit.tsx:245
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Actualiser les métadonnées"
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:240
|
||||
#: src/components/GlobalPlayer.tsx:304
|
||||
msgid "Refreshing stream…"
|
||||
msgstr "Actualisation du flux…"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:240
|
||||
#: src/pages/DumpEdit.tsx:244
|
||||
msgid "Refreshing…"
|
||||
msgstr "Actualisation…"
|
||||
|
||||
@@ -1080,7 +1080,7 @@ msgstr "Retirer le j'aime"
|
||||
msgid "Remove vote"
|
||||
msgstr "Retirer le vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:430
|
||||
#: src/pages/DumpEdit.tsx:418
|
||||
msgid "Replace file"
|
||||
msgstr "Remplacer le fichier"
|
||||
|
||||
@@ -1106,8 +1106,8 @@ msgstr "Échec de la réinitialisation"
|
||||
msgid "Reset password"
|
||||
msgstr "Réinitialiser le mot de passe"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:391
|
||||
#: src/pages/DumpEdit.tsx:409
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
#: src/pages/DumpEdit.tsx:448
|
||||
msgid "Reset to default"
|
||||
msgstr "Réinitialiser par défaut"
|
||||
|
||||
@@ -1128,7 +1128,7 @@ msgstr "Rôle"
|
||||
#: src/components/ChatModal.tsx:224
|
||||
#: src/components/CommentThread.tsx:341
|
||||
#: src/pages/Dump.tsx:398
|
||||
#: src/pages/DumpEdit.tsx:473
|
||||
#: src/pages/DumpEdit.tsx:487
|
||||
#: src/pages/PlaylistDetail.tsx:930
|
||||
#: src/pages/UserPublicProfile.tsx:1725
|
||||
#: src/pages/UserPublicProfile.tsx:1798
|
||||
@@ -1237,13 +1237,13 @@ msgstr "Rien à voir, circulez."
|
||||
msgid "This reset link is missing or malformed."
|
||||
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:375
|
||||
#: src/pages/DumpEdit.tsx:383
|
||||
msgid "Thumbnail"
|
||||
msgstr "Miniature"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:174
|
||||
#: src/components/PlaylistCreateForm.tsx:74
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
#: src/pages/DumpEdit.tsx:438
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
@@ -1256,6 +1256,11 @@ msgstr "Un titre est requis."
|
||||
msgid "Today"
|
||||
msgstr "Aujourd'hui"
|
||||
|
||||
#. placeholder {0}: i + 1
|
||||
#: src/components/GlobalPlayer.tsx:318
|
||||
msgid "Track {0}"
|
||||
msgstr "Piste {0}"
|
||||
|
||||
#: src/components/ChatModal.tsx:637
|
||||
msgid "Type a message…"
|
||||
msgstr "Écrivez un message…"
|
||||
@@ -1299,7 +1304,7 @@ msgstr "Voté"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Votés ({0}{1})"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:422
|
||||
#: src/pages/DumpEdit.tsx:410
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
@@ -1345,7 +1350,7 @@ msgid "View dump →"
|
||||
msgstr "Voir la reco →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:905
|
||||
#: src/pages/DumpEdit.tsx:447
|
||||
#: src/pages/DumpEdit.tsx:459
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "Pourquoi on en voudrait ?"
|
||||
|
||||
@@ -1363,7 +1368,7 @@ msgid "Why & where"
|
||||
msgstr "Pourquoi & où"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:904
|
||||
#: src/pages/DumpEdit.tsx:446
|
||||
#: src/pages/DumpEdit.tsx:458
|
||||
msgid "Why?"
|
||||
msgstr "Pourquoi ?"
|
||||
|
||||
|
||||
@@ -214,41 +214,42 @@ export function DumpEdit() {
|
||||
<h1 className="form-page-title">{dump.title}</h1>
|
||||
</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
|
||||
key={dump.id}
|
||||
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}
|
||||
thumbUploading={thumbUploading}
|
||||
onThumbnailChange={handleThumbnailChange}
|
||||
@@ -272,6 +273,7 @@ export function DumpEdit() {
|
||||
|
||||
interface DumpEditFormProps {
|
||||
dump: Dump;
|
||||
preview: React.ReactNode;
|
||||
currentThumbnailSrc: string | null;
|
||||
thumbUploading: boolean;
|
||||
onThumbnailChange: (file: File) => void;
|
||||
@@ -290,6 +292,7 @@ interface EditValues {
|
||||
|
||||
function DumpEditForm({
|
||||
dump,
|
||||
preview,
|
||||
currentThumbnailSrc,
|
||||
thumbUploading,
|
||||
onThumbnailChange,
|
||||
@@ -367,111 +370,123 @@ function DumpEditForm({
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<FormError title={t`Could not save`} />
|
||||
<form className="form dump-edit-form" onSubmit={onSubmit}>
|
||||
{
|
||||
/* 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">
|
||||
<span className="form-label">
|
||||
<Trans>Thumbnail</Trans>
|
||||
</span>
|
||||
<div className="dump-edit-thumbnail-row">
|
||||
<ImagePicker
|
||||
src={currentThumbnailSrc}
|
||||
size={64}
|
||||
onChange={onThumbnailChange}
|
||||
uploading={thumbUploading}
|
||||
/>
|
||||
{dump.thumbnailMime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border"
|
||||
onClick={onThumbnailReset}
|
||||
disabled={thumbUploading}
|
||||
>
|
||||
<Trans>Reset to default</Trans>
|
||||
</button>
|
||||
)}
|
||||
<div className="form-field">
|
||||
<span className="form-label">
|
||||
<Trans>Thumbnail</Trans>
|
||||
</span>
|
||||
<div className="dump-edit-thumbnail-row">
|
||||
<ImagePicker
|
||||
src={currentThumbnailSrc}
|
||||
size={64}
|
||||
onChange={onThumbnailChange}
|
||||
uploading={thumbUploading}
|
||||
/>
|
||||
{dump.thumbnailMime && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-border"
|
||||
onClick={onThumbnailReset}
|
||||
disabled={thumbUploading}
|
||||
>
|
||||
<Trans>Reset to default</Trans>
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<TextField<EditValues>
|
||||
name="title"
|
||||
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 }}
|
||||
/>
|
||||
<div className="dump-edit-panel dump-edit-panel--details">
|
||||
<FormError title={t`Could not save`} />
|
||||
|
||||
{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}
|
||||
/>
|
||||
<TextField<EditValues>
|
||||
name="title"
|
||||
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 }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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