mirror of
https://github.com/ergosteur/instaarchive-viewer.git
synced 2026-07-04 11:07:15 -04:00
perf: implement background thumbnail generation and inter-post preloading
Key changes: - Added Web Worker for background image thumbnailing with a 1MiB threshold to optimize CPU/memory usage. - Implemented a serial task queue for memory-safe high-res image processing, preventing OOM crashes. - Added inter-post preloading in the modal for seamless 'Previous/Next' navigation. - Refined scanning UI with double-buffering and a dark background to completely eliminate white flashes. - Renamed project to 'instaarchive-viewer' in package.json. - Fixed 'Open image in new tab' by denylisting /archives and /api in PWA config.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "react-example",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
55
src/App.tsx
55
src/App.tsx
@@ -23,8 +23,9 @@ import { Post, ServerArchive } from './types';
|
|||||||
import { ArchiveDashboard } from './components/ArchiveDashboard';
|
import { ArchiveDashboard } from './components/ArchiveDashboard';
|
||||||
import { StoryViewer } from './components/StoryViewer';
|
import { StoryViewer } from './components/StoryViewer';
|
||||||
import { PostModal } from './components/PostModal';
|
import { PostModal } from './components/PostModal';
|
||||||
import { VideoThumbnail } from './components/VideoThumbnail';
|
import { PostThumbnail } from './components/PostThumbnail';
|
||||||
import { useArchiveScanner } from './hooks/useArchiveScanner';
|
import { useArchiveScanner } from './hooks/useArchiveScanner';
|
||||||
|
import { useThumbnailQueue } from './hooks/useThumbnailQueue';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [showStoryViewer, setShowStoryViewer] = useState(false);
|
const [showStoryViewer, setShowStoryViewer] = useState(false);
|
||||||
@@ -43,6 +44,8 @@ export default function App() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { cacheHits, requestThumbnail } = useThumbnailQueue();
|
||||||
|
|
||||||
const refreshCachedArchives = useCallback(async () => {
|
const refreshCachedArchives = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await idb.keys();
|
const keys = await idb.keys();
|
||||||
@@ -82,6 +85,8 @@ export default function App() {
|
|||||||
resetScannerState
|
resetScannerState
|
||||||
} = useArchiveScanner('', currentArchive, refreshCachedArchives);
|
} = useArchiveScanner('', currentArchive, refreshCachedArchives);
|
||||||
|
|
||||||
|
const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
username,
|
username,
|
||||||
fullName,
|
fullName,
|
||||||
@@ -285,13 +290,31 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : isScanning ? (
|
) : isScanning ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden text-black">
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden text-black bg-gray-900">
|
||||||
<AnimatePresence>{currentScanningImage && (<motion.div key={currentScanningImage} initial={{ opacity: 0 }} animate={{ opacity: 0.4 }} exit={{ opacity: 0 }} className="absolute inset-0 z-0 text-black"><img src={currentScanningImage} alt="" className="w-full h-full object-cover blur-[100px] scale-110 text-black" /></motion.div>)}</AnimatePresence>
|
{currentScanningImage && (
|
||||||
<div className="absolute inset-0 bg-white/20 z-1 text-black" />
|
<img
|
||||||
|
src={currentScanningImage}
|
||||||
|
className="hidden"
|
||||||
|
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<motion.img
|
||||||
|
key={lastLoadedScanningImage}
|
||||||
|
src={lastLoadedScanningImage || undefined}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.25 }}
|
||||||
|
transition={{ duration: 1.5 }}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover blur-[25px] scale-110"
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/20 z-1" />
|
||||||
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
|
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
|
||||||
<div className="text-center space-y-2 text-black"><div className="text-4xl font-bold tracking-tight italic font-serif text-black/80 drop-shadow-sm text-black">Scanning Archive...</div><div className="flex items-center justify-center gap-3 text-black"><div className="h-[1px] w-12 bg-black/10 text-black" /><p className="text-black/40 text-[10px] uppercase tracking-[0.3em] font-bold text-black">{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}</p><div className="h-[1px] w-12 bg-black/10 text-black" /></div></div>
|
<div className="text-center space-y-2 text-black"><div className="text-4xl font-bold tracking-tight italic font-serif text-black/80 drop-shadow-sm text-black">Scanning Archive...</div><div className="flex items-center justify-center gap-3 text-black"><div className="h-[1px] w-12 bg-black/10 text-black" /><p className="text-black/40 text-[10px] uppercase tracking-[0.3em] font-bold text-black">{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}</p><div className="h-[1px] w-12 bg-black/10 text-black" /></div></div>
|
||||||
<div className="w-full max-w-2xl space-y-4 text-black"><div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-black/40 px-1 text-black"><span className="flex items-center gap-2 text-black"><Loader2 size={12} className="animate-spin text-black" />Phase: {scanningPhase}</span><span className="text-black">{scannedCount} / {totalFiles}</span></div><div className="w-full h-1.5 bg-black/5 rounded-full overflow-hidden backdrop-blur-sm border border-black/5 shadow-inner text-black"><motion.div className="h-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] text-black" initial={{ width: 0 }} animate={{ width: `${(scannedCount / (totalFiles || 1)) * 100}%` }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} /></div></div>
|
<div className="w-full max-w-2xl space-y-4 text-black"><div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-black/40 px-1 text-black"><span className="flex items-center gap-2 text-black"><Loader2 size={12} className="animate-spin text-black" />Phase: {scanningPhase}</span><span className="text-black">{scannedCount} / {totalFiles}</span></div><div className="w-full h-1.5 bg-black/5 rounded-full overflow-hidden backdrop-blur-sm border border-black/5 shadow-inner text-black"><motion.div className="h-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] text-black" initial={{ width: 0 }} animate={{ width: `${(scannedCount / (totalFiles || 1)) * 100}%` }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} /></div></div>
|
||||||
<div className="w-full bg-white/40 backdrop-blur-3xl rounded-2xl shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] border border-white/40 overflow-hidden h-[500px] flex flex-col text-black"><div className="flex items-center justify-between border-b border-black/5 py-3 px-5 bg-white/20 shrink-0 text-black"><div className="flex items-center gap-2 text-black"><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><span className="ml-3 text-black/30 uppercase tracking-[0.2em] text-[9px] font-bold text-black">System Parser Feed</span></div><div className="text-[9px] font-bold text-black/20 uppercase tracking-widest text-black">Live Output</div></div><div className="flex-1 overflow-y-auto space-y-1 scrollbar-hide p-4 text-black">{scannedFilesLog.map((log, idx) => (<div key={`${idx}-${log}`} className="flex gap-4 leading-tight text-[11px] md:text-[12px] font-medium text-black"><span className="text-black/20 shrink-0 tabular-nums text-black">[{new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}]</span><span className={cn("shrink-0 px-1.5 py-0.5 rounded-[4px] text-[9px] font-bold tracking-wider text-black", scanningPhase === 'Indexing' ? "bg-green-500/10 text-green-600/70" : "bg-blue-500/10 text-blue-600/70")}>{scanningPhase === 'Indexing' ? 'IDX' : 'PARSE'}</span><span className="truncate text-black/60 text-black">{log}</span></div>))}{scannedFilesLog.length === 0 && <div className="animate-pulse text-black/20 font-mono text-center mt-20 italic text-black">Initializing scanner context...</div>}</div></div>
|
<div className="w-full bg-white/5 backdrop-blur-lg rounded-2xl shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] border border-white/10 overflow-hidden h-[500px] flex flex-col text-black"><div className="flex items-center justify-between border-b border-black/5 py-3 px-5 bg-white/10 shrink-0 text-black"><div className="flex items-center gap-2 text-black"><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><span className="ml-3 text-black/30 uppercase tracking-[0.2em] text-[9px] font-bold text-black">System Parser Feed</span></div><div className="text-[9px] font-bold text-black/20 uppercase tracking-widest text-black">Live Output</div></div><div className="flex-1 overflow-y-auto space-y-1 scrollbar-hide p-4 text-black">{scannedFilesLog.map((log, idx) => (<div key={`${idx}-${log}`} className="flex gap-4 leading-tight text-[11px] md:text-[12px] font-medium text-black"><span className="text-black/20 shrink-0 tabular-nums text-black">[{new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}]</span><span className={cn("shrink-0 px-1.5 py-0.5 rounded-[4px] text-[9px] font-bold tracking-wider text-black", scanningPhase === 'Indexing' ? "bg-green-500/10 text-green-600/70" : "bg-blue-500/10 text-blue-600/70")}>{scanningPhase === 'Indexing' ? 'IDX' : 'PARSE'}</span><span className="truncate text-black/60 text-black">{log}</span></div>))}{scannedFilesLog.length === 0 && <div className="animate-pulse text-black/20 font-mono text-center mt-20 italic text-black">Initializing scanner context...</div>}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -324,7 +347,11 @@ export default function App() {
|
|||||||
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (<div key={`blank-${i}`} className={cn("bg-gray-100/50 border border-dashed border-gray-200 flex items-center justify-center text-[10px] font-bold text-gray-300 uppercase tracking-tighter text-black", gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]")}>Blank</div>))}
|
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (<div key={`blank-${i}`} className={cn("bg-gray-100/50 border border-dashed border-gray-200 flex items-center justify-center text-[10px] font-bold text-gray-300 uppercase tracking-tighter text-black", gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]")}>Blank</div>))}
|
||||||
{visiblePosts.map((post) => (
|
{visiblePosts.map((post) => (
|
||||||
<motion.div key={post.id} layoutId={post.id} onClick={() => setSelectedPost(post)} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}>
|
<motion.div key={post.id} layoutId={post.id} onClick={() => setSelectedPost(post)} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}>
|
||||||
{post.media[0].type === 'video' ? <VideoThumbnail url={post.media[0].url} /> : <img src={post.thumbnail} alt="" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 text-black" referrerPolicy="no-referrer" />}
|
<PostThumbnail
|
||||||
|
post={post}
|
||||||
|
thumbnailUrl={cacheHits.get(post.id)}
|
||||||
|
onRequestThumbnail={requestThumbnail}
|
||||||
|
/>
|
||||||
<div className="absolute top-2 right-2 flex gap-1.5 z-10 text-black">{post.media.length > 1 && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Layers size={16} /></div>}{post.media.some(m => m.type === 'video') && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Play size={16} fill="white" /></div>}</div>
|
<div className="absolute top-2 right-2 flex gap-1.5 z-10 text-black">{post.media.length > 1 && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Layers size={16} /></div>}{post.media.some(m => m.type === 'video') && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Play size={16} fill="white" /></div>}</div>
|
||||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-6 text-white font-bold z-20 text-black"><div className="flex items-center gap-2 text-black"><Heart fill="white" size={20} className="text-black" /><span>-</span></div><div className="flex items-center gap-2 text-black"><MessageCircle fill="white" size={20} className="text-black" /><span>-</span></div></div>
|
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-6 text-white font-bold z-20 text-black"><div className="flex items-center gap-2 text-black"><Heart fill="white" size={20} className="text-black" /><span>-</span></div><div className="flex items-center gap-2 text-black"><MessageCircle fill="white" size={20} className="text-black" /><span>-</span></div></div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -335,7 +362,21 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AnimatePresence>{selectedPost && <PostModal post={selectedPost} onClose={() => setSelectedPost(null)} onNextPost={onNextPost} onPrevPost={onPrevPost} hasNextPost={postIndex < filteredPosts.length - 1} hasPrevPost={postIndex > 0} profilePic={profilePic} />}</AnimatePresence>
|
<AnimatePresence>
|
||||||
|
{selectedPost && (
|
||||||
|
<PostModal
|
||||||
|
post={selectedPost}
|
||||||
|
nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
|
||||||
|
prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
|
||||||
|
onClose={() => setSelectedPost(null)}
|
||||||
|
onNextPost={onNextPost}
|
||||||
|
onPrevPost={onPrevPost}
|
||||||
|
hasNextPost={postIndex < filteredPosts.length - 1}
|
||||||
|
hasPrevPost={postIndex > 0}
|
||||||
|
profilePic={profilePic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
|
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
|
||||||
|
|
||||||
{!isScanning && (
|
{!isScanning && (
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { MediaRenderer } from './MediaRenderer';
|
|||||||
|
|
||||||
interface PostModalProps {
|
interface PostModalProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
|
nextPost?: Post;
|
||||||
|
prevPost?: Post;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNextPost?: () => void;
|
onNextPost?: () => void;
|
||||||
onPrevPost?: () => void;
|
onPrevPost?: () => void;
|
||||||
@@ -26,7 +28,7 @@ interface PostModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PostModal: React.FC<PostModalProps> = ({
|
export const PostModal: React.FC<PostModalProps> = ({
|
||||||
post, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
|
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [direction, setDirection] = useState(0);
|
const [direction, setDirection] = useState(0);
|
||||||
@@ -34,32 +36,34 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
// Preloading Logic
|
// Preloading Logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const preloadMedia = async (index: number) => {
|
|
||||||
if (index < 0 || index >= post.media.length) return;
|
|
||||||
const media = post.media[index];
|
|
||||||
if (!media.url) return;
|
|
||||||
|
|
||||||
|
const preloadMedia = async (url: string, type: 'image' | 'video') => {
|
||||||
|
if (!url) return;
|
||||||
try {
|
try {
|
||||||
if (media.type === 'image') {
|
if (type === 'image') {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = media.url;
|
img.src = url;
|
||||||
} else {
|
} else {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.src = media.url;
|
video.src = url;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Immediate preload of first two slides
|
// 1. Current post: Immediate preload of first two slides
|
||||||
preloadMedia(0);
|
if (post.media[0]) preloadMedia(post.media[0].url, post.media[0].type);
|
||||||
preloadMedia(1);
|
if (post.media[1]) preloadMedia(post.media[1].url, post.media[1].type);
|
||||||
|
|
||||||
// 2. Delayed preload of the rest to stay out of the way of initial render
|
// 2. Next/Prev posts: Preload their first slides
|
||||||
|
if (nextPost?.media[0]) preloadMedia(nextPost.media[0].url, nextPost.media[0].type);
|
||||||
|
if (prevPost?.media[0]) preloadMedia(prevPost.media[0].url, prevPost.media[0].type);
|
||||||
|
|
||||||
|
// 3. Current post: Delayed preload of the rest
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
for (let i = 2; i < post.media.length; i++) {
|
for (let i = 2; i < post.media.length; i++) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
preloadMedia(i);
|
preloadMedia(post.media[i].url, post.media[i].type);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [post.id, post.media]);
|
}, [post.id, post.media, nextPost?.id, prevPost?.id]);
|
||||||
|
|
||||||
useEffect(() => setCurrentIndex(0), [post.id]);
|
useEffect(() => setCurrentIndex(0), [post.id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
115
src/components/PostThumbnail.tsx
Normal file
115
src/components/PostThumbnail.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Play, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Post } from '../types';
|
||||||
|
|
||||||
|
interface PostThumbnailProps {
|
||||||
|
post: Post;
|
||||||
|
className?: string;
|
||||||
|
thumbnailUrl?: string; // High-res thumbnail from queue
|
||||||
|
onRequestThumbnail: (id: string, url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoThumbnailCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export const PostThumbnail = ({ post, className, thumbnailUrl, onRequestThumbnail }: PostThumbnailProps) => {
|
||||||
|
const [videoThumbnail, setVideoThumbnail] = useState<string | null>(videoThumbnailCache.get(post.media[0].url) || null);
|
||||||
|
const [isInView, setIsInView] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const mainMedia = post.media[0];
|
||||||
|
const isVideo = mainMedia.type === 'video';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsInView(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
}, { rootMargin: '400px' }); // Larger margin for smoother scrolling
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInView) return;
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
if (videoThumbnail) return;
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = `${mainMedia.url}#t=0.1`;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
|
const captureFrame = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx && video.videoWidth > 0) {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
||||||
|
videoThumbnailCache.set(mainMedia.url, dataUrl);
|
||||||
|
setVideoThumbnail(dataUrl);
|
||||||
|
}
|
||||||
|
} catch (err) {} finally { cleanup(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => video.currentTime = 0.1;
|
||||||
|
const handleSeeked = () => captureFrame();
|
||||||
|
const cleanup = () => {
|
||||||
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
video.removeEventListener('seeked', handleSeeked);
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
video.addEventListener('seeked', handleSeeked);
|
||||||
|
video.addEventListener('error', cleanup);
|
||||||
|
const timeout = setTimeout(() => cleanup(), 5000);
|
||||||
|
return () => { clearTimeout(timeout); cleanup(); };
|
||||||
|
} else {
|
||||||
|
// Request high-res image thumbnailing only if size > 1MiB
|
||||||
|
const ONE_MIB = 1024 * 1024;
|
||||||
|
if (mainMedia.size && mainMedia.size > ONE_MIB) {
|
||||||
|
onRequestThumbnail(post.id, mainMedia.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isInView, isVideo, mainMedia.url, mainMedia.size, post.id, onRequestThumbnail, videoThumbnail]);
|
||||||
|
|
||||||
|
// Use high-res thumbnail if available, then video thumb, then original (if small enough), then placeholder
|
||||||
|
const displayUrl = thumbnailUrl || videoThumbnail || (isVideo ? null : post.thumbnail);
|
||||||
|
|
||||||
|
if (!displayUrl && isVideo) {
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("w-full h-full bg-gray-100 flex items-center justify-center text-black", className)}>
|
||||||
|
<Play size={20} className="text-gray-300" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayUrl) {
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("w-full h-full bg-gray-50 flex items-center justify-center text-black", className)}>
|
||||||
|
<ImageIcon size={20} className="text-gray-200" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="w-full h-full">
|
||||||
|
<img
|
||||||
|
src={displayUrl}
|
||||||
|
alt=""
|
||||||
|
className={cn("w-full h-full object-cover transition-all duration-700", className, !thumbnailUrl && !videoThumbnail ? "blur-sm scale-105" : "blur-0 scale-100")}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Play } from 'lucide-react';
|
|
||||||
import { cn } from '../lib/utils';
|
|
||||||
|
|
||||||
const thumbnailCache = new Map<string, string>();
|
|
||||||
|
|
||||||
export const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => {
|
|
||||||
const [thumbnail, setThumbnail] = useState<string | null>(thumbnailCache.get(url) || null);
|
|
||||||
const [isInView, setIsInView] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (thumbnail || !containerRef.current) return;
|
|
||||||
const observer = new IntersectionObserver(([entry]) => {
|
|
||||||
if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); }
|
|
||||||
}, { rootMargin: '200px' });
|
|
||||||
observer.observe(containerRef.current);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [thumbnail]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (thumbnail || !isInView) return;
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.src = `${url}#t=0.1`; video.preload = 'metadata'; video.muted = true; video.playsInline = true;
|
|
||||||
const captureFrame = () => {
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx && video.videoWidth > 0) {
|
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
||||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
|
||||||
thumbnailCache.set(url, dataUrl); setThumbnail(dataUrl);
|
|
||||||
}
|
|
||||||
} catch (err) {} finally { cleanup(); }
|
|
||||||
};
|
|
||||||
const handleLoadedMetadata = () => video.currentTime = 0.1;
|
|
||||||
const handleSeeked = () => captureFrame();
|
|
||||||
const cleanup = () => {
|
|
||||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
video.removeEventListener('seeked', handleSeeked);
|
|
||||||
video.removeAttribute('src');
|
|
||||||
video.load();
|
|
||||||
};
|
|
||||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
||||||
video.addEventListener('seeked', handleSeeked);
|
|
||||||
video.addEventListener('error', cleanup);
|
|
||||||
const timeout = setTimeout(() => { if (!thumbnailCache.has(url)) cleanup(); }, 5000);
|
|
||||||
return () => { clearTimeout(timeout); cleanup(); };
|
|
||||||
}, [url, thumbnail, isInView]);
|
|
||||||
|
|
||||||
if (!thumbnail) return (
|
|
||||||
<div ref={containerRef} className={cn("w-full h-full bg-gray-100 flex items-center justify-center text-black", className)}>
|
|
||||||
<Play size={20} className="text-gray-300" fill="currentColor" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return <img src={thumbnail} alt="" className={cn("w-full h-full object-cover transition-transform duration-500 group-hover:scale-110", className)} referrerPolicy="no-referrer" />;
|
|
||||||
};
|
|
||||||
@@ -195,8 +195,8 @@ export const useArchiveScanner = (
|
|||||||
const type = isVideo(matchedFile.name) ? 'video' : 'image';
|
const type = isVideo(matchedFile.name) ? 'video' : 'image';
|
||||||
const url = matchedFile.url || URL.createObjectURL(new Blob([await matchedFile.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
|
const url = matchedFile.url || URL.createObjectURL(new Blob([await matchedFile.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
|
||||||
const existingMedia = post.media!.find(media => media.index === mIdx + 1);
|
const existingMedia = post.media!.find(media => media.index === mIdx + 1);
|
||||||
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(media => media.index === mIdx + 1 ? { name: matchedFile!.name, url, type, index: mIdx + 1 } : media); }
|
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(media => media.index === mIdx + 1 ? { name: matchedFile!.name, url, type, index: mIdx + 1, size: matchedFile!.size } : media); }
|
||||||
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1 });
|
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1, size: matchedFile.size });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (post.media!.length > 0) postsMap.set(postId, post);
|
if (post.media!.length > 0) postsMap.set(postId, post);
|
||||||
@@ -251,8 +251,8 @@ export const useArchiveScanner = (
|
|||||||
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
|
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
|
||||||
if (type === 'image') throttledSetScanningImage(url);
|
if (type === 'image') throttledSetScanningImage(url);
|
||||||
const existingMedia = post.media!.find(m => m.index === index);
|
const existingMedia = post.media!.find(m => m.index === index);
|
||||||
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(m => m.index === index ? { name: file.name, url, type, index } : m); }
|
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(m => m.index === index ? { name: file.name, url, type, index, size: file.size } : m); }
|
||||||
else post.media!.push({ name: file.name, url, type, index });
|
else post.media!.push({ name: file.name, url, type, index, size: file.size });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
@@ -297,7 +297,7 @@ export const useArchiveScanner = (
|
|||||||
const type = isVideo(file.name) ? 'video' : 'image';
|
const type = isVideo(file.name) ? 'video' : 'image';
|
||||||
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
|
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' }));
|
||||||
if (type === 'image') throttledSetScanningImage(url);
|
if (type === 'image') throttledSetScanningImage(url);
|
||||||
post.media.push({ name: file.name, url, type, index: idx + 1 });
|
post.media.push({ name: file.name, url, type, index: idx + 1, size: file.size });
|
||||||
}
|
}
|
||||||
post.thumbnail = post.media[0].url;
|
post.thumbnail = post.media[0].url;
|
||||||
postsMap.set(postId, post);
|
postsMap.set(postId, post);
|
||||||
|
|||||||
106
src/hooks/useThumbnailQueue.ts
Normal file
106
src/hooks/useThumbnailQueue.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import * as idb from 'idb-keyval';
|
||||||
|
|
||||||
|
interface ThumbnailRequest {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
blob?: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH = 400;
|
||||||
|
|
||||||
|
export const useThumbnailQueue = () => {
|
||||||
|
const [cacheHits, setCacheHits] = useState<Map<string, string>>(new Map());
|
||||||
|
const queueRef = useRef<ThumbnailRequest[]>([]);
|
||||||
|
const isProcessingRef = useRef(false);
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize worker with relative URL (vite will handle this)
|
||||||
|
workerRef.current = new Worker(new URL('../lib/thumbnail-worker.ts', import.meta.url), {
|
||||||
|
type: 'module'
|
||||||
|
});
|
||||||
|
|
||||||
|
workerRef.current.onmessage = async (e) => {
|
||||||
|
const { id, blob, error } = e.data;
|
||||||
|
|
||||||
|
if (!error && blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setCacheHits(prev => new Map(prev).set(id, url));
|
||||||
|
|
||||||
|
// Persist to IndexedDB (as dataURL for simple retrieval or keep as Blob)
|
||||||
|
try {
|
||||||
|
await idb.set(`thumb_${id}`, blob);
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process next in queue
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
processNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
workerRef.current?.terminate();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const processNext = useCallback(async () => {
|
||||||
|
if (isProcessingRef.current || queueRef.current.length === 0 || !workerRef.current) return;
|
||||||
|
|
||||||
|
isProcessingRef.current = true;
|
||||||
|
const request = queueRef.current.shift()!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Double check cache before expensive work
|
||||||
|
const cached = await idb.get(`thumb_${request.id}`);
|
||||||
|
if (cached instanceof Blob) {
|
||||||
|
const url = URL.createObjectURL(cached);
|
||||||
|
setCacheHits(prev => new Map(prev).set(request.id, url));
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
processNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch original if no blob provided
|
||||||
|
let blob = request.blob;
|
||||||
|
if (!blob) {
|
||||||
|
const res = await fetch(request.url);
|
||||||
|
blob = await res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send to worker
|
||||||
|
workerRef.current.postMessage({
|
||||||
|
id: request.id,
|
||||||
|
blob,
|
||||||
|
width: THUMBNAIL_WIDTH
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ThumbnailQueue] Failed to process:', request.id, err);
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
processNext();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestThumbnail = useCallback(async (id: string, url: string, blob?: Blob) => {
|
||||||
|
// 1. Sync check state
|
||||||
|
if (cacheHits.has(id)) return;
|
||||||
|
|
||||||
|
// 2. Async check idb
|
||||||
|
const cached = await idb.get(`thumb_${id}`);
|
||||||
|
if (cached instanceof Blob) {
|
||||||
|
setCacheHits(prev => new Map(prev).set(id, URL.createObjectURL(cached)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add to queue if not already there
|
||||||
|
if (!queueRef.current.some(r => r.id === id)) {
|
||||||
|
queueRef.current.push({ id, url, blob });
|
||||||
|
processNext();
|
||||||
|
}
|
||||||
|
}, [cacheHits, processNext]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheHits,
|
||||||
|
requestThumbnail
|
||||||
|
};
|
||||||
|
};
|
||||||
42
src/lib/thumbnail-worker.ts
Normal file
42
src/lib/thumbnail-worker.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Thumbnail Generation Worker
|
||||||
|
* Uses OffscreenCanvas and createImageBitmap for high-performance,
|
||||||
|
* background-thread image resizing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
self.onmessage = async (e: MessageEvent) => {
|
||||||
|
const { id, blob, width } = e.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create a bitmap from the blob (native browser decoding)
|
||||||
|
// We resize it DURING the decode step for maximum efficiency
|
||||||
|
const bitmap = await createImageBitmap(blob, {
|
||||||
|
resizeWidth: width,
|
||||||
|
resizeQuality: 'medium'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Use OffscreenCanvas to draw the resized bitmap
|
||||||
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Could not get OffscreenCanvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
|
||||||
|
// 3. Convert to a small JPEG blob
|
||||||
|
const thumbnailBlob = await canvas.convertToBlob({
|
||||||
|
type: 'image/jpeg',
|
||||||
|
quality: 0.7
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Release bitmap memory
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
// 5. Send result back
|
||||||
|
self.postMessage({ id, blob: thumbnailBlob });
|
||||||
|
} catch (err: any) {
|
||||||
|
self.postMessage({ id, error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ export interface MediaFile {
|
|||||||
url: string;
|
url: string;
|
||||||
type: 'image' | 'video';
|
type: 'image' | 'video';
|
||||||
index: number;
|
index: number;
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Post {
|
export interface Post {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default defineConfig(({mode}) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
navigateFallbackDenylist: [/^\/api/, /^\/archives/],
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user