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:
parent
98cf36fc51
commit
0bdf13816d
6 changed files with 562 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
80
apps/web/src/components/files/media-thumb.tsx
Normal file
80
apps/web/src/components/files/media-thumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
apps/web/src/components/files/media-viewer.tsx
Normal file
189
apps/web/src/components/files/media-viewer.tsx
Normal 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" />;
|
||||
}
|
||||
184
apps/web/src/components/files/video-player.tsx
Normal file
184
apps/web/src/components/files/video-player.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/lib/media-cache.ts
Normal file
70
apps/web/src/lib/media-cache.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue