aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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} />`}
`}
`;
}