diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 118 |
1 files changed, 5 insertions, 113 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 5f7e9ea..124033e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -3,9 +3,10 @@ import { } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; -import { formatSize, pipelinedDownload } from './file-utils.js'; +import { formatSize } from './file-utils.js'; import { SourceTag } from './group-name.js'; import { usePager, Pager, pageSizeFrom } from './pager.js'; +import { LazyTile, MediaThumb } from './media-tiles.js'; // ── Videos ─────────────────────────────────────────────────────────────────── // @@ -17,9 +18,9 @@ import { usePager, Pager, pageSizeFrom } from './pager.js'; // deep a show folder sits, which display_title already settled once. // // TMDB metadata is fetched lazily, only for a tile once it is actually -// visible (LazyTile below) — the virtualization requirement for a grid of +// visible (LazyTile, media-tiles.js) — the virtualization requirement for a grid of // many tiles. Thumbnails go through the same `file_req`/chunk path -// as a real file (docs/MESHBAY_DESIGN.md §6.5) via MediaThumb, reusing +// as a real file (docs/MESHBAY_DESIGN.md §6.5) via MediaThumb (media-tiles.js), reusing // chat-app.js's ChatImage pattern. const VIEW_MODE_KEY = 'meshbay_video_view_mode'; @@ -121,110 +122,6 @@ function groupVideoEntries(entries, videoDirectories) { return { movies, shows }; } -// ── 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`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`; -} - -// ── 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`<div class="${cls} video-thumb-empty"><${Icon} name=${emptyIcon} /></div>`; - return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`; -} - // ── TMDB metadata, fetched once per visible tile ──────────────────────────── // An operator correcting a wrong match (TmdbSearchOverlay below) changes @@ -239,11 +136,6 @@ function bumpMediaMetaGeneration() { for (const fn of _mediaMetaListeners) fn(); } -const _thumbRetryListeners = new Set(); -function bumpThumbGeneration() { - for (const fn of _thumbRetryListeners) fn(); -} - // `enrichSig` — a value that changes when the entry's index-time // enrichment lands (display_title fills in). The node answers confidence 0 // for a not-yet-enriched video (it can't tell it apart from a movie and @@ -1189,4 +1081,4 @@ function VideoApp({ // blob" and "mount only once actually scrolled near" mechanisms apply to a // track's cover art unchanged, so Music imports them here rather than // re-implementing (docs/MESHBAY_DESIGN.md §9.4's checklist). -export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration }; +export { VideoApp, groupVideoEntries, bumpMediaMetaGeneration }; |