aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js
diff options
context:
space:
mode:
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.js119
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 };