aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js
blob: f56ec9aed6e1b5462c5498f1863d39bdec7cfb22 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
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 };