import { useEffect, useId, useState } from "react"; import clsx from "clsx"; import { Download, File as FileIcon, Film, Link2, Lock, Music, Play, PlayCircle, Repeat, } from "lucide-react"; import { Button } from "../components/ui/Button"; import { Chip } from "../components/ui/Chip"; import { Pressable } from "../components/ui/Pressable"; import RemoteImage from "../components/ui/RemoteImage"; import { MEDIA_BADGE } from "../components/ui/mediaCardStyles"; import { formatBytes, formatDuration } 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 "../components/ui/Input"; import { SURFACE } from "../components/ui/surfaces"; import { IconLink, LinkButton } from "../components/ui/LinkButton"; import { T, lang } from "./watchStrings"; // Standalone, login-free page for a shared /watch/ link: one file, or a folder's list of them. // 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). // One shared file: a file link's page, or one row of a folder link's. type FileMeta = { kind?: "file"; 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; }; // A folder link's page: what is in the folder now, newest first. type FolderMeta = { kind: "folder"; name: string; allow_download: boolean; files: (FileMeta & { id: number })[]; }; type Meta = FileMeta | FolderMeta; // What the link answers before it is opened: a password link says only which kind it is. type Answer = | (Meta & { needs_password?: false }) | { needs_password: true; kind?: "file" | "folder" }; // 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); // A password link's kind, for its title, until it is unlocked. const [locked, setLocked] = useState<"file" | "folder">("file"); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const errorId = useId(); const [unlocking, setUnlocking] = useState(false); // 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) { setLocked(m.kind ?? "file"); 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" && (
{" "} {locked === "folder" ? T.passwordTitleFolder : 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?.kind === "folder" && ( )} {status === "ready" && meta && meta.kind !== "folder" && ( )}
{T.brand}
); } /** One shared file: its player (or, for a document, its card), title and actions. `pointKey` is where * this browser keeps the point it stopped at: the link's token, or the token and the file's id in a * folder. `nested` is a file shown inside a folder's page, whose heading is the folder's. */ function FileView({ meta, pointKey, nested = false, }: { meta: FileMeta; pointKey: string; nested?: boolean; }) { const Heading = nested ? "h2" : "h1"; // 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(media, { title: meta.title || "Video", artist: meta.uploader || "Siftlode", artwork: meta.poster_url, }); useMediaKeys(media); // It opens where this browser stopped in this file (the viewer has no account to keep it in). // Read once per file, at render: the point a later checkpoint writes must not move the start. const [start] = useState(() => linkPoint(pointKey)); useResume(media, pointKey, start, (position, duration) => saveLinkPoint(pointKey, 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); }; if (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. */ return (
{meta.title || T.file}
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)] .filter(Boolean) .join(" · ")}
{meta.allow_download && ( } className="mt-5"> {T.download} )}
); return (
{meta.audio ? ( /* Audio-only asset (mp3): WebKit won't play it in a
); } /** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a * song carries its length. */ function RowThumb({ file }: { file: FileMeta }) { const Glyph = file.media_kind === "file" ? FileIcon : file.audio ? Music : Film; const length = file.media_kind !== "file" && file.duration_s ? file.duration_s : null; // Above the length badge when there is one, so the badge never covers it. const glyph = ( ); return (
{length ? ( {formatDuration(length)} ) : null}
); } /** A folder link's page: the folder's name, its files, and the one playing above them. Play opens the * same player a file link has; a document only downloads. */ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) { const [playing, setPlaying] = useState(null); const current = folder.files.find((f) => f.id === playing); const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0); return (

{folder.name}

{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)] .filter(Boolean) .join(" · ")}
{current && ( // Keyed by the file, so a new pick is a new player (its own resume point, its own media // session) and brings the player into view — on a phone it is above the row just tapped.
el?.scrollIntoView({ block: "start" })}>
)} {folder.files.length === 0 ? (

{T.folderEmpty}

) : (
    {folder.files.map((f) => { const title = f.title || T.file; const body = ( <> {title} {formatBytes(f.size_bytes, lang)} ); return (
  • {f.media_kind === "file" ? (
    {body}
    ) : ( setPlaying(f.id)} className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5" > {body} )} {folder.allow_download && ( )}
  • ); })}
)}
); }