diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/search-page.js | 83 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 81 |
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} />`} `} `; } |