aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 10:25:44 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 10:25:44 +0200
commit950f4aa6d107e127fb6dc0579beedbff0e6f1af5 (patch)
tree9891a96220afac04ea06a650aa2f75a951385b94 /packages/meshbay-hub/src/meshbay_hub/static/video-app.js
parentf1ed3e6be452f6211cee8db1afef8b56473a04db (diff)
downloadmeshbay-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/video-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js118
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 };