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 };
|