The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
639 lines
25 KiB
TypeScript
639 lines
25 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 "../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 share link (/watch/, /file/, /folder/): one file, or a folder's list.
|
||
// 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;
|
||
/** "image": a picture (a GIF cut from a clip, an uploaded photo), which the server serves as itself. */
|
||
preview?: "image" | null;
|
||
width?: number | null;
|
||
height?: number | 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"; video?: boolean };
|
||
|
||
// 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";
|
||
}
|
||
|
||
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
|
||
* any link (`main.SHARE_PREFIXES`). */
|
||
function shareToken(path: string): string {
|
||
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
|
||
}
|
||
|
||
export default function WatchPage() {
|
||
const token = shareToken(window.location.pathname);
|
||
// 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);
|
||
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
|
||
// it said `video` only had video links' words, so a file without it reads as one.
|
||
const [locked, setLocked] = useState<"video" | "file" | "folder">("video");
|
||
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 === "folder" ? "folder" : m.video === false ? "file" : "video");
|
||
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
|
||
: locked === "file"
|
||
? T.passwordTitleFile
|
||
: T.passwordTitle}
|
||
</div>
|
||
<Input
|
||
className="w-full"
|
||
type="password"
|
||
value={password}
|
||
autoFocus
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
// Not again while one is on its way, as the button: a held or repeated Enter would
|
||
// spend the link's tries (and its view count) once per press.
|
||
onKeyDown={(e) => e.key === "Enter" && password && !unlocking && 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"
|
||
>
|
||
{locked === "video" ? T.watch : T.open}
|
||
</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" && meta.preview === "image")
|
||
return <SharedPicture meta={meta} nested={nested} />;
|
||
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 shared picture: the picture itself, no player (a GIF plays and repeats on its own, and nobody
|
||
* pauses one), its name and kind under it, and the Download the link allows. A GIF says how to get
|
||
* it moving in a chat: a link's preview is the chat's to draw, the dragged-in file moves everywhere. */
|
||
function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) {
|
||
// In a folder the page's own heading is the folder's.
|
||
const Heading = nested ? "h2" : "h1";
|
||
const gif = meta.container?.toLowerCase() === "gif";
|
||
const dims = meta.width && meta.height ? `${meta.width}×${meta.height}` : null;
|
||
return (
|
||
<div>
|
||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||
<RemoteImage
|
||
src={meta.file_url}
|
||
alt={meta.title || T.file}
|
||
width={meta.width ?? undefined}
|
||
height={meta.height ?? undefined}
|
||
retries={1}
|
||
// A picture link always has a picture: a missing one failed to load, and says so —
|
||
// the bare file glyph would read as a file with no preview. Only once the retry failed
|
||
// too: while it waits, the frame stays empty.
|
||
pending={<div aria-hidden className="w-48 h-32" />}
|
||
fallback={
|
||
<div role="status" className="flex flex-col items-center gap-2 px-12 py-10 text-muted">
|
||
<FileIcon className="w-12 h-12" aria-hidden />
|
||
<p className="text-sm">{T.pictureFailed}</p>
|
||
</div>
|
||
}
|
||
className={clsx(
|
||
"max-w-full h-auto w-auto mx-auto block",
|
||
nested ? "max-h-[60dvh]" : "max-h-[80dvh]",
|
||
)}
|
||
/>
|
||
</div>
|
||
<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 || T.file}
|
||
</Heading>
|
||
<div className="text-sm text-muted">
|
||
{[meta.container?.toUpperCase(), dims, formatBytes(meta.size_bytes, lang)]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
</div>
|
||
</div>
|
||
{meta.allow_download && (
|
||
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
|
||
{T.download}
|
||
</LinkButton>
|
||
)}
|
||
</div>
|
||
{gif && meta.allow_download && <p className="mt-3 text-sm text-muted">{T.gifTip}</p>}
|
||
</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>
|
||
</>
|
||
);
|
||
// A picture opens above the list as a video does (it is shown, not played); any other
|
||
// file only downloads.
|
||
const picture = f.media_kind === "file" && f.preview === "image";
|
||
return (
|
||
<li
|
||
key={f.id}
|
||
className={clsx(
|
||
"flex items-center gap-1 rounded-lg",
|
||
f.id === playing && "bg-accent/10",
|
||
)}
|
||
>
|
||
{f.media_kind === "file" && !picture ? (
|
||
<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={`${picture ? T.open : 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}
|
||
{!picture && <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>
|
||
);
|
||
}
|