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
439 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|