diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 10:25:44 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 10:25:44 +0200 |
| commit | 950f4aa6d107e127fb6dc0579beedbff0e6f1af5 (patch) | |
| tree | 9891a96220afac04ea06a650aa2f75a951385b94 /packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js | |
| parent | f1ed3e6be452f6211cee8db1afef8b56473a04db (diff) | |
| download | meshbay-950f4aa6d107e127fb6dc0579beedbff0e6f1af5.tar.gz | |
refactor(client): move the connection pool and the media tiles to modules
ConnectionPool (with connectToGroup and its limits) leaves search-page.js
for connection-pool.js, and LazyTile/MediaThumb leave video-app.js for
media-tiles.js, cut as text. The shell and the Music and Photos apps now
reach them without importing the search page or the Videos app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js | 119 |
1 files changed, 119 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js b/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js new file mode 100644 index 0000000..f56ec9a --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js @@ -0,0 +1,119 @@ +// 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`<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" />`; +} + +const _thumbRetryListeners = new Set(); +function bumpThumbGeneration() { + for (const fn of _thumbRetryListeners) fn(); +} + +export { LazyTile, MediaThumb, bumpThumbGeneration }; |