feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself

The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected
to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB).
A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF
per kept segment; Join says why it is unavailable instead of going dead. The share page shows a
picture as itself with its Download, and a picture in a folder opens above the list.
This commit is contained in:
2026-11-11 02:10:07 +01:00
parent c221452a00
commit 27ccf253f5
15 changed files with 775 additions and 37 deletions
+58 -3
View File
@@ -44,6 +44,10 @@ type FileMeta = {
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;
@@ -335,6 +339,8 @@ function FileView({
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
@@ -465,6 +471,52 @@ function FileView({
);
}
/** 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}
fallback={<FileIcon className="m-12 w-12 h-12 text-muted" aria-hidden />}
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 }) {
@@ -536,6 +588,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
</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}
@@ -544,19 +599,19 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
f.id === playing && "bg-accent/10",
)}
>
{f.media_kind === "file" ? (
{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={`${T.play}: ${title}`}
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}
<Play aria-hidden className="size-4 shrink-0 text-muted" />
{!picture && <Play aria-hidden className="size-4 shrink-0 text-muted" />}
</Pressable>
)}
{folder.allow_download && (