Files
siftlode/frontend/src/components/WatchPage.tsx
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00

439 lines
18 KiB
TypeScript

import { useEffect, useId, useRef, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { LinkButton } from "./ui/LinkButton";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
// self-contained i18n dict (the public viewer has no app language preference).
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
});
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
}
type Meta = {
needs_password: boolean;
title?: string | null;
uploader?: string | null;
uploader_url?: string | null;
source_url?: string | null;
extra_links?: string[];
duration_s?: number | null;
audio?: boolean;
media_kind?: "video" | "audio" | "file" | null;
size_bytes?: number | null;
container?: string | null;
allow_download?: boolean;
file_url?: string;
poster_url?: string | null;
};
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
try {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
const STR = {
en: {
loading: "Loading…",
gone: "This link is no longer available.",
goneHint: "It may have expired or been revoked by the owner.",
passwordTitle: "This video is password-protected",
passwordPlaceholder: "Enter password",
watch: "Watch",
wrong: "Wrong password.",
download: "Download",
file: "File",
links: "Links",
brand: "Shared via Siftlode",
repeat: "Repeat",
failed: "Couldn't load this link.",
failedHint: "Check your connection and try again.",
retry: "Try again",
tooMany: "Too many attempts. Try again in a few minutes.",
unlockFailed: "Couldn't check the password. Try again.",
},
hu: {
loading: "Betöltés…",
gone: "Ez a link már nem elérhető.",
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
passwordTitle: "Ez a videó jelszóval védett",
passwordPlaceholder: "Add meg a jelszót",
watch: "Megnézem",
wrong: "Hibás jelszó.",
download: "Letöltés",
file: "Fájl",
links: "Linkek",
brand: "Megosztva a Siftlode-dal",
repeat: "Ismétlés",
failed: "Nem sikerült betölteni a linket.",
failedHint: "Ellenőrizd a kapcsolatot, és próbáld újra.",
retry: "Újrapróbálás",
tooMany: "Túl sok próbálkozás. Próbáld újra pár perc múlva.",
unlockFailed: "Nem sikerült ellenőrizni a jelszót. Próbáld újra.",
},
de: {
loading: "Wird geladen…",
gone: "Dieser Link ist nicht mehr verfügbar.",
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
passwordTitle: "Dieses Video ist passwortgeschützt",
passwordPlaceholder: "Passwort eingeben",
watch: "Ansehen",
wrong: "Falsches Passwort.",
download: "Herunterladen",
file: "Datei",
links: "Links",
brand: "Geteilt über Siftlode",
repeat: "Wiederholen",
failed: "Der Link konnte nicht geladen werden.",
failedHint: "Prüfe deine Verbindung und versuche es erneut.",
retry: "Erneut versuchen",
tooMany: "Zu viele Versuche. Versuche es in ein paar Minuten erneut.",
unlockFailed: "Das Passwort konnte nicht geprüft werden. Versuche es erneut.",
},
} as const;
const lang =
((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
: "en";
const T = STR[lang];
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
token ? "loading" : "gone",
);
const [meta, setMeta] = useState<Meta | null>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
const mediaRef = useRef<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => {
mediaRef.current = el;
};
useMediaSession(mediaRef, meta, status === "ready");
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
writeJSON(LS.watchRepeat, next);
};
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r) => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
// else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying,
// not a dead link.
if (r.status === 404) return null;
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Meta>;
})
.then((m) => {
if (!m) setStatus("gone");
else if (m.needs_password) setStatus("password");
else {
setMeta(m);
setStatus("ready");
}
})
.catch(() => setStatus("error"));
}, [token, attempt]);
const retry = () => {
setStatus("loading");
setAttempt((n) => n + 1);
};
const unlock = async () => {
setUnlocking(true);
setError(null);
try {
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (r.status === 403) {
setError(T.wrong);
return;
}
// Unlocking is rate-limited per link: too many tries is not a dead link either.
if (r.status === 429) {
setError(T.tooMany);
return;
}
if (r.status === 404) {
setStatus("gone");
return;
}
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setMeta(await r.json());
setStatus("ready");
} catch {
setError(T.unlockFailed);
} finally {
setUnlocking(false);
}
};
return (
// No page fill of its own (B6): the body's ambient scheme backdrop shows through, as in the app.
<div className="min-h-dvh text-fg flex flex-col items-center">
<div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col">
<div className="flex items-center gap-2 mb-5 text-sm text-muted">
<PlayCircle className="w-5 h-5 text-accent" />
<span className="font-semibold text-fg">Siftlode</span>
</div>
{status === "loading" && (
<div role="status" className="flex-1 grid place-items-center text-muted">
{T.loading}
</div>
)}
{status === "gone" && (
<div className="flex-1 grid place-items-center text-center">
<div>
<div className="text-lg font-semibold">{T.gone}</div>
<div className="text-sm text-muted mt-1">{T.goneHint}</div>
</div>
</div>
)}
{status === "error" && (
<div className="flex-1 grid place-items-center text-center">
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
<div role="alert">
<div className="text-lg font-semibold">{T.failed}</div>
<div className="text-sm text-muted mt-1">{T.failedHint}</div>
<Button variant="primary" size="md" onClick={retry} className="mt-4">
{T.retry}
</Button>
</div>
</div>
)}
{status === "password" && (
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
</div>
<Input
className="w-full"
type="password"
value={password}
autoFocus
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined}
/>
{/* Announced: focus stays in the input, so a silent message reads as a submit that did nothing. */}
{error && (
<div id={errorId} role="alert" className="text-sm text-danger mt-2">
{error}
</div>
)}
<Button
variant="primary"
size="md"
fullWidth
onClick={unlock}
disabled={!password || unlocking}
className="mt-4"
>
{T.watch}
</Button>
</div>
</div>
)}
{status === "ready" && meta && meta.media_kind === "file" && (
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. It is the page's one action, so it is the filled
primary (E28), as the unlock card's Watch is. */
<div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
<FileIcon className="w-8 h-8 text-accent" />
</div>
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</h1>
<div className="text-sm text-muted mt-1">
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
{meta.allow_download && (
<LinkButton
href={meta.file_url}
variant="primary"
icon={<Download />}
className="mt-5"
>
{T.download}
</LinkButton>
)}
</div>
</div>
)}
{status === "ready" && meta && meta.media_kind !== "file" && (
<div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className={`${SURFACE.card} p-4`}>
<audio
ref={setMedia}
controls
loop={repeat}
src={meta.file_url}
className="w-full block"
/>
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={(el) => {
setMedia(el);
// So Brave iOS's background play keeps it going when the app is left.
exposeVideoToBackgroundPlay(el);
}}
controls
playsInline
loop={repeat}
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80dvh] max-w-full mx-auto block"
/>
</div>
)}
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
than squeezing the title into a column a word wide. */}
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</h1>
{meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted hover:text-accent hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-muted">{meta.uploader}</div>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<Chip
size="md"
pressed={repeat}
icon={<Repeat className="size-3.5" aria-hidden />}
onClick={toggleRepeat}
className="font-medium"
>
{T.repeat}
</Chip>
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
</div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div>
)}
</div>
<div className="text-xs text-muted py-4">{T.brand}</div>
</div>
);
}