feat: Files media viewer — brand video player, image lightbox, media grid

SOTA lightbox patterns adapted to the hub: images zoom (wheel/double-click)
pan rotate with a filmstrip + counter + focus trap; videos play in a
brand-skinned player (scrubber with buffered range, volume, speed, PiP,
fullscreen, keyboard: space/k, arrows ±5s, j/l ±10s, m, f, p, up/down).
Payloads are E2E-encrypted, so thumbnails and playback decrypt client-side
through the session cache (direct objects via presigned GET + CEK; others
via the streaming route) — the grid shows real video stills captured in the
browser. List view stays for documents; Media/Grid is the default for
image/video items. FileRow gains a View action for media rows.
This commit is contained in:
glass beadian 2026-10-17 12:58:00 +00:00
parent 98cf36fc51
commit 0bdf13816d
6 changed files with 562 additions and 1 deletions

View file

@ -10,6 +10,8 @@ import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client"
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { FileRow } from "../../../components/files/file-row";
import { MediaThumb } from "../../../components/files/media-thumb";
import { MediaViewer } from "../../../components/files/media-viewer";
import { UploadDropzone } from "../../../components/files/upload-dropzone";
import { ShareDialog } from "../../../components/files/share-dialog";
import { RetentionDialog } from "../../../components/files/retention-dialog";
@ -82,6 +84,8 @@ export default function FilesPage(): React.ReactElement {
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [view, setView] = useState<"list" | "grid">("grid");
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@ -368,6 +372,34 @@ export default function FilesPage(): React.ReactElement {
)}
{showList && items.length > 0 && (
<div className="mb-3 flex items-center gap-1" role="tablist" aria-label={t({ en: "View", es: "Vista" })}>
<Button size="sm" variant={view === "grid" ? "primary" : "outline"} aria-pressed={view === "grid"} onClick={() => setView("grid")}>{t({ en: "Media", es: "Medios" })}</Button>
<Button size="sm" variant={view === "list" ? "primary" : "outline"} aria-pressed={view === "list"} onClick={() => setView("list")}>{t({ en: "List", es: "Lista" })}</Button>
</div>
)}
{showList && view === "grid" && items.length > 0 && (() => {
const mediaItems = items.filter((item) => item.mimeClass === "image" || item.mimeClass === "video");
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{mediaItems.map((item) => (
<MediaThumb key={item.id} item={item} onOpen={() => setViewerIndex(mediaItems.findIndex((m) => m.id === item.id))} />
))}
</div>
{items.some((item) => item.mimeClass !== "image" && item.mimeClass !== "video") && (
<p className="mt-3 text-2xs text-text-faint">
{t({ en: "Media view shows images and videos — switch to List for documents and archives.", es: "La vista de medios muestra imágenes y vídeos — cambia a Lista para documentos y archivos." })}
</p>
)}
{viewerIndex !== null && mediaItems[viewerIndex] !== undefined && (
<MediaViewer items={mediaItems} index={viewerIndex} onClose={() => setViewerIndex(null)} onIndex={setViewerIndex} />
)}
</>
);
})()}
{showList && view === "list" && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<FileRow

View file

@ -13,7 +13,8 @@ function authHeaders(): Record<string, string> {
* same base URL, Bearer token, cookie credentials — then streams the body
* into a Blob and clicks a temporary anchor.
*/
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
/** The decrypted payload as a Blob (viewer/thumbnail source; downloads save it). */
export async function downloadGroupFileBlob(itemId: string, mimeType = "application/octet-stream"): Promise<Blob> {
const response = await fetch(`${API_URL}/files/${encodeURIComponent(itemId)}/download`, {
credentials: "include",
headers: authHeaders(),
@ -23,6 +24,11 @@ export async function downloadGroupFile(itemId: string, fileName: string): Promi
throw new ApiError(response.status, `Download failed (HTTP ${response.status})`, null);
}
const blob = await response.blob();
return blob.type === "" ? new Blob([blob], { type: mimeType }) : blob;
}
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
const blob = await downloadGroupFileBlob(itemId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;

View file

@ -0,0 +1,80 @@
"use client";
/**
* Grid thumbnail for one media item: decrypts once through the session cache,
* renders images directly, and captures a frame from videos (hidden <video> +
* canvas at 1 s) so the grid shows real stills without a server thumbnailer.
*/
import { useEffect, useRef, useState } from "react";
import { cachedMedia, type CachedMedia } from "../../lib/media-cache";
import { MimeIcon } from "./mime-icon";
import { formatBytes } from "../video/format";
import type { GroupFile } from "./types";
export function MediaThumb({ item, onOpen }: { item: GroupFile; onOpen: () => void }): React.ReactElement {
const [media, setMedia] = useState<CachedMedia | null>(null);
const [failed, setFailed] = useState(false);
const [poster, setPoster] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const isVideo = item.mimeClass === "video";
useEffect(() => {
let alive = true;
cachedMedia(item)
.then((m) => { if (alive) setMedia(m); })
.catch(() => { if (alive) setFailed(true); });
return () => { alive = false; };
}, [item]);
// Grab a still from the video once its bytes are cached: seek 1 s, paint the
// frame onto a canvas, keep the data URL as the poster.
useEffect(() => {
if (!isVideo || media === null || poster !== null) return;
const v = document.createElement("video");
v.src = media.url;
v.muted = true;
v.preload = "auto";
const capture = (): void => {
try {
const canvas = document.createElement("canvas");
canvas.width = v.videoWidth || 640;
canvas.height = v.videoHeight || 360;
canvas.getContext("2d")?.drawImage(v, 0, 0, canvas.width, canvas.height);
setPoster(canvas.toDataURL("image/jpeg", 0.7));
} catch { /* frame capture is best-effort; the tile falls back to the icon */ }
};
v.addEventListener("loadeddata", () => { v.currentTime = Math.min(1, v.duration / 3); }, { once: true });
v.addEventListener("seeked", capture, { once: true });
v.addEventListener("error", () => setFailed(true), { once: true });
return () => { v.removeAttribute("src"); v.load(); };
}, [isVideo, media, poster]);
return (
<button
type="button"
onClick={onOpen}
className="group relative aspect-video w-full overflow-hidden rounded-lg border border-border bg-surface-2 text-left transition-colors hover:border-accent"
aria-label={`${item.name}${isVideo ? ` (${formatBytes(item.sizeBytes)})` : ""}`}
>
{failed ? (
<span className="flex h-full w-full items-center justify-center text-text-faint"><MimeIcon mimeClass={item.mimeClass} size={28} /></span>
) : media === null ? (
<span className="block h-full w-full animate-pulse bg-surface-2" aria-hidden />
) : isVideo ? (
<>
{poster !== null
? // eslint-disable-next-line @next/next/no-img-element -- captured frame data URL
<img src={poster} alt="" className="h-full w-full object-cover" />
: <video src={media.url} muted preload="metadata" className="h-full w-full object-cover" />}
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-2xs text-white">▶</span>
</>
) : (
// eslint-disable-next-line @next/next/no-img-element -- decrypted object URL
<img src={media.url} alt="" className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105" />
)}
<span className="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/80 to-transparent px-2 pb-1 pt-4 text-2xs text-white opacity-0 transition-opacity group-hover:opacity-100">
{item.name}
</span>
</button>
);
}

View file

@ -0,0 +1,189 @@
"use client";
/**
* Media viewer (lightbox) for the Files tab — images and videos of the
* filtered list, opened from the grid or a row's view button.
*
* Images: wheel/double-click zoom to the cursor, drag pan, rotate (r), reset.
* Videos: the brand player (./video-player). Both: ←/→ navigate the filtered
* media list, Esc closes, a filmstrip jumps, focus is trapped while open.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useFilesLang } from "./i18n";
import { cachedMedia, type CachedMedia } from "../../lib/media-cache";
import { VideoPlayer } from "./video-player";
import { formatBytes } from "../video/format";
import type { GroupFile } from "./types";
export function MediaViewer({ items, index, onClose, onIndex }: {
items: GroupFile[];
index: number;
onClose: () => void;
onIndex: (next: number) => void;
}): React.ReactElement | null {
const { t } = useFilesLang();
const item = items[index];
const [media, setMedia] = useState<CachedMedia | null>(null);
const [error, setError] = useState<string | null>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [rotation, setRotation] = useState(0);
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const isVideo = item?.mimeClass === "video";
useEffect(() => {
if (item === undefined) return;
setMedia(null); setError(null); setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0);
let alive = true;
cachedMedia(item)
.then((m) => { if (alive) setMedia(m); })
.catch((err: unknown) => { if (alive) setError(err instanceof Error ? err.message : String(err)); });
return () => { alive = false; };
}, [item]);
useEffect(() => { closeRef.current?.focus(); }, [index]);
const step = useCallback((delta: number): void => {
if (items.length === 0) return;
onIndex((index + delta + items.length) % items.length);
}, [index, items.length, onIndex]);
// Keyboard + focus trap.
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
switch (e.key) {
case "Escape": onClose(); break;
case "ArrowLeft": step(-1); break;
case "ArrowRight": step(1); break;
case "+": case "=": setScale((s) => Math.min(8, s * 1.4)); break;
case "-": case "_": setScale((s) => Math.max(1, s / 1.4)); break;
case "r": setRotation((r) => (r + 90) % 360); break;
case "0": setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0); break;
default: return;
}
e.preventDefault();
};
const trap = (e: KeyboardEvent): void => {
if (e.key !== "Tab") return;
const focusables = closeRef.current?.closest("[data-viewer]")?.querySelectorAll<HTMLElement>("button, [href], input, video");
if (focusables === undefined || focusables.length === 0) return;
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
window.addEventListener("keydown", onKey);
window.addEventListener("keydown", trap);
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", trap); };
}, [onClose, step]);
if (item === undefined) return null;
return (
<div
data-viewer
role="dialog"
aria-modal="true"
aria-label={item.name}
className="fixed inset-0 z-50 flex flex-col bg-black/95"
onClick={onClose}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-2 text-xs text-white/80" onClick={(e) => e.stopPropagation()}>
<button ref={closeRef} type="button" aria-label={t({ en: "Close", es: "Cerrar" })} className="w-8 rounded px-1 py-0.5 hover:bg-white/20" onClick={onClose}>✕</button>
<span className="truncate font-medium text-white">{item.name}</span>
<span className="text-white/50">{formatBytes(item.sizeBytes)}</span>
<span className="ml-auto tabular-nums">{index + 1} / {items.length}</span>
</div>
<div className="relative flex-1 overflow-hidden">
{error !== null && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-danger">{error}</div>
)}
{error === null && media === null && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-white/70">{t({ en: "Decrypting…", es: "Descifrando…" })}</div>
)}
{error === null && media !== null && (isVideo ? (
<div className="absolute inset-0 flex items-center justify-center p-2">
<div className="h-full w-full max-w-5xl">
<VideoPlayer src={media.url} title={item.name} />
</div>
</div>
) : (
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
onWheel={(e) => {
e.preventDefault();
setScale((s) => Math.max(1, Math.min(8, e.deltaY < 0 ? s * 1.15 : s / 1.15)));
}}
onDoubleClick={() => setScale((s) => (s > 1 ? 1 : 2.5))}
onPointerDown={(e) => { if (scale > 1) dragRef.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y }; }}
onPointerMove={(e) => {
const d = dragRef.current;
if (d !== null) setOffset({ x: d.ox + (e.clientX - d.x), y: d.oy + (e.clientY - d.y) });
}}
onPointerUp={() => { dragRef.current = null; }}
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element -- decrypted object URL, not a static asset */}
<img
src={media.url}
alt={item.name}
draggable={false}
className="max-h-full max-w-full transition-transform duration-100"
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotation}deg)`, cursor: scale > 1 ? "grab" : "default" }}
/>
</div>
))}
{error === null && media !== null && !isVideo && (
<div className="absolute bottom-16 left-1/2 flex -translate-x-1/2 gap-2 rounded bg-black/70 px-3 py-1 text-xs text-white" onClick={(e) => e.stopPropagation()}>
<button type="button" aria-label={t({ en: "Zoom out", es: "Alejar" })} className="px-2 hover:text-accent" onClick={() => setScale((s) => Math.max(1, s / 1.4))}>−</button>
<span className="tabular-nums leading-5">{Math.round(scale * 100)}%</span>
<button type="button" aria-label={t({ en: "Zoom in", es: "Acercar" })} className="px-2 hover:text-accent" onClick={() => setScale((s) => Math.min(8, s * 1.4))}>+</button>
<button type="button" aria-label={t({ en: "Rotate", es: "Rotar" })} className="px-2 hover:text-accent" onClick={() => setRotation((r) => (r + 90) % 360)}>⟳</button>
<button type="button" aria-label={t({ en: "Reset", es: "Restablecer" })} className="px-2 hover:text-accent" onClick={() => { setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0); }}>⌂</button>
</div>
)}
<button type="button" aria-label={t({ en: "Previous", es: "Anterior" })} className="absolute left-2 top-1/2 -translate-y-1/2 rounded bg-black/60 px-3 py-4 text-xl text-white hover:bg-black/80" onClick={(e) => { e.stopPropagation(); step(-1); }}>‹</button>
<button type="button" aria-label={t({ en: "Next", es: "Siguiente" })} className="absolute right-2 top-1/2 -translate-y-1/2 rounded bg-black/60 px-3 py-4 text-xl text-white hover:bg-black/80" onClick={(e) => { e.stopPropagation(); step(1); }}>›</button>
</div>
<div className="flex gap-1.5 overflow-x-auto px-3 py-2" onClick={(e) => e.stopPropagation()}>
{items.map((entry, i) => (
<button
key={entry.id}
type="button"
aria-label={entry.name}
aria-current={i === index}
className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 ${i === index ? "border-accent" : "border-transparent opacity-60 hover:opacity-100"}`}
onClick={() => onIndex(i)}
>
<FilmThumb item={entry} />
</button>
))}
</div>
</div>
);
}
function FilmThumb({ item }: { item: GroupFile }): React.ReactElement {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
if (item.mimeClass !== "image" && item.mimeClass !== "video") return;
cachedMedia(item).then((m) => { if (alive) setUrl(m.url); }).catch(() => undefined);
return () => { alive = false; };
}, [item]);
if (item.mimeClass === "video") {
return (
<span className="flex h-full w-full items-center justify-center bg-surface-2 text-xs text-text-dim">▶</span>
);
}
if (url === null) return <span className="block h-full w-full animate-pulse bg-surface-2" />;
// eslint-disable-next-line @next/next/no-img-element -- decrypted object URL
return <img src={url} alt="" className="h-full w-full object-cover" />;
}

View file

@ -0,0 +1,184 @@
"use client";
/**
* Brand video player: the hub's controls on a plain <video> — scrubber with
* buffered ranges, volume, speed, PiP, fullscreen, and the keyboard set the
* audit expects (space/k play, ←/→ ±5s, j/l ±10s, m, f, p, ↑/↓ volume).
* Controls auto-hide after 2.5 s while playing; any pointer wake brings them
* back. No external player dependency — every token comes from the theme.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useFilesLang } from "./i18n";
const RATES = [0.5, 1, 1.5, 2] as const;
function clock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
return h > 0 ? `${h}:${mm}:${String(s).padStart(2, "0")}` : `${mm}:${String(s).padStart(2, "0")}`;
}
export function VideoPlayer({ src, poster, title }: { src: string; poster?: string; title: string }): React.ReactElement {
const { t } = useFilesLang();
const shellRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const hideTimer = useRef<number | null>(null);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [rate, setRate] = useState(1);
const [rateOpen, setRateOpen] = useState(false);
const [full, setFull] = useState(false);
const [controls, setControls] = useState(true);
const wake = useCallback((): void => {
setControls(true);
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => setControls(false), 2500);
}, []);
const toggle = useCallback((): void => {
const v = videoRef.current;
if (v === null) return;
if (v.paused) void v.play(); else v.pause();
}, []);
const seekBy = useCallback((delta: number): void => {
const v = videoRef.current;
if (v === null) return;
v.currentTime = Math.max(0, Math.min(v.duration || 0, v.currentTime + delta));
wake();
}, [wake]);
const setVol = useCallback((next: number): void => {
const v = videoRef.current;
if (v === null) return;
const clamped = Math.max(0, Math.min(1, next));
v.volume = clamped;
v.muted = clamped === 0;
setVolume(clamped);
setMuted(clamped === 0);
}, []);
const toggleFull = useCallback((): void => {
if (document.fullscreenElement !== null) void document.exitFullscreen();
else void shellRef.current?.requestFullscreen();
}, []);
const togglePip = useCallback(async (): Promise<void> => {
const v = videoRef.current;
if (v === null) return;
if (document.pictureInPictureElement !== null) await document.exitPictureInPicture();
else await v.requestPictureInPicture().catch(() => undefined);
}, []);
useEffect(() => {
const onFull = (): void => setFull(document.fullscreenElement !== null);
document.addEventListener("fullscreenchange", onFull);
return () => document.removeEventListener("fullscreenchange", onFull);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
switch (e.key) {
case " ": case "k": e.preventDefault(); toggle(); break;
case "ArrowLeft": e.preventDefault(); seekBy(-5); break;
case "ArrowRight": e.preventDefault(); seekBy(5); break;
case "j": seekBy(-10); break;
case "l": seekBy(10); break;
case "m": { const v = videoRef.current; if (v !== null) { v.muted = !v.muted; setMuted(v.muted); } break; }
case "f": toggleFull(); break;
case "p": void togglePip(); break;
case "ArrowUp": e.preventDefault(); setVol(volume + 0.1); break;
case "ArrowDown": e.preventDefault(); setVol(volume - 0.1); break;
default: return;
}
wake();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [seekBy, setVol, toggle, toggleFull, togglePip, volume, wake]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
return (
<div
ref={shellRef}
className={`relative h-full w-full select-none bg-black ${full ? "" : "rounded-lg overflow-hidden"}`}
onMouseMove={wake}
onMouseLeave={() => { if (playing) setControls(false); }}
onClick={toggle}
onDoubleClick={toggleFull}
>
<video
ref={videoRef}
src={src}
poster={poster}
className="h-full w-full object-contain"
playsInline
onClick={(e) => e.stopPropagation()}
onPlay={() => { setPlaying(true); wake(); }}
onPause={() => { setPlaying(false); setControls(true); }}
onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onProgress={(e) => {
const v = e.currentTarget;
if (v.buffered.length > 0) setBuffered(v.buffered.end(v.buffered.length - 1));
}}
/>
{!playing && (
<button
type="button"
aria-label={t({ en: "Play", es: "Reproducir" })}
className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center rounded-full bg-accent/90 text-2xl text-white shadow-lg transition-transform hover:scale-105"
>
▶
</button>
)}
<div
className={`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-3 pb-2 pt-6 transition-opacity duration-200 ${controls ? "opacity-100" : "opacity-0"}`}
onClick={(e) => e.stopPropagation()}
>
<div className="relative mb-1 h-1.5 w-full">
<div className="absolute inset-0 rounded bg-white/20" />
<div className="absolute inset-y-0 left-0 rounded bg-white/30" style={{ width: duration > 0 ? `${(buffered / duration) * 100}%` : "0%" }} />
<div className="absolute inset-y-0 left-0 rounded bg-accent" style={{ width: `${pct}%` }} />
<input
type="range" min={0} max={duration || 0} step={0.1} value={time}
aria-label={t({ en: "Seek", es: "Buscar" })}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={(e) => { const v = videoRef.current; if (v !== null) v.currentTime = Number(e.target.value); wake(); }}
/>
</div>
<div className="flex items-center gap-2 text-xs text-white">
<button type="button" aria-label={playing ? t({ en: "Pause", es: "Pausar" }) : t({ en: "Play", es: "Reproducir" })} onClick={toggle} className="w-8 text-base">{playing ? "⏸" : "▶"}</button>
<span className="tabular-nums">{clock(time)} / {clock(duration)}</span>
<button type="button" aria-label={muted ? t({ en: "Unmute", es: "Activar sonido" }) : t({ en: "Mute", es: "Silenciar" })} className="w-8" onClick={() => { const v = videoRef.current; if (v !== null) { v.muted = !v.muted; setMuted(v.muted); } }}>{muted ? "🔇" : "🔊"}</button>
<input type="range" min={0} max={1} step={0.05} value={muted ? 0 : volume} aria-label={t({ en: "Volume", es: "Volumen" })} className="h-1 w-16 accent-accent" onChange={(e) => setVol(Number(e.target.value))} />
<div className="relative ml-auto">
<button type="button" className="w-10 rounded px-1 py-0.5 hover:bg-white/20" onClick={() => setRateOpen((v) => !v)}>{rate}×</button>
{rateOpen && (
<div className="absolute bottom-6 right-0 rounded bg-black/90 py-1">
{RATES.map((r) => (
<button key={r} type="button" className={`block w-14 px-2 py-1 text-left hover:bg-white/20 ${r === rate ? "text-accent" : ""}`} onClick={() => { const v = videoRef.current; if (v !== null) v.playbackRate = r; setRate(r); setRateOpen(false); }}>{r}×</button>
))}
</div>
)}
</div>
<button type="button" aria-label={t({ en: "Picture in picture", es: "Picture in picture" })} className="w-8" onClick={() => void togglePip()}>⧉</button>
<button type="button" aria-label={full ? t({ en: "Exit fullscreen", es: "Salir de pantalla completa" }) : t({ en: "Fullscreen", es: "Pantalla completa" })} className="w-8" onClick={toggleFull}>{full ? "⤡" : "⛶"}</button>
</div>
</div>
<span className="sr-only">{title}</span>
</div>
);
}

View file

@ -0,0 +1,70 @@
/**
* Session-scoped media cache for the Files tab: every item's payload is
* end-to-end encrypted, so "viewing" means fetch → decrypt → object URL.
* The cache makes that happen exactly once per item per session and shares
* the resulting URL between the grid thumbnails, the viewer and downloads.
*/
import type { GroupFile } from "../components/files/types";
import { downloadGroupFileBlob } from "../components/files/files-client";
import { isDirectObject } from "./files-direct";
import { base64ToBytes, openDirectFrameBytes } from "./direct-storage";
import { api } from "./api-client";
export interface CachedMedia {
url: string;
mime: string;
bytes: number;
}
const cache = new Map<string, CachedMedia>();
const inflight = new Map<string, Promise<CachedMedia>>();
/** Bounded session: drop the oldest entries past the cap (object URLs revoke). */
const MAX_ENTRIES = 60;
async function fetchBlob(item: GroupFile): Promise<Blob> {
if (isDirectObject(item)) {
const ticket = await api.post<{ url: string; cekB64: string; frame: "SPENC2" }>(
`/files/${encodeURIComponent(item.id)}/direct-download`,
{},
);
const res = await fetch(ticket.url);
if (!res.ok) throw new Error(`direct fetch failed (HTTP ${res.status})`);
const sealed = new Uint8Array(await res.arrayBuffer());
const plain = await openDirectFrameBytes(sealed, base64ToBytes(ticket.cekB64));
return new Blob([plain.slice()], { type: item.mimeType });
}
return downloadGroupFileBlob(item.id, item.mimeType);
}
/** Fetch (or reuse) the decrypted object URL for one item. */
export async function cachedMedia(item: GroupFile): Promise<CachedMedia> {
const hit = cache.get(item.id);
if (hit !== undefined) return hit;
const pending = inflight.get(item.id);
if (pending !== undefined) return pending;
const task = (async (): Promise<CachedMedia> => {
const blob = await fetchBlob(item);
const entry: CachedMedia = { url: URL.createObjectURL(blob), mime: blob.type || item.mimeType, bytes: blob.size };
cache.set(item.id, entry);
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) {
const dropped = cache.get(oldest);
if (dropped !== undefined) URL.revokeObjectURL(dropped.url);
cache.delete(oldest);
}
}
return entry;
})();
inflight.set(item.id, task);
try {
return await task;
} finally {
inflight.delete(item.id);
}
}
/** Preload without consuming (warms the grid). */
export function warmMedia(item: GroupFile): void {
void cachedMedia(item).catch(() => undefined);
}