summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 15:35:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 15:35:58 +0200
commiteba2e6b14484c124f3c87ff95cd7ee640833e5d3 (patch)
treeba109b719c879df3ea40090e4fd6ff80b403a3a5 /packages/meshbay-hub/src/meshbay_hub/static/video-app.js
parente6917349c47cc4b7e04e1ecfab27c5291a5fe522 (diff)
downloadmeshbay-eba2e6b14484c124f3c87ff95cd7ee640833e5d3.tar.gz
feat(hub): cross-group search with reuse of existing views
Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.js46
1 files changed, 36 insertions, 10 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 dd120da..df930fd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -147,6 +147,13 @@ function MediaThumb({
thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, emptyIcon = 'video',
}) {
const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
+ const [retryToken, setRetryToken] = useState(0);
+
+ useEffect(() => {
+ const listener = () => setRetryToken((n) => n + 1);
+ _thumbRetryListeners.add(listener);
+ return () => _thumbRetryListeners.delete(listener);
+ }, []);
// Re-checks the cache by the CURRENT thumbHash on every change rather than
// trusting the `blobUrl` state variable — a PosterCard swaps this same
@@ -181,7 +188,7 @@ function MediaThumb({
}
})();
return () => { cancelled = true; };
- }, [thumbHash]);
+ }, [thumbHash, retryToken]);
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" />`;
@@ -201,6 +208,11 @@ function bumpMediaMetaGeneration() {
for (const fn of _mediaMetaListeners) fn();
}
+const _thumbRetryListeners = new Set();
+function bumpThumbGeneration() {
+ for (const fn of _thumbRetryListeners) fn();
+}
+
function useMediaMeta(transportRef, fileId, active) {
const [meta, setMeta] = useState(null);
const [refetchToken, setRefetchToken] = useState(0);
@@ -266,7 +278,9 @@ function useSeasonMeta(transportRef, tmdbId, season, active) {
// ── Mode A: poster grid ──────────────────────────────────────────────────────
function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
- const meta = useMediaMeta(transportRef, repEntry.id, true);
+ const tRef = repEntry._tRef || transportRef;
+ const gRef = repEntry._gRef || gekRef;
+ const meta = useMediaMeta(tRef, repEntry.id, true);
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const metaReady = meta !== null;
@@ -318,7 +332,7 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g
<div class="video-poster-slot" style=${ready ? '' : 'display:none'}>
${metaReady && html`
<${MediaThumb} thumbHash=${posterHash} alt=${title}
- cls="video-poster" transportRef=${transportRef} gekRef=${gekRef}
+ cls="video-poster" transportRef=${tRef} gekRef=${gRef}
onReady=${handleImageReady} />
`}
</div>
@@ -330,6 +344,9 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g
${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''}
${subtitle ? ` · ${subtitle}` : ''}
</div>
+ ${repEntry.groupName && html`
+ <div class="video-card-group">${repEntry.groupName}</div>
+ `}
</div>
`}
</div>
@@ -534,7 +551,7 @@ 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=${transportRef} gekRef=${gekRef} />
+ cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef} />
</${LazyTile}>
<span class="video-episode-label">
S${ep.season}E${String(ep.episode).padStart(2, '0')}
@@ -615,7 +632,9 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
}, [shows, metaByGroup]);
const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
- const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.id : null, !!detail);
+ const detailTRef = detail && detail.repEntry._tRef ? detail.repEntry._tRef : transportRef;
+ const detailGRef = detail && detail.repEntry._gRef ? detail.repEntry._gRef : gekRef;
+ const detailMeta = useMediaMeta(detailTRef, detail ? detail.repEntry.id : null, !!detail);
return html`
<div class="video-grid">
@@ -666,7 +685,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
- transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin}
+ transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin}
onClose=${() => setDetail(null)}
onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
`}
@@ -683,6 +702,8 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
// 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 }) {
+ const tRef = entry._tRef || transportRef;
+ const gRef = entry._gRef || gekRef;
const isEpisode = seasonContext && entry.season != null && entry.episode != null;
const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext;
const label = isEpisode
@@ -694,7 +715,7 @@ 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=${transportRef} gekRef=${gekRef} />
+ cls="video-flat-thumb" transportRef=${tRef} gekRef=${gRef} />
</${LazyTile}>
<div class="video-flat-info">
<div class="video-flat-title">${label}</div>
@@ -703,6 +724,10 @@ function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext })
${' · '}${formatSize(entry.size)}
</div>
</div>
+ ${entry.groupName && html`
+ <a href="#/group/${entry.groupId}" class="badge search-group-badge"
+ onClick=${(e) => e.stopPropagation()}>${entry.groupName}</a>
+ `}
</div>
`;
}
@@ -755,6 +780,7 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
function VideoApp({
groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
+ hideFilter,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
@@ -812,11 +838,11 @@ function VideoApp({
${t('video.filter_series')}
</button>
</div>
- <div class="tb-search">
+ ${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
value=${filter} onInput=${(e) => setFilter(e.target.value)} />
- </div>
+ </div>`}
</div>
${filteredMovies.length === 0 && filteredShows.length === 0 && html`
<p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p>
@@ -836,4 +862,4 @@ function VideoApp({
// blob" and "mount only once actually scrolled near" mechanisms apply to a
// track's cover art unchanged, so Music imports them here rather than
// re-implementing (apps.md §4's checklist).
-export { VideoApp, MediaThumb, LazyTile };
+export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration };