A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id, exactly one set; a folder's links go with it). The page shows what is in the folder when it is opened, newest first: a video or a song plays in the same player a file link has, above the list, each keeping its own resume point; a document only downloads, never opens as a page. A file moved out stops opening through the link at its next request. Password, expiry and stream-only work as on a file link; the unfurl names the folder and counts its files, never their names. Source folders and the Hub cannot be shared. The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail marks a folder a live link opens. Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once, and the new-link form goes back to downloadable after making a link.
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
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 "./ui/Button";
|
|
import { Chip } from "./ui/Chip";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import RemoteImage from "./ui/RemoteImage";
|
|
import { MEDIA_BADGE } from "./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 "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { IconLink, LinkButton } from "./ui/LinkButton";
|
|
import { T, lang } from "./watchStrings";
|
|
|
|
// Standalone, login-free page for a shared /watch/<token> 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<Meta | null>(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<string | null>(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 <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<Answer | "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<Answer>;
|
|
})
|
|
.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.
|
|
<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" />{" "}
|
|
{locked === "folder" ? T.passwordTitleFolder : 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?.kind === "folder" && (
|
|
<FolderView folder={meta} token={token} />
|
|
)}
|
|
{status === "ready" && meta && meta.kind !== "folder" && (
|
|
<FileView meta={meta} pointKey={token} />
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted py-4">{T.brand}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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<HTMLMediaElement | null>(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<boolean>(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 (
|
|
<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>
|
|
<Heading className="text-lg font-semibold leading-snug break-words">
|
|
{meta.title || T.file}
|
|
</Heading>
|
|
<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>
|
|
);
|
|
|
|
return (
|
|
<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
|
|
autoPlay={nested}
|
|
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
|
|
// In a folder the viewer picked it from the list just now: it starts.
|
|
autoPlay={nested}
|
|
loop={repeat}
|
|
src={meta.file_url}
|
|
poster={meta.poster_url || undefined}
|
|
className={clsx("max-w-full mx-auto block", nested ? "max-h-[60dvh]" : "max-h-[80dvh]")}
|
|
/>
|
|
</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">
|
|
<Heading className="text-lg font-semibold leading-snug break-words">
|
|
{meta.title || "Video"}
|
|
</Heading>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
/** 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 = (
|
|
<Glyph
|
|
aria-hidden
|
|
className={clsx(
|
|
"absolute inset-x-0 mx-auto text-muted",
|
|
length ? "top-1 size-4" : "inset-y-0 my-auto size-5",
|
|
)}
|
|
/>
|
|
);
|
|
return (
|
|
<div className="relative h-10 w-16 shrink-0 overflow-hidden rounded-lg bg-surface">
|
|
<RemoteImage
|
|
src={file.poster_url}
|
|
alt=""
|
|
loading="lazy"
|
|
className="h-full w-full object-cover"
|
|
fallback={glyph}
|
|
/>
|
|
{length ? (
|
|
<span className={clsx(MEDIA_BADGE, "bottom-0.5 right-0.5")}>{formatDuration(length)}</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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<number | null>(null);
|
|
const current = folder.files.find((f) => f.id === playing);
|
|
const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0);
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div>
|
|
<h1 className="text-xl font-bold leading-snug break-words">{folder.name}</h1>
|
|
<div className="text-sm text-muted">
|
|
{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</div>
|
|
</div>
|
|
{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.
|
|
<div key={current.id} ref={(el) => el?.scrollIntoView({ block: "start" })}>
|
|
<FileView meta={current} pointKey={`${token}/${current.id}`} nested />
|
|
</div>
|
|
)}
|
|
{folder.files.length === 0 ? (
|
|
<p className="text-sm text-muted">{T.folderEmpty}</p>
|
|
) : (
|
|
<ul className="flex flex-col">
|
|
{folder.files.map((f) => {
|
|
const title = f.title || T.file;
|
|
const body = (
|
|
<>
|
|
<RowThumb file={f} />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{title}</span>
|
|
<span className="block text-xs text-muted">
|
|
{formatBytes(f.size_bytes, lang)}
|
|
</span>
|
|
</span>
|
|
</>
|
|
);
|
|
return (
|
|
<li
|
|
key={f.id}
|
|
className={clsx(
|
|
"flex items-center gap-1 rounded-lg",
|
|
f.id === playing && "bg-accent/10",
|
|
)}
|
|
>
|
|
{f.media_kind === "file" ? (
|
|
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
|
|
) : (
|
|
<Pressable
|
|
layer
|
|
selectable
|
|
aria-current={f.id === playing ? "true" : undefined}
|
|
aria-label={`${T.play}: ${title}`}
|
|
onClick={() => setPlaying(f.id)}
|
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
|
|
>
|
|
{body}
|
|
<Play aria-hidden className="size-4 shrink-0 text-muted" />
|
|
</Pressable>
|
|
)}
|
|
{folder.allow_download && (
|
|
<IconLink href={f.file_url} label={`${T.download}: ${title}`}>
|
|
<Download />
|
|
</IconLink>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|