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-01 14:48:13 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 14:48:13 +0200
commitb83803a91753b38435eb4d3be2e683daae9b4de9 (patch)
tree7581649be4bf99f2998330fd33ac52e234a83af4 /packages/meshbay-hub/src/meshbay_hub/static/video-app.js
parentcfc91e0a424163869c64d30e55d55a53f18a3dbf (diff)
downloadmeshbay-b83803a91753b38435eb4d3be2e683daae9b4de9.tar.gz
fix(hub): stop Search-view video posters flickering to spinners
The cross-group "Search files" view caps its WebRTC connection pool at MAX_POOL_SIZE but pre-connected every indexed group, so any account with more groups than the cap thrashed the pool. An evicted transport was never removed from SearchPage's own groupConns map, so every tile of that group kept a `_tRef` pointing at a closed transport; MediaThumb and useMediaMeta bail on a disconnected transport with no retry, so posters rendered for a moment then fell back to a loading spinner for good. Every connect also fired the module-wide bumpMediaMetaGeneration(), clearing every mounted tile's metadata across all groups and flickering the whole grid through the warm-up walk. A one-group account (grenet) never hit it; a many-group account (cbesson) always did. - ConnectionPool takes an onEvict callback; SearchPage prunes groupConns and rebuilds the entry lists when a connection is evicted or closed. - MAX_POOL_SIZE 3 -> 12; the pre-connect walk is capped to it. Groups past the cap connect lazily when a tile scrolls into view (onNeedConn). - connectGroup() no longer fires the module-wide meta/thumb generation bumps on a normal connect (kept for operator TMDB override/rematch). Per-group gen counter + concurrent-caller guard: a group's tiles refetch once per (re)connect, not once per mounting tile. - MediaThumb/useMediaMeta take an optional reloadKey; each group's `_connGen` is threaded through so a tile refetches when its group reconnects instead of staying stuck on a spinner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4Yj8EuXkjjYxbeS5kPd3U
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.js81
1 files changed, 63 insertions, 18 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 c0102e5..143cf4a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -145,6 +145,7 @@ 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);
@@ -188,7 +189,12 @@ function MediaThumb({
}
})();
return () => { cancelled = true; };
- }, [thumbHash, retryToken]);
+ // `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" />`;
@@ -219,7 +225,13 @@ function bumpThumbGeneration() {
// would otherwise storm TMDB with its raw filename), so the client must
// refetch once the enriched fields arrive on an index delta — the fileId
// (a content hash) never changes, so nothing else would trigger it.
-function useMediaMeta(transportRef, fileId, active, enrichSig) {
+// `reloadKey` — an optional value the caller bumps when the transport behind
+// `transportRef` was swapped out (search-page's connection pool evicted and
+// later rebuilt this group's connection). The first fetch after a mount onto a
+// not-yet-connected transport returns early below; nothing else here would ever
+// re-run it, so the tile would sit on a spinner for good. Threading the group's
+// `_connGen` in as `reloadKey` is what unsticks it.
+function useMediaMeta(transportRef, fileId, active, enrichSig, reloadKey) {
const [meta, setMeta] = useState(null);
const [refetchToken, setRefetchToken] = useState(0);
@@ -244,7 +256,7 @@ function useMediaMeta(transportRef, fileId, active, enrichSig) {
} catch { if (!cancelled) setMeta({ confidence: 0 }); }
})();
return () => { cancelled = true; };
- }, [fileId, active, refetchToken, enrichSig]);
+ }, [fileId, active, refetchToken, enrichSig, reloadKey]);
return meta;
}
@@ -283,10 +295,24 @@ function useSeasonMeta(transportRef, tmdbId, season, active) {
// ── Mode A: poster grid ──────────────────────────────────────────────────────
-function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
+function PosterCard({
+ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved, onNeedConn,
+}) {
const tRef = repEntry._tRef || transportRef;
const gRef = repEntry._gRef || gekRef;
- const meta = useMediaMeta(tRef, repEntry.id, true, repEntry.display_title || '');
+
+ // Search view only: ask for this group's connection the moment the tile
+ // actually mounts (it is inside a LazyTile, so that means "scrolled near").
+ // Groups the pre-connect walk did not reach light up here instead of never.
+ // A no-op in a single-group page — there is no groupId and no onNeedConn.
+ useEffect(() => {
+ if (onNeedConn && repEntry.groupId) {
+ Promise.resolve(onNeedConn(repEntry.groupId)).catch(() => {});
+ }
+ }, [onNeedConn, repEntry.groupId]);
+
+ const meta = useMediaMeta(
+ tRef, repEntry.id, true, repEntry.display_title || '', repEntry._connGen);
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const metaReady = meta !== null;
@@ -339,6 +365,7 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g
${metaReady && html`
<${MediaThumb} thumbHash=${posterHash} alt=${title}
cls="video-poster" transportRef=${tRef} gekRef=${gRef}
+ reloadKey=${repEntry._connGen}
onReady=${handleImageReady} />
`}
</div>
@@ -588,7 +615,8 @@ function VideoDetailModal({
<button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
<${LazyTile} cls="video-episode-thumb-slot">
<${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
- cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef} />
+ cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef}
+ reloadKey=${ep._connGen} />
</${LazyTile}>
<span class="video-episode-label">
S${ep.season}E${String(ep.episode).padStart(2, '0')}
@@ -615,7 +643,9 @@ function VideoDetailModal({
`;
}
-function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin }) {
+function PosterGrid({
+ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin, onNeedConn,
+}) {
const [detail, setDetail] = useState(null); // { title, repEntry, show? }
// raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
const [metaByGroup, setMetaByGroup] = useState({});
@@ -673,7 +703,8 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
const detailGRef = detail && detail.repEntry._gRef ? detail.repEntry._gRef : gekRef;
const detailMeta = useMediaMeta(
detailTRef, detail ? detail.repEntry.id : null, !!detail,
- detail ? (detail.repEntry.display_title || '') : '');
+ detail ? (detail.repEntry.display_title || '') : '',
+ detail ? detail.repEntry._connGen : 0);
return html`
<div class="video-grid">
@@ -683,6 +714,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
subtitle=${formatDuration(e.duration)} repEntry=${e}
groupKey=${`movie:${e.id}`}
transportRef=${transportRef} gekRef=${gekRef}
+ onNeedConn=${onNeedConn}
onOpen=${() => (tmdbEnabled
// With TMDB off there is nothing the detail modal would show
// for a movie (no overview, no season list to pick from,
@@ -716,6 +748,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
repEntry=${repEntry}
groupKey=${s.title}
onMetaResolved=${handleMetaResolved}
+ onNeedConn=${onNeedConn}
transportRef=${transportRef} gekRef=${gekRef}
onOpen=${() => openDetail(s.title, repEntry, s)} />
</${LazyTile}>
@@ -740,9 +773,16 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
// is actually inside a season group (`seasonContext` set); a real,
// distinct per-episode title (a show that *does* carry one) still wins
// over the generic "Episode N" label.
-function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext }) {
+function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext, onNeedConn }) {
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
+
+ useEffect(() => {
+ if (onNeedConn && entry.groupId) {
+ Promise.resolve(onNeedConn(entry.groupId)).catch(() => {});
+ }
+ }, [onNeedConn, entry.groupId]);
+
const isEpisode = seasonContext && entry.season != null && entry.episode != null;
const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext;
const label = isEpisode
@@ -754,7 +794,8 @@ function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext })
<div class="video-flat-row" onClick=${() => onPreview(entry)}>
<${LazyTile} cls="video-flat-thumb-slot">
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name}
- cls="video-flat-thumb" transportRef=${tRef} gekRef=${gRef} />
+ cls="video-flat-thumb" transportRef=${tRef} gekRef=${gRef}
+ reloadKey=${entry._connGen} />
</${LazyTile}>
<div class="video-flat-info">
<div class="video-flat-title">${label}</div>
@@ -771,7 +812,7 @@ function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext })
`;
}
-function FlatShowFolder({ show, transportRef, gekRef, onPreview }) {
+function FlatShowFolder({ show, transportRef, gekRef, onPreview, onNeedConn }) {
const [open, setOpen] = useState(false);
return html`
<div class="video-flat-folder">
@@ -790,7 +831,8 @@ function FlatShowFolder({ show, transportRef, gekRef, onPreview }) {
</div>
${s.episodes.map((ep) => html`
<${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title}
- transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ onNeedConn=${onNeedConn} />
`)}
</div>
`)}
@@ -798,7 +840,7 @@ function FlatShowFolder({ show, transportRef, gekRef, onPreview }) {
`;
}
-function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
+function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }) {
const items = [
...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })),
...shows.map((s) => ({ key: s.title, kind: 'show', show: s })),
@@ -808,9 +850,11 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
<div class="video-flat-list">
${items.map((it) => it.kind === 'movie'
? html`<${FlatMovieRow} key=${it.entry.id} entry=${it.entry}
- transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ onNeedConn=${onNeedConn} />`
: html`<${FlatShowFolder} key=${it.show.title} show=${it.show}
- transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`)}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ onNeedConn=${onNeedConn} />`)}
</div>
`;
}
@@ -819,7 +863,7 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
function VideoApp({
groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
- hideFilter,
+ hideFilter, onNeedConn,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
@@ -889,9 +933,10 @@ function VideoApp({
${mode === 'poster'
? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
- tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} />`
+ tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} onNeedConn=${onNeedConn} />`
: html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
- transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ onNeedConn=${onNeedConn} />`}
`}
`;
}