mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
assets and tier plans revamp
This commit is contained in:
@@ -13,18 +13,11 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
|
||||
@@ -45,6 +38,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [speedIdx, setSpeedIdx] = useState(2); // 1×
|
||||
// True from the moment `src` is set until the browser has actually
|
||||
// buffered enough to play (or stalls mid-playback) — same gap VideoBlock
|
||||
// closes, so a slow-loading audio file doesn't just sit there silently.
|
||||
const [mediaLoading, setMediaLoading] = useState(true);
|
||||
|
||||
const assetId = content.asset_id ?? null;
|
||||
const storageProvider = content.storage_provider ?? null;
|
||||
@@ -79,6 +76,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, isS3]);
|
||||
|
||||
// Reset the loading spinner whenever the src actually changes (new
|
||||
// asset picked, or the S3 token above just resolved).
|
||||
useEffect(() => { setMediaLoading(true); }, [src]);
|
||||
|
||||
// ── Audio events ──────────────────────────────────────────────────────────
|
||||
|
||||
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
|
||||
@@ -90,6 +91,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
|
||||
}
|
||||
}, []);
|
||||
const onLoadedData = useCallback(() => setMediaLoading(false), []);
|
||||
const onCanPlay = useCallback(() => setMediaLoading(false), []);
|
||||
const onWaiting = useCallback(() => setMediaLoading(true), []);
|
||||
const onPlaying = useCallback(() => setMediaLoading(false), []);
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -186,6 +191,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
onLoadedData={onLoadedData}
|
||||
onCanPlay={onCanPlay}
|
||||
onWaiting={onWaiting}
|
||||
onPlaying={onPlaying}
|
||||
preload="metadata"
|
||||
/>
|
||||
|
||||
@@ -206,13 +215,13 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
|
||||
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25">
|
||||
{thumbnail ? (
|
||||
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25 flex items-center justify-center">
|
||||
{mediaLoading ? (
|
||||
<Spinner className="size-6 text-white" />
|
||||
) : thumbnail ? (
|
||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-7 h-7 text-white/30" />
|
||||
</div>
|
||||
<Music2 className="w-7 h-7 text-white/30" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
|
||||
@@ -12,15 +12,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||
|
||||
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,6 +37,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [overlayVisible,setOverlayVisible]= useState(true);
|
||||
// True from the moment `src` is set until the browser has actually
|
||||
// buffered enough to render a frame (or stalls mid-playback) — closes the
|
||||
// gap between "token resolved" (the `loading` from useAssetPreviewSrc
|
||||
// above) and "video is actually watchable", which used to render as a
|
||||
// blank black box with no indication anything was happening, especially
|
||||
// on large/slow-loading files.
|
||||
const [mediaLoading, setMediaLoading] = useState(true);
|
||||
|
||||
// Reset player when video changes
|
||||
useEffect(() => {
|
||||
@@ -52,6 +52,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
setMediaLoading(true);
|
||||
}, [src]);
|
||||
|
||||
// ── Video event listeners ─────────────────────────────────────────────────
|
||||
@@ -64,17 +65,29 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
setCurrentTime(v.currentTime);
|
||||
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
|
||||
const onLoadedData = () => setMediaLoading(false);
|
||||
const onCanPlay = () => setMediaLoading(false);
|
||||
const onWaiting = () => setMediaLoading(true);
|
||||
const onPlaying = () => setMediaLoading(false);
|
||||
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
v.addEventListener("loadeddata", onLoadedData);
|
||||
v.addEventListener("canplay", onCanPlay);
|
||||
v.addEventListener("waiting", onWaiting);
|
||||
v.addEventListener("playing", onPlaying);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
v.removeEventListener("loadeddata", onLoadedData);
|
||||
v.removeEventListener("canplay", onCanPlay);
|
||||
v.removeEventListener("waiting", onWaiting);
|
||||
v.removeEventListener("playing", onPlaying);
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
@@ -180,22 +193,32 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Loading spinner — covers the gap between src resolving and the
|
||||
browser actually having a frame to show */}
|
||||
{mediaLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none">
|
||||
<Spinner className="size-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play/pause overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ background: "rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
<button
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||
className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
|
||||
{!mediaLoading && (
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ background: "rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
{playing
|
||||
? <Pause className="size-4 text-black" />
|
||||
: <Play className="size-4 text-black ml-0.5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||
className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
|
||||
>
|
||||
{playing
|
||||
? <Pause className="size-4 text-black" />
|
||||
: <Play className="size-4 text-black ml-0.5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change video hover hint */}
|
||||
{!readOnly && (
|
||||
|
||||
@@ -3,15 +3,7 @@ import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lu
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
|
||||
@@ -10,15 +10,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||
|
||||
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : FileZoomViewer.jsx
|
||||
* Type : Component (Generic)
|
||||
* Description : Zoom/pan/fit viewer for image and PDF files. Used by both the
|
||||
* client task-attachment preview (FilePreview.jsx, via blob:
|
||||
* URLs) and the admin asset preview dialog (AssetPreviewDialog.jsx,
|
||||
* via direct stream-token URLs) — `src` accepts either, this
|
||||
* component doesn't care how the URL was produced.
|
||||
*
|
||||
* Supported:
|
||||
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
|
||||
* application/pdf → pdf.js renders the current page to
|
||||
* <canvas>, same zoom/pan controls,
|
||||
* with page navigation for multi-page PDFs
|
||||
*
|
||||
* Not supported (shows a message instead of attempting render):
|
||||
* DOCX, video, audio, and any other file type — video/audio
|
||||
* assets use the existing admin VideoBlock/AudioBlock players
|
||||
* instead (see Blocks/Admin/), not this viewer.
|
||||
*
|
||||
* Props:
|
||||
* src {string} – object URL or direct stream URL
|
||||
* mimeType {string}
|
||||
* fileName {string}
|
||||
* loading {boolean} – true while the parent is still resolving the src
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
ZoomIn, ZoomOut, Maximize2, ChevronLeft, ChevronRight, FileWarning,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 4;
|
||||
const SCALE_STEP = 0.25;
|
||||
|
||||
// ─── Resolve viewer mode from mime type / extension ───────────────────────────
|
||||
const resolveMode = (mimeType = '', fileName = '') => {
|
||||
if (mimeType === 'image/jpeg' || mimeType === 'image/png') return 'image';
|
||||
if (mimeType === 'application/pdf') return 'pdf';
|
||||
|
||||
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
|
||||
if (['jpg', 'jpeg', 'png'].includes(ext)) return 'image';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
return 'unsupported';
|
||||
};
|
||||
|
||||
// ─── Not supported message ─────────────────────────────────────────────────────
|
||||
const UnsupportedMessage = ({ fileName }) => (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
|
||||
<FileWarning className="size-12" />
|
||||
<p className="text-sm font-medium">Full preview not supported for this file type.</p>
|
||||
<p className="text-xs max-w-xs text-center">
|
||||
{fileName ? `"${fileName}" ` : 'This file '}
|
||||
can't be opened in the zoom viewer. JPEG, PNG, and PDF files are supported.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Toolbar ──────────────────────────────────────────────────────────────────
|
||||
const ZoomToolbar = ({
|
||||
scale, onZoomIn, onZoomOut, onFit,
|
||||
page, numPages, onPrevPage, onNextPage,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted/40">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomOut} disabled={scale <= MIN_SCALE} aria-label="Zoom out">
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs w-12 text-center tabular-nums select-none">{Math.round(scale * 100)}%</span>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomIn} disabled={scale >= MAX_SCALE} aria-label="Zoom in">
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onFit} aria-label="Fit to screen">
|
||||
<Maximize2 className="size-4" /> Fit to screen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{numPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onPrevPage} disabled={page <= 1} aria-label="Previous page">
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums">{page} / {numPages}</span>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onNextPage} disabled={page >= numPages} aria-label="Next page">
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
|
||||
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
|
||||
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
|
||||
const containerRef = useRef(null);
|
||||
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
|
||||
|
||||
// ── Scroll to zoom (centered on cursor) ────────────────────────────────────
|
||||
const onWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP;
|
||||
setScale((s) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, +(s + delta).toFixed(2))));
|
||||
}, [setScale]);
|
||||
|
||||
// ── Drag to pan ───────────────────────────────────────────────────────────
|
||||
const onPointerDown = (e) => {
|
||||
dragRef.current = {
|
||||
dragging: true,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: offset.x,
|
||||
origY: offset.y,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!dragRef.current.dragging) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
setOffset({ x: dragRef.current.origX + dx, y: dragRef.current.origY + dy });
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
dragRef.current.dragging = false;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [onWheel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
|
||||
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
||||
)}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onDoubleClick={fitFn}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
transition: dragRef.current.dragging ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
||||
const ImageViewer = ({ src, fileName }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<ZoomToolbar
|
||||
scale={scale}
|
||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
|
||||
onFit={fit}
|
||||
page={1}
|
||||
numPages={1}
|
||||
onPrevPage={() => {}}
|
||||
onNextPage={() => {}}
|
||||
/>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||
<img
|
||||
src={src}
|
||||
alt={fileName}
|
||||
draggable={false}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="max-h-[380px] max-w-none select-none pointer-events-none"
|
||||
/>
|
||||
</ZoomPanArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
||||
const PdfViewer = ({ src, fileName }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [pdfDoc, setPdfDoc] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [numPages, setNumPages] = useState(1);
|
||||
const [rendering, setRendering] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
|
||||
// ── Load the PDF document ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
const doc = await pdfjsLib.getDocument(src).promise;
|
||||
if (cancelled) return;
|
||||
setPdfDoc(doc);
|
||||
setNumPages(doc.numPages);
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [src]);
|
||||
|
||||
// ── Render current page to canvas ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!pdfDoc) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setRendering(true);
|
||||
try {
|
||||
const pdfPage = await pdfDoc.getPage(page);
|
||||
const viewport = pdfPage.getViewport({ scale: 1.5 }); // base render scale for crispness
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || cancelled) return;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
await pdfPage.render({ canvasContext: ctx, viewport }).promise;
|
||||
} catch {
|
||||
if (!cancelled) setLoadError(true);
|
||||
} finally {
|
||||
if (!cancelled) setRendering(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [pdfDoc, page]);
|
||||
|
||||
if (loadError) return <UnsupportedMessage fileName={fileName} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<ZoomToolbar
|
||||
scale={scale}
|
||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
|
||||
onFit={fit}
|
||||
page={page}
|
||||
numPages={numPages}
|
||||
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
|
||||
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
|
||||
/>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
|
||||
</ZoomPanArea>
|
||||
{rendering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
||||
const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
|
||||
const mode = resolveMode(mimeType, fileName);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[420px]">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src || mode === 'unsupported') {
|
||||
return <UnsupportedMessage fileName={fileName} />;
|
||||
}
|
||||
|
||||
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} />;
|
||||
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} />;
|
||||
|
||||
return <UnsupportedMessage fileName={fileName} />;
|
||||
};
|
||||
|
||||
export default FileZoomViewer;
|
||||
@@ -0,0 +1,22 @@
|
||||
// components/generic/TranscodeStatusBanner.jsx
|
||||
//
|
||||
// Small inline notice for a video asset's background remux (see backend
|
||||
// services/assetTranscode.service.js) — .mov/.mkv uploads get repackaged
|
||||
// into a faststart .mp4 for fast in-browser playback. Non-blocking: the
|
||||
// asset still plays from its original (slower) file while this is pending/
|
||||
// processing, this banner is just a heads-up. Renders nothing once the
|
||||
// asset is "done"/"none" (fast already) — "failed" also renders nothing,
|
||||
// the asset just quietly keeps playing from the original.
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function TranscodeStatusBanner({ status }) {
|
||||
if (status !== "pending" && status !== "processing") return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/40 border-b">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Optimizing this video for faster playback — still watchable now, will load quicker shortly.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user