diff --git a/package.json b/package.json index f3354d2..7c5ad2a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "react-example", + "name": "instaarchive-viewer", "private": true, "version": "1.2.0", "type": "module", diff --git a/src/App.tsx b/src/App.tsx index bea1a31..c7f8c30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,8 +23,9 @@ import { Post, ServerArchive } from './types'; import { ArchiveDashboard } from './components/ArchiveDashboard'; import { StoryViewer } from './components/StoryViewer'; import { PostModal } from './components/PostModal'; -import { VideoThumbnail } from './components/VideoThumbnail'; +import { PostThumbnail } from './components/PostThumbnail'; import { useArchiveScanner } from './hooks/useArchiveScanner'; +import { useThumbnailQueue } from './hooks/useThumbnailQueue'; export default function App() { const [showStoryViewer, setShowStoryViewer] = useState(false); @@ -43,6 +44,8 @@ export default function App() { const fileInputRef = useRef(null); const profilePicInputRef = useRef(null); + const { cacheHits, requestThumbnail } = useThumbnailQueue(); + const refreshCachedArchives = useCallback(async () => { try { const keys = await idb.keys(); @@ -82,6 +85,8 @@ export default function App() { resetScannerState } = useArchiveScanner('', currentArchive, refreshCachedArchives); + const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState(null); + const { username, fullName, @@ -285,13 +290,31 @@ export default function App() { ) ) : isScanning ? ( -
- {currentScanningImage && ()} -
+
+ {currentScanningImage && ( + setLastLoadedScanningImage(currentScanningImage)} + /> + )} +
+ + + +
+
Scanning Archive...

{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}

Phase: {scanningPhase}{scannedCount} / {totalFiles}
-
System Parser Feed
Live Output
{scannedFilesLog.map((log, idx) => (
[{new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}]{scanningPhase === 'Indexing' ? 'IDX' : 'PARSE'}{log}
))}{scannedFilesLog.length === 0 &&
Initializing scanner context...
}
+
System Parser Feed
Live Output
{scannedFilesLog.map((log, idx) => (
[{new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}]{scanningPhase === 'Indexing' ? 'IDX' : 'PARSE'}{log}
))}{scannedFilesLog.length === 0 &&
Initializing scanner context...
}
) : ( @@ -324,7 +347,11 @@ export default function App() { {activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (
Blank
))} {visiblePosts.map((post) => ( 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' ? : } +
{post.media.length > 1 &&
}{post.media.some(m => m.type === 'video') &&
}
-
-
@@ -335,7 +362,21 @@ export default function App() { )} - {selectedPost && setSelectedPost(null)} onNextPost={onNextPost} onPrevPost={onPrevPost} hasNextPost={postIndex < filteredPosts.length - 1} hasPrevPost={postIndex > 0} profilePic={profilePic} />} + + {selectedPost && ( + 0 ? filteredPosts[postIndex - 1] : undefined} + onClose={() => setSelectedPost(null)} + onNextPost={onNextPost} + onPrevPost={onPrevPost} + hasNextPost={postIndex < filteredPosts.length - 1} + hasPrevPost={postIndex > 0} + profilePic={profilePic} + /> + )} + {showStoryViewer && allStories.length > 0 && setShowStoryViewer(false)} profilePic={profilePic} />} {!isScanning && ( diff --git a/src/components/PostModal.tsx b/src/components/PostModal.tsx index a8094d2..c25d794 100644 --- a/src/components/PostModal.tsx +++ b/src/components/PostModal.tsx @@ -17,6 +17,8 @@ import { MediaRenderer } from './MediaRenderer'; interface PostModalProps { post: Post; + nextPost?: Post; + prevPost?: Post; onClose: () => void; onNextPost?: () => void; onPrevPost?: () => void; @@ -26,7 +28,7 @@ interface PostModalProps { } export const PostModal: React.FC = ({ - post, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic + post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic }) => { const [currentIndex, setCurrentIndex] = useState(0); const [direction, setDirection] = useState(0); @@ -34,32 +36,34 @@ export const PostModal: React.FC = ({ // Preloading Logic useEffect(() => { 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 { - if (media.type === 'image') { + if (type === 'image') { const img = new Image(); - img.src = media.url; + img.src = url; } else { const video = document.createElement('video'); - video.src = media.url; + video.src = url; video.preload = 'auto'; } } catch (e) {} }; - // 1. Immediate preload of first two slides - preloadMedia(0); - preloadMedia(1); + // 1. Current post: Immediate preload of first two slides + if (post.media[0]) preloadMedia(post.media[0].url, post.media[0].type); + 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(() => { for (let i = 2; i < post.media.length; i++) { if (controller.signal.aborted) break; - preloadMedia(i); + preloadMedia(post.media[i].url, post.media[i].type); } }, 1000); @@ -67,7 +71,7 @@ export const PostModal: React.FC = ({ controller.abort(); clearTimeout(timeout); }; - }, [post.id, post.media]); + }, [post.id, post.media, nextPost?.id, prevPost?.id]); useEffect(() => setCurrentIndex(0), [post.id]); useEffect(() => { diff --git a/src/components/PostThumbnail.tsx b/src/components/PostThumbnail.tsx new file mode 100644 index 0000000..fcdd7cc --- /dev/null +++ b/src/components/PostThumbnail.tsx @@ -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(); + +export const PostThumbnail = ({ post, className, thumbnailUrl, onRequestThumbnail }: PostThumbnailProps) => { + const [videoThumbnail, setVideoThumbnail] = useState(videoThumbnailCache.get(post.media[0].url) || null); + const [isInView, setIsInView] = useState(false); + const containerRef = useRef(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 ( +
+ +
+ ); + } + + if (!displayUrl) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/src/components/VideoThumbnail.tsx b/src/components/VideoThumbnail.tsx deleted file mode 100644 index cc660b8..0000000 --- a/src/components/VideoThumbnail.tsx +++ /dev/null @@ -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(); - -export const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => { - const [thumbnail, setThumbnail] = useState(thumbnailCache.get(url) || null); - const [isInView, setIsInView] = useState(false); - const containerRef = useRef(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 ( -
- -
- ); - - return ; -}; diff --git a/src/hooks/useArchiveScanner.ts b/src/hooks/useArchiveScanner.ts index eb78984..6301c41 100644 --- a/src/hooks/useArchiveScanner.ts +++ b/src/hooks/useArchiveScanner.ts @@ -195,8 +195,8 @@ export const useArchiveScanner = ( 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 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); } - else post.media!.push({ name: matchedFile.name, url, type, 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, size: matchedFile!.size } : media); } + else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1, size: matchedFile.size }); } } 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' })); if (type === 'image') throttledSetScanningImage(url); 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); } - else post.media!.push({ name: file.name, url, type, index }); + 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, size: file.size }); } } await new Promise(resolve => setTimeout(resolve, 0)); @@ -297,7 +297,7 @@ export const useArchiveScanner = ( 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' })); 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; postsMap.set(postId, post); diff --git a/src/hooks/useThumbnailQueue.ts b/src/hooks/useThumbnailQueue.ts new file mode 100644 index 0000000..d3ef0ac --- /dev/null +++ b/src/hooks/useThumbnailQueue.ts @@ -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>(new Map()); + const queueRef = useRef([]); + const isProcessingRef = useRef(false); + const workerRef = useRef(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 + }; +}; diff --git a/src/lib/thumbnail-worker.ts b/src/lib/thumbnail-worker.ts new file mode 100644 index 0000000..178ce46 --- /dev/null +++ b/src/lib/thumbnail-worker.ts @@ -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 }); + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index 76642bb..067292a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export interface MediaFile { url: string; type: 'image' | 'video'; index: number; + size?: number; } export interface Post { diff --git a/vite.config.ts b/vite.config.ts index c7299cc..60b7f04 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig(({mode}) => { ] }, workbox: { + navigateFallbackDenylist: [/^\/api/, /^\/archives/], globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], runtimeCaching: [ {