diff --git a/package-lock.json b/package-lock.json index 778558f..f96bb2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instaarchive-viewer", - "version": "1.5.1", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instaarchive-viewer", - "version": "1.5.1", + "version": "1.6.0", "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", diff --git a/package.json b/package.json index 2b704b4..9f26bda 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "instaarchive-viewer", "private": true, - "version": "1.5.1", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", diff --git a/src/App.tsx b/src/App.tsx index 6896e2a..23cdd05 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ import { import { motion, AnimatePresence } from 'motion/react'; import { cn } from './lib/utils'; -import { PRESS } from './lib/motion'; +import { PRESS, prefersReducedMotion } from './lib/motion'; import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { @@ -111,6 +111,30 @@ export default function App() { const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState(null); + /** + * Blurred backdrops behind the scanning UI, newest last. + * + * Each new image is stacked *over* the previous one and fades in; the one + * underneath stays fully opaque until it's covered. Cross-fading by swapping + * a single element left the pale backdrop showing through mid-transition, + * which read as a white flash between every image. + */ + const [scanBackdrops, setScanBackdrops] = useState([]); + + useEffect(() => { + if (!lastLoadedScanningImage) return; + setScanBackdrops(prev => + prev[prev.length - 1] === lastLoadedScanningImage + ? prev + : [...prev, lastLoadedScanningImage].slice(-3), + ); + }, [lastLoadedScanningImage]); + + // Don't carry one archive's backdrops into the next scan. + useEffect(() => { + if (!isScanning) { setScanBackdrops([]); setLastLoadedScanningImage(null); } + }, [isScanning]); + const { username, fullName, @@ -460,17 +484,28 @@ export default function App() { onLoad={() => setLastLoadedScanningImage(currentScanningImage)} /> )} -
- - + {scanBackdrops.map(src => ( + setScanBackdrops(prev => { + // Once this layer is opaque it hides everything below it. + const i = prev.indexOf(src); + return i > 0 ? prev.slice(i) : prev; + })} className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110" /> - + ))}
diff --git a/src/components/PostModal.tsx b/src/components/PostModal.tsx index ff77ab5..9151976 100644 --- a/src/components/PostModal.tsx +++ b/src/components/PostModal.tsx @@ -82,11 +82,14 @@ export const PostModal: React.FC = ({ useEffect(() => setCurrentIndex(0), [post.id]); useEffect(() => { + // Arrows page within the carousel — the thing the arrows visually point at. + // Moving between posts stays on the side buttons, with , and . as keyboard + // equivalents. const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') goToPost(1, 'x'); - else if (e.key === 'ArrowLeft') goToPost(-1, 'x'); - else if (e.key === '.') paginate(1); - else if (e.key === ',') paginate(-1); + if (e.key === 'ArrowRight') paginate(1); + else if (e.key === 'ArrowLeft') paginate(-1); + else if (e.key === '.') goToPost(1, 'x'); + else if (e.key === ',') goToPost(-1, 'x'); else if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); diff --git a/src/hooks/useArchiveScanner.ts b/src/hooks/useArchiveScanner.ts index f2faab8..8f61b79 100644 --- a/src/hooks/useArchiveScanner.ts +++ b/src/hooks/useArchiveScanner.ts @@ -107,11 +107,23 @@ export const useArchiveScanner = ( /** Stable identity for a media file, used to rehydrate URLs after a reload. */ const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name; + /** + * Decompress an `.xz` metadata sidecar. + * + * Read fully into memory first rather than handing the live HTTP body to + * the decompressor. These sidecars are a few KB, so buffering costs + * nothing, and streaming was actively harmful: the decompressor stops + * reading at the end of the xz member, leaving the response body neither + * drained nor cancelled. Across a couple of hundred sidecars that exhausts + * the connection pool and every later fetch fails with "Failed to fetch" — + * which silently cost Instaloader archives their captions, story flags and + * profile metadata, since all of it lives in these files. + */ const parseXZFile = async (file: ArchiveFile) => { try { - const stream = new XzReadableStream(file.stream()); - const response = new Response(stream); - return await response.json(); + const compressed = await file.arrayBuffer(); + const stream = new XzReadableStream(new Blob([compressed]).stream()); + return await new Response(stream).json(); } catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; } }; diff --git a/src/lib/archive-files.ts b/src/lib/archive-files.ts index 699236e..65614f6 100644 --- a/src/lib/archive-files.ts +++ b/src/lib/archive-files.ts @@ -14,7 +14,6 @@ export class LocalArchiveFile implements ArchiveFile { get size() { return this.file.size; } text() { return this.file.text(); } arrayBuffer() { return this.file.arrayBuffer(); } - stream() { return this.file.stream(); } /** * A blob: URL backed directly by the on-disk File. @@ -55,15 +54,6 @@ export class RemoteArchiveFile implements ArchiveFile { const res = await fetch(this.url); return res.arrayBuffer(); } - stream() { - const transform = new TransformStream(); - fetch(this.url).then(res => { - if (res.body) res.body.pipeTo(transform.writable); - else transform.writable.getWriter().close(); - }); - return transform.readable; - } - createObjectUrl() { return this.url; } diff --git a/src/types/index.ts b/src/types/index.ts index 269accb..b49211e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,7 +50,6 @@ export interface ArchiveFile { size: number; text(): Promise; arrayBuffer(): Promise; - stream(): ReadableStream; url?: string; /** * A URL pointing at this file's contents. Local files mint a disk-backed