Files
siftlode/frontend/src/components/WatchPage.tsx
T
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
2026-10-06 23:55:41 +02:00

400 lines
16 KiB
TypeScript

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/<token> 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<Meta | null>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(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<HTMLMediaElement | null>(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<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);
// 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 <body>. 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<Meta | "preparing" | "gone"> => {
// 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<Meta>;
})
.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.
<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 === "preparing" && (
<div className="flex-1 grid place-items-center text-center">
{/* 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. */}
<div role="alert">
<div className="text-lg font-semibold">{T.preparing}</div>
<div className="text-sm text-muted mt-1">{T.preparingHint}</div>
<Button
variant="primary"
size="md"
onClick={retry}
autoFocus={acted}
className="mt-4"
>
{T.retry}
</Button>
</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}
autoFocus={acted}
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, 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>
);
}