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.
400 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|