diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:48:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 14:48:13 +0200 |
| commit | b83803a91753b38435eb4d3be2e683daae9b4de9 (patch) | |
| tree | 7581649be4bf99f2998330fd33ac52e234a83af4 /packages/meshbay-hub/src/meshbay_hub/static/video-app.js | |
| parent | cfc91e0a424163869c64d30e55d55a53f18a3dbf (diff) | |
| download | meshbay-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.js | 81 |
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} />`} `} `; } |