import { useEffect, useId, 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 { useMediaSession } from "../lib/mediaSession"; import { useMediaKeys } from "../lib/mediaKeys"; import { linkPoint, saveLinkPoint, useResume } from "../lib/resume"; 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"; import { T, lang } from "./watchStrings"; // Standalone, login-free video page for a shared /watch/ link. Its own entry (watchMain.tsx, // watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny // self-contained i18n dict (`watchStrings`: the public viewer has no app language preference). 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; } } /** What a 404 from the link means: `video_preparing` is a link whose video is being made again (a * re-download, a conversion) and will work shortly; any other is a link that is gone. */ async function notFoundState(r: Response): Promise<"preparing" | "gone"> { const body = (await r.json().catch(() => null)) as { detail?: { reason?: string } } | null; return body?.detail?.reason === "video_preparing" ? "preparing" : "gone"; } 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" | "preparing" | "error" >(token ? "loading" : "gone"); const [meta, setMeta] = useState(null); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const errorId = useId(); const [unlocking, setUnlocking] = useState(false); // Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and // the resume bind to it once it exists. const [media, setMedia] = useState(null); useMediaSession( status === "ready" ? media : null, meta && { title: meta.title || "Video", artist: meta.uploader || "Siftlode", artwork: meta.poster_url, }, ); useMediaKeys(media); // It opens where this browser stopped in this link (the viewer has no account to keep it in). // Read once per link, at render: the point a later checkpoint writes must not move the start. const [start] = useState(() => (token ? linkPoint(token) : 0)); useResume(media, token || null, start, (position, duration) => saveLinkPoint(token, position, duration), ); // 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(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); // Set once the viewer acted (Retry, the password): the state that answers replaces the button they // were on, so its own Retry takes the focus rather than leaving it on . Not on the first load, // where nothing had the focus to lose. const [acted, setActed] = useState(false); useEffect(() => { if (!token) return; // the seed above already says "gone" send(`/api/public/watch/${encodeURIComponent(token)}`) .then((r): Promise => { // Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that // its video is being made again (`notFoundState`). 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 notFoundState(r); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise; }) .then((m) => { if (typeof m === "string") setStatus(m); else if (m.needs_password) setStatus("password"); else { setMeta(m); setStatus("ready"); } }) .catch(() => setStatus("error")); }, [token, attempt]); const retry = () => { setActed(true); setStatus("loading"); setAttempt((n) => n + 1); }; // unlock()'s steps, apart so `unlocking` clears on every way out without a try/finally (the React // Compiler cannot lower one yet, and skips the whole page). const unlockSteps = async () => { 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(await notFoundState(r)); return; } if (!r.ok) throw new Error(`HTTP ${r.status}`); setMeta(await r.json()); setStatus("ready"); }; const unlock = async () => { setActed(true); setUnlocking(true); setError(null); try { await unlockSteps(); } catch { setError(T.unlockFailed); } setUnlocking(false); }; return ( // No page fill of its own (B6): the body's ambient scheme backdrop shows through, as in the app.
Siftlode
{status === "loading" && (
{T.loading}
)} {status === "gone" && (
{T.gone}
{T.goneHint}
)} {status === "preparing" && (
{/* An alert, like the failure below: it is inserted already filled, which a polite status region is not reliably announced for — and a Retry that answers "still preparing" would otherwise say nothing at all. */}
{T.preparing}
{T.preparingHint}
)} {status === "error" && (
{/* Announced: it replaces the loading line, and this page has no live region of its own. */}
{T.failed}
{T.failedHint}
)} {status === "password" && (
{T.passwordTitle}
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 && ( )}
)} {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, as the unlock card's Watch is. */

{meta.title || T.file}

{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)] .filter(Boolean) .join(" · ")}
{meta.allow_download && ( } className="mt-5" > {T.download} )}
)} {status === "ready" && meta && meta.media_kind !== "file" && (
{meta.audio ? ( /* Audio-only asset (mp3): WebKit won't play it in a
)}
{T.brand}
); }