summaryrefslogtreecommitdiffstats
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
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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js83
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js81
2 files changed, 132 insertions, 32 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index f144ef0..ec92e76 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -8,14 +8,22 @@ import {
HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
} from './hub-client.js';
import { FilesPanel, FilePreview } from './files-app.js';
-import { VideoApp, bumpMediaMetaGeneration, bumpThumbGeneration } from './video-app.js';
-import { MusicApp, bumpMusicMetaGeneration } from './music-app.js';
+import { VideoApp } from './video-app.js';
+import { MusicApp } from './music-app.js';
import { PhotosApp } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
const BATCH_SIZE = 3;
-const MAX_POOL_SIZE = 3;
+// One WebRTC peer connection per group the search view touches. The cap bounds
+// how many a busy account (many groups on the hub) keeps open at once; groups
+// past it connect lazily when a tile of theirs scrolls into view (video-app.js's
+// LazyTile → onNeedConn). Was 3, which meant any account with more than three
+// groups thrashed the pool: an evicted transport left a stale `_tRef` on every
+// tile of that group, and MediaThumb / useMediaMeta gave up on it with no
+// retry — so posters rendered for a moment, then fell back to a spinner for
+// good. grenet (one shared group) never hit it; cbesson (many) always did.
+const MAX_POOL_SIZE = 12;
const DEBOUNCE_MS = 200;
const SEARCH_TIMEOUT = 10000;
const SEARCH_VIDEO_ROOT = '__search__';
@@ -25,10 +33,14 @@ const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
// -- Connection pool ----------------------------------------------------------
class ConnectionPool {
- constructor(hubBase) {
+ constructor(hubBase, onEvict) {
this._hubBase = hubBase;
this._connections = new Map();
this._connecting = new Map();
+ // Called with a groupId whenever this pool closes that group's connection
+ // (eviction or closeAll). SearchPage uses it to drop its own record so it
+ // never hands a tile a `_tRef` pointing at a transport just closed here.
+ this._onEvict = onEvict || (() => {});
}
async connect(groupId, token, bundleKey, username, userId) {
@@ -94,12 +106,14 @@ class ConnectionPool {
const conn = this._connections.get(oldestId);
try { conn.transport.close(); } catch {}
this._connections.delete(oldestId);
+ this._onEvict(oldestId);
}
}
closeAll() {
- for (const [, conn] of this._connections) {
+ for (const [id, conn] of this._connections) {
try { conn.transport.close(); } catch {}
+ this._onEvict(id);
}
this._connections.clear();
for (const [, p] of this._connecting) {
@@ -202,13 +216,28 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const modalTransportRef = useRef(null);
const modalGekRef = useRef(null);
const groupConns = useRef(new Map());
+ // groupId -> how many times its connection has been (re)established. Threaded
+ // into each entry as `_connGen` and used by video-app.js's tiles as a refetch
+ // key, so a group's posters/thumbnails recover the moment it reconnects
+ // (after a pool eviction) instead of staying stuck on a spinner.
+ const connGenRef = useRef(new Map());
+ const mountedRef = useRef(true);
const [connectionGen, setConnectionGen] = useState(0);
const debounceRef = useRef(null);
const [debouncedQuery, setDebouncedQuery] = useState('');
useEffect(() => {
- poolRef.current = new ConnectionPool(HUB);
- return () => { if (poolRef.current) poolRef.current.closeAll(); };
+ mountedRef.current = true;
+ poolRef.current = new ConnectionPool(HUB, (evictedId) => {
+ groupConns.current.delete(evictedId);
+ // Rebuild the entry lists so tiles of the evicted group fall back to a
+ // null `_tRef` (and pick a live one up again once reconnected).
+ if (mountedRef.current) setConnectionGen((g) => g + 1);
+ });
+ return () => {
+ mountedRef.current = false;
+ if (poolRef.current) poolRef.current.closeAll();
+ };
}, []);
useEffect(() => {
@@ -247,29 +276,50 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
if (!poolRef.current) throw new Error('no pool');
const bundleKey = session.bundleKey || await _loadBundleKey();
const conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId);
+
+ // A concurrent caller for the same group (several tiles mounting at once)
+ // may already have recorded this exact transport while we awaited. Only
+ // treat it as new — bump the per-group generation, force a rebuild — when
+ // it genuinely is, so a group's tiles refetch once per (re)connect rather
+ // than once per mounting tile.
+ const existing = groupConns.current.get(groupId);
+ if (existing && existing.transport === conn.transport) {
+ existing.lastUsed = Date.now();
+ return existing;
+ }
+
+ const gen = (connGenRef.current.get(groupId) || 0) + 1;
+ connGenRef.current.set(groupId, gen);
const entry = {
transport: conn.transport,
gek: conn.gek,
+ gen,
tRef: { current: conn.transport },
gRef: { current: conn.gek },
};
groupConns.current.set(groupId, entry);
+ // No bumpMediaMetaGeneration() / bumpThumbGeneration() here: a fresh
+ // transport does not invalidate metadata another group already resolved,
+ // and firing the module-wide reset on every connect is what made the whole
+ // grid flicker through the pre-connect walk. Recovery for *this* group's
+ // tiles comes from `_connGen` (threaded into its entries) instead; the
+ // module-wide bumps stay for an operator's TMDB override/rematch only.
setConnectionGen((g) => g + 1);
- bumpMediaMetaGeneration();
- bumpThumbGeneration();
- bumpMusicMetaGeneration();
return entry;
}, [token, username, userId]);
- // Pre-connect to all groups as soon as indexing finishes so thumbnails
- // start loading before the user switches views. The pool evicts old
- // connections but _thumbBlobCache keeps fetched thumbnails across evictions.
+ // Warm up connections as soon as indexing finishes so thumbnails start
+ // loading before the user switches views — but only up to the pool's
+ // capacity. Warming every group would just evict the earlier ones before
+ // the user ever gets there; groups past the cap connect lazily when a tile
+ // of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps
+ // fetched thumbnails across evictions.
useEffect(() => {
if (fetching || indexedGroups.size === 0) return;
let cancelled = false;
(async () => {
- const groupIds = [...indexedGroups.keys()];
+ const groupIds = [...indexedGroups.keys()].slice(0, MAX_POOL_SIZE);
for (let i = 0; i < groupIds.length; i += BATCH_SIZE) {
if (cancelled) break;
const batch = groupIds.slice(i, i + BATCH_SIZE);
@@ -325,6 +375,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -356,6 +407,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -381,6 +433,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -407,6 +460,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
groupOwner: data.groupOwner,
_tRef: conn ? conn.tRef : null,
_gRef: conn ? conn.gRef : null,
+ _connGen: conn ? conn.gen : 0,
});
}
}
@@ -570,6 +624,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
videoRoot=${SEARCH_VIDEO_ROOT}
tmdbConfig=${{ enabled: true }}
isNodeAdmin=${false}
+ onNeedConn=${connectGroup}
hideFilter=${true} />
`}
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} />`}
`}
`;
}