// Tiles the media apps share: a slot that mounts its content only once it // scrolls near (virtualization), and a thumbnail decrypted through the chunk // path. Videos, Music and Photos all use them; a module of their own so that // opening one app does not load another. import { html, useState, useEffect, useRef } from './vendor/htm-preact.js'; import { Icon } from './icon.js'; import { pipelinedDownload } from './file-utils.js'; // ── lazy-mount tile (virtualization) ─────────────────────────────────────── const LAZY_TILE_MARGIN = 300; function LazyTile({ cls = 'video-tile-slot', children }) { const ref = useRef(null); const [visible, setVisible] = useState(false); useEffect(() => { if (visible || !ref.current) return; // A tile that is already on screen (or within the margin) the moment // it mounts — the overwhelmingly common case, since a merge (§V6) or a // tab revisit mounts tiles into a grid that was already scrolled to // wherever the operator was looking — doesn't need to wait for // IntersectionObserver's own first callback at all: that first // delivery is only a *microtask/next-paint* guarantee, not an // immediate one, and was observed live taking upwards of 30 seconds // (matching the browser's own periodic intersection-computation // cadence exactly) — which read as "the poster never finishes // loading" even though every fetch behind it had already completed. // Checked synchronously so a genuinely below-the-fold tile still only // mounts once actually scrolled near. const rect = ref.current.getBoundingClientRect(); const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN; if (alreadyNear) { setVisible(true); return; } const obs = new IntersectionObserver((obsEntries) => { if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); } }, { rootMargin: `${LAZY_TILE_MARGIN}px` }); obs.observe(ref.current); return () => obs.disconnect(); }, [visible]); return html`
${visible ? children : null}
`; } // ── thumbnail/poster image, decrypted via the chunk path ──────────────────── // // Cached per session by thumb_hash (a content hash, so it never goes stale): // the same poster reused across a season's worth of episode tiles is // decrypted once, not once per tile. Blob URLs are not revoked — the number // of distinct thumbnails one session ever visits is bounded by the library // size, and reference-counting revocation across many tile mounts/unmounts // would cost real complexity for a benefit that only matters in a very long // session. const _thumbBlobCache = new Map(); function MediaThumb({ thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, emptyIcon = 'video', reloadKey, }) { const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null); const [retryToken, setRetryToken] = useState(0); useEffect(() => { const listener = () => setRetryToken((n) => n + 1); _thumbRetryListeners.add(listener); return () => _thumbRetryListeners.delete(listener); }, []); // Re-checks the cache by the CURRENT thumbHash on every change rather than // trusting the `blobUrl` state variable — a PosterCard swaps this same // component instance's thumbHash prop from the raw fallback frame to the // TMDB poster once metadata resolves, and gating on "is blobUrl already // set" (from the *previous* hash) would leave the fallback frame on // screen forever instead of ever fetching the poster. // // `onReady` fires exactly once per settled thumbHash — cache hit, fetch // success, fetch failure, or no hash at all — so a caller that hides this // component until its image has actually arrived (PosterCard) always // gets unstuck, even when there's nothing to show. useEffect(() => { const cached = _thumbBlobCache.get(thumbHash); if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; } setBlobUrl(null); if (!thumbHash) { if (onReady) onReady(null); return; } let cancelled = false; (async () => { const transport = transportRef.current; if (!transport || !transport.connected) { if (onReady) onReady(null); return; } try { const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1); if (cancelled) return; const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' })); _thumbBlobCache.set(thumbHash, url); setBlobUrl(url); if (onReady) onReady(url); } catch { /* leave the placeholder — a transient fetch failure isn't an error state */ if (!cancelled && onReady) onReady(null); } })(); return () => { cancelled = true; }; // `reloadKey` — bumped by the caller when the transport behind `transportRef` // was replaced (a search-page pool reconnect). Without it, a thumb whose // first fetch hit a not-yet-connected transport and bailed above would stay // an empty placeholder for good, since neither `thumbHash` nor `retryToken` // changes when only the connection does. }, [thumbHash, retryToken, reloadKey]); if (!blobUrl) return html`
<${Icon} name=${emptyIcon} />
`; return html`${alt`; } const _thumbRetryListeners = new Set(); function bumpThumbGeneration() { for (const fn of _thumbRetryListeners) fn(); } export { LazyTile, MediaThumb, bumpThumbGeneration };