From eba2e6b14484c124f3c87ff95cd7ee640833e5d3 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 15:35:20 +0200 Subject: feat(hub): cross-group search with reuse of existing views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 175 +++---- .../src/meshbay_hub/static/files-app.js | 16 +- .../src/meshbay_hub/static/group-page.js | 59 +-- .../src/meshbay_hub/static/hub-client.js | 17 +- .../src/meshbay_hub/static/locales/de.js | 12 +- .../src/meshbay_hub/static/locales/en.js | 16 +- .../src/meshbay_hub/static/locales/es.js | 11 +- .../src/meshbay_hub/static/locales/fr.js | 11 +- .../src/meshbay_hub/static/locales/it.js | 11 +- .../src/meshbay_hub/static/locales/ja.js | 11 +- .../src/meshbay_hub/static/locales/nl.js | 11 +- .../src/meshbay_hub/static/locales/pl.js | 11 +- .../src/meshbay_hub/static/locales/pt-BR.js | 11 +- .../src/meshbay_hub/static/locales/zh-CN.js | 11 +- .../src/meshbay_hub/static/music-app.js | 39 +- .../src/meshbay_hub/static/music-player.js | 32 +- .../src/meshbay_hub/static/search-page.js | 560 +++++++++++++++++++++ .../meshbay-hub/src/meshbay_hub/static/style.css | 106 +++- .../src/meshbay_hub/static/video-app.js | 46 +- 19 files changed, 916 insertions(+), 250 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/search-page.js (limited to 'packages/meshbay-hub/src/meshbay_hub') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 703bd47..50a972b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -8,14 +8,16 @@ import { transfers, formatSpeed } from './transfers.js'; import * as downloads from './downloads.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; -import { FILE_ICONS, formatSize } from './file-utils.js'; +import { formatSize } from './file-utils.js'; import { - HUB, navigate, session, getCachedGroupIndex, getAllCachedIndexes, + HUB, navigate, session, getCachedGroupIndex, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, refreshAccessToken, } from './hub-client.js'; import { GroupPage } from './group-page.js'; +import { SearchPage, ConnectionPool } from './search-page.js'; +import { MusicPlayerBar } from './music-player.js'; import { GroupName } from './group-name.js'; import { APPS } from './apps.js'; @@ -1293,118 +1295,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru `; } - -// ── Search Page (cross-group file search) ─────────────────────────────────── - -/** - * "3 hours ago", in the reader's language. - * - * The search page needs it because its results come from a cache: a file that - * was deleted an hour ago is still listed until the group is opened again, and - * the honest thing is to say how old the answer is rather than to imply it is - * live. - */ -function formatAgo(ts) { - if (!ts) return ''; - const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' }); - let delta = (ts - Date.now()) / 1000; - const steps = [['second', 60], ['minute', 60], ['hour', 24], - ['day', 7], ['week', 4.35], ['month', 12], ['year', Infinity]]; - for (const [unit, span] of steps) { - if (Math.abs(delta) < span || span === Infinity) { - return rtf.format(Math.round(delta), unit); - } - delta /= span; - } - return ''; -} - -function SearchPage() { - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [searched, setSearched] = useState(false); - - const doSearch = useCallback(async (q) => { - const term = q.trim().toLowerCase(); - if (!term) { setResults([]); setSearched(false); return; } - const indexes = await getAllCachedIndexes(); - const hits = []; - for (const idx of indexes) { - for (const e of (idx.entries || [])) { - if (e.name.toLowerCase().includes(term) || - (e.path && e.path.toLowerCase().includes(term))) { - hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName, - groupOwner: idx.groupOwner, syncedAt: idx.cachedAt }); - } - } - } - setResults(hits); - setSearched(true); - }, []); - - const onInput = useCallback((e) => { - const q = e.target.value; - setQuery(q); - doSearch(q); - }, [doSearch]); - - return html` -
-

${t('search.title')}

-
- -
- ${searched && results.length === 0 && html` -

${t('search.no_results')}

- `} - ${results.length > 0 && html` - - - - - - - - - - - - ${results.map(r => html` - - - - - - - - `)} - -
${t('group.col_name')}${t('group.col_size')}${t('search.col_group')}${t('group.col_type')}
${FILE_ICONS[r.type] || FILE_ICONS.other} - ${r.name} - ${formatSize(r.size)} - ${r.groupName - ? html`<${GroupName} name=${r.groupName} owner=${r.groupOwner} inline=${true} />` - : r.groupId.slice(0, 8)} - ${r.syncedAt && html` -
- ${t('search.synced', { ago: formatAgo(r.syncedAt) })} -
- `} -
${r.type}
-

- ${t('search.result_count', { n: results.length })} -

- `} - ${!searched && html` -

${t('search.hint')}

- `} -

${t('search.cache_note')}

-
- `; -} - // ── Settings Page ─────────────────────────────────────────────────────────── const THEME_OPTIONS = ['light', 'dark', 'system']; @@ -3010,6 +2900,48 @@ function App() { const [hubInfo, setHubInfo] = useState(null); const allowPublicGroups = !hubInfo || hubInfo.allow_public_groups !== false; + // -- Persistent music player (lifted from group-page.js) -- + const [musicQueue, setMusicQueue] = useState(null); + const musicPoolRef = useRef(null); + const userRef = useRef(user); + userRef.current = user; + const groupTransportRef = useRef(null); + + useEffect(() => { + musicPoolRef.current = new ConnectionPool(HUB); + return () => { if (musicPoolRef.current) musicPoolRef.current.closeAll(); }; + }, []); + + const getMusicConnection = useCallback(async (groupId) => { + const gt = groupTransportRef.current; + if (gt && gt.groupId === groupId) { + const tr = gt.transportRef.current; + if (tr && tr.connected) return { transport: tr, gek: gt.gekRef.current }; + } + const u = userRef.current; + if (!u || !musicPoolRef.current) throw new Error('no connection'); + const bundleKey = session.bundleKey || await _loadBundleKey(); + if (bundleKey) session.bundleKey = bundleKey; + const conn = await musicPoolRef.current.connect( + groupId, u.token, bundleKey, u.username, u.userId); + return { transport: conn.transport, gek: conn.gek }; + }, []); + + const handlePlayQueue = useCallback((tracks, startIndex, source) => { + if (source && source.transportRef) { + groupTransportRef.current = { + groupId: source.groupId, + transportRef: source.transportRef, + gekRef: source.gekRef, + }; + } else { + groupTransportRef.current = null; + } + setMusicQueue({ tracks, startIndex, nonce: Date.now() }); + }, []); + + const handleStopMusic = useCallback(() => setMusicQueue(null), []); + const resolved = resolveTheme(theme); // Keep the session alive without anyone having to think about it. @@ -3286,7 +3218,10 @@ function App() { } else if (!user) { page = html`<${LoginPage} />`; } else if (route === '/search') { - page = html`<${SearchPage} />`; + page = html`<${SearchPage} + token=${user.token} username=${user.username} userId=${user.userId} + groups=${groups} userPrefs=${userPrefs} + onPlayQueue=${handlePlayQueue} />`; } else if (route === '/explore') { page = html`<${ExplorePage} token=${user.token} myGroupIds=${groups.map(g => g.id)} @@ -3311,7 +3246,8 @@ function App() { userPrefs=${userPrefs} onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} onGroupUpdated=${updateGroup} onPresence=${notePresence} - onLeft=${handleLeftGroup} />`; + onLeft=${handleLeftGroup} + onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} role=${user.role} />` @@ -3360,6 +3296,13 @@ function App() { ${page} + ${musicQueue && html` + <${MusicPlayerBar} + getConnection=${getMusicConnection} + queue=${musicQueue} + userPrefs=${userPrefs} + onClose=${handleStopMusic} /> + `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index c4b221e..daf13af 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -27,6 +27,7 @@ function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + showGroup, readOnly, }) { const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState(() => new Set()); @@ -331,19 +332,19 @@ function FilesPanel({
-
@@ -358,6 +359,7 @@ function FilesPanel({ toggleSort('size')}> ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} + ${showGroup && html`${t('search.col_group')}`} toggleSort('type')}> ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} @@ -386,6 +388,7 @@ function FilesPanel({ ${t('group.root_unavailable')} ` : ''} ${inside.length ? formatSize(bytes) : ''} + ${showGroup && html``} @@ -411,12 +414,15 @@ function FilesPanel({ onClick=${() => { setFilter(''); setCurrentPath(e.path); }}>${e.path}`} ${formatSize(e.size)} + ${showGroup && html` + ${e.groupName || ''} + `} ${e.type} ${formatDate(e.added_at)} `)} ${sorted.length === 0 && subdirs.length === 0 && html` - + ${filter ? t('group.empty_filter') : t('group.empty_dir')} `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 4ebe5a7..5a2a79b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -12,7 +12,6 @@ import { visibleApps } from './apps.js'; import { GroupName } from './group-name.js'; import { FilePreview } from './files-app.js'; import { VideoPlayer } from './video-player.js'; -import { MusicPlayerBar } from './music-player.js'; import { GroupSettingsPanel } from './group-settings.js'; /** @@ -23,7 +22,8 @@ import { GroupSettingsPanel } from './group-settings.js'; * an operator with no way to re-enable anything). */ function GroupPage({ groupId, group, token, username, userId, userPrefs, - onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) { + onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft, + onPlayQueue: parentOnPlayQueue, onStopMusic }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); @@ -97,26 +97,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // MusicBrainz on/off (per-group) + whether a contact string is configured // (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); - // What music-app.js hands over when a track/album is clicked — owned here - // (not by music-app.js) so playback survives switching tabs, the same - // reasoning the video/preview modals are shell-owned. `nonce` makes - // "play this same album again from track 0" a distinct value every time, - // so MusicPlayerBar's queue-init effect always re-runs. - const [musicQueue, setMusicQueue] = useState(null); - // Starting one player stops the other — both would otherwise keep playing - // at once, found live: opening a film while a track was going left both - // audio tracks running together. onPlayQueue closes the video modal for - // the same reason `entry.type === 'video'` below stops the music queue. const onPlayQueue = useCallback((tracks, startIndex) => { setVideoEntry(null); - setMusicQueue({ tracks, startIndex, nonce: Date.now() }); - }, []); - // This component instance is not remounted when switching to a *different* - // group on the same /group/:id route (only groupId as a prop changes, see - // the connect effect's own comment below) — so leaving music playing here - // would carry it into whatever group is opened next. Tab switches inside - // one group must not stop it; leaving the group itself must. - useEffect(() => { setMusicQueue(null); }, [groupId]); + if (parentOnPlayQueue) { + const annotated = tracks.map(tr => tr.groupId ? tr : { ...tr, groupId }); + parentOnPlayQueue(annotated, startIndex, { transportRef, gekRef, groupId }); + } + }, [parentOnPlayQueue, groupId]); // Paired ≠ operator account. `is_node_admin` says the hub account owning this // node is the one connecting; this says the node pinned *this browser's* key // as an operator key. Only the second one lets you sign an invite, and only @@ -162,8 +149,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); if (indexMsg.roots) setNodeRoots(indexMsg.roots); cacheGroupIndex(groupId, group ? group.name : groupId, - group ? group.owner_username : null, fresh); - }, [groupId, group]); + group ? group.owner_username : null, fresh, + { videoRoot, audioRoot, photoRoots }); + }, [groupId, group, videoRoot, audioRoot, photoRoots]); // additions/deletions/updates (daemon.py _broadcast_index_change, once // there is a previous snapshot to diff against) — applied on top of @@ -184,10 +172,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id)); const fresh = updated.concat(additions); cacheGroupIndex(groupId, group ? group.name : groupId, - group ? group.owner_username : null, fresh); + group ? group.owner_username : null, fresh, + { videoRoot, audioRoot, photoRoots }); return fresh; }); - }, [groupId, group]); + }, [groupId, group, videoRoot, audioRoot, photoRoots]); useEffect(() => { let cancelled = false; @@ -464,9 +453,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // already playing (from Music, or a previous Files click) rather than // merging with it, so there is nothing to special-case here. const onPreview = useCallback((entry) => { - // Opening a film stops whatever the Music player was doing — see the - // matching setVideoEntry(null) in onPlayQueue above for the reverse case. - if (entry.type === 'video') { setMusicQueue(null); setVideoEntry(entry); return; } + if (entry.type === 'video') { + if (onStopMusic) onStopMusic(); + setVideoEntry(entry); + return; + } if (entry.type === 'audio') { const siblings = entries .filter((e) => e.type === 'audio' && e.path === entry.path) @@ -476,7 +467,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, return; } setPreviewEntry(entry); - }, [entries, onPlayQueue]); + }, [entries, onPlayQueue, onStopMusic]); const apps = visibleApps(enabledApps); const commonProps = { @@ -647,18 +638,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onClose=${() => setVideoEntry(null)} onDownload=${() => downloadFileForModal(videoEntry)} /> `} - ${/* Outside the tab-switched area on purpose (docs/musicbay.md §2.3): - once something has been played this session, the bar stays - mounted and keeps playing regardless of which tab is active — - switching to Chat or Files must not stop the music. Renders - nothing of its own until onPlayQueue has been called once. - Its own close button, and the effect above on leaving the - group, both go through the same setMusicQueue(null) — the - bar's own unmount cleanup is what actually stops playback. */ - musicQueue && html` - <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue} - userPrefs=${userPrefs} onClose=${() => setMusicQueue(null)} /> - `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index 176d22b..16dce9f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -35,12 +35,13 @@ function openDB() { }); } -async function cacheGroupIndex(groupId, groupName, groupOwner, entries) { +async function cacheGroupIndex(groupId, groupName, groupOwner, entries, roots) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put({ - groupId, groupName, groupOwner, entries, cachedAt: Date.now(), + groupId, groupName, groupOwner, entries, roots: roots || {}, + cachedAt: Date.now(), }); await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); db.close(); @@ -69,6 +70,16 @@ async function getAllCachedIndexes() { } catch { return []; } } +async function clearAllCachedIndexes() { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).clear(); + await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); + db.close(); + } catch { /* best-effort */ } +} + // ── Auth persistence ───────────────────────────────────────────────────────── // The key that opens a node's keypair bundle, derived once at sign-in, and a @@ -262,7 +273,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { export { HUB, navigate, session, - cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, + cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index f8daa16..e64d910 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -522,16 +522,18 @@ export default { 'search.title': 'Dateien suchen', 'search.placeholder': 'In allen Gruppen suchen …', 'search.no_results': 'Keine Datei entspricht Ihrer Suche.', - 'search.synced': 'synchronisiert {ago}', - 'search.cache_note': 'Die Suche stützt sich auf den Stand, den jede Gruppe beim ' - + 'letzten Öffnen hatte. Öffnen Sie eine Gruppe, um ihre Dateien hier zu ' - + 'aktualisieren.', 'search.col_group': 'Gruppe', 'search.result_count': { one: '{n} Datei gefunden', other: '{n} Dateien gefunden', }, - 'search.hint': 'Durchsucht Dateinamen in allen zwischengespeicherten Gruppenindexen.', + 'search.hint': 'Tippen Sie einen Begriff ein, um in allen Ihren Gruppen zu suchen.', + 'search.indexing': 'Indexierung von {done} / {total} Gruppen …', + 'search.unreachable': { one: '{n} Gruppe nicht erreichbar', other: '{n} Gruppen nicht erreichbar' }, + 'search.view_files': 'Dateien', + 'search.view_videos': 'Videos', + 'search.view_music': 'Musik', + 'search.connecting': 'Verbindung zu Gruppen wird hergestellt …', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Ein Node, der diese Gruppe hostet, ist online', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 7cb3769..06d54a7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -603,15 +603,15 @@ export default { 'search.title': 'Search Files', 'search.placeholder': 'Search across all groups...', 'search.no_results': 'No files match your search.', - 'search.synced': 'synced {ago}', - 'search.cache_note': 'Search reads what each group looked like the last time you ' - + 'opened it. Open a group to bring its files up to date here.', 'search.col_group': 'Group', - 'search.result_count': { - one: '{n} file found', - other: '{n} files found', - }, - 'search.hint': 'Search file names across all cached group indexes.', + 'search.result_count': { one: '{n} file found', other: '{n} files found' }, + 'search.hint': 'Type to search file names across all your groups.', + 'search.indexing': 'Indexing {done} / {total} groups...', + 'search.unreachable': { one: '{n} group unreachable', other: '{n} groups unreachable' }, + 'search.view_files': 'Files', + 'search.view_videos': 'Videos', + 'search.view_music': 'Music', + 'search.connecting': 'Connecting to groups...', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'A node serving this group is online', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index ba64ee0..af211c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -518,15 +518,18 @@ export default { 'search.title': 'Buscar archivos', 'search.placeholder': 'Buscar en todos los grupos...', 'search.no_results': 'Ningún archivo coincide con su búsqueda.', - 'search.synced': 'sincronizado {ago}', - 'search.cache_note': 'La búsqueda usa el estado que tenía cada grupo la última vez ' - + 'que lo abrió. Abra un grupo para poner sus archivos al día aquí.', 'search.col_group': 'Grupo', 'search.result_count': { one: '{n} archivo encontrado', other: '{n} archivos encontrados', }, - 'search.hint': 'Busca nombres de archivo en todos los índices de grupo en caché.', + 'search.hint': 'Escriba un término para buscar en todos sus grupos.', + 'search.indexing': 'Indexando {done} / {total} grupos…', + 'search.unreachable': { one: '{n} grupo inalcanzable', other: '{n} grupos inalcanzables' }, + 'search.view_files': 'Archivos', + 'search.view_videos': 'Vídeos', + 'search.view_music': 'Música', + 'search.connecting': 'Conectando a los grupos…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Un node que sirve este grupo está en línea', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 60e56c9..92ab2b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -522,15 +522,18 @@ export default { 'search.title': 'Rechercher des fichiers', 'search.placeholder': 'Rechercher dans tous les groupes...', 'search.no_results': 'Aucun fichier ne correspond à votre recherche.', - 'search.synced': 'synchronisé {ago}', - 'search.cache_note': 'La recherche s’appuie sur l’état de chaque groupe lors de ' - + 'votre dernière visite. Ouvrez un groupe pour mettre ses fichiers à jour ici.', 'search.col_group': 'Groupe', 'search.result_count': { one: '{n} fichier trouvé', other: '{n} fichiers trouvés', }, - 'search.hint': 'Recherche les noms de fichiers dans tous les index de groupe en cache.', + 'search.hint': 'Saisissez un terme pour rechercher dans tous vos groupes.', + 'search.indexing': 'Indexation de {done} / {total} groupes…', + 'search.unreachable': { one: '{n} groupe injoignable', other: '{n} groupes injoignables' }, + 'search.view_files': 'Fichiers', + 'search.view_videos': 'Vidéos', + 'search.view_music': 'Musique', + 'search.connecting': 'Connexion aux groupes…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Un node servant ce groupe est en ligne', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index ccf54ed..201fdfc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -520,15 +520,18 @@ export default { 'search.title': 'Cerca file', 'search.placeholder': 'Cerca in tutti i gruppi...', 'search.no_results': 'Nessun file corrisponde alla sua ricerca.', - 'search.synced': 'sincronizzato {ago}', - 'search.cache_note': 'La ricerca si basa su com’era ogni gruppo l’ultima volta che ' - + 'lo ha aperto. Apra un gruppo per aggiornarne qui i file.', 'search.col_group': 'Gruppo', 'search.result_count': { one: '{n} file trovato', other: '{n} file trovati', }, - 'search.hint': 'Cerca i nomi dei file in tutti gli indici di gruppo in cache.', + 'search.hint': 'Digita un termine per cercare in tutti i tuoi gruppi.', + 'search.indexing': 'Indicizzazione di {done} / {total} gruppi…', + 'search.unreachable': { one: '{n} gruppo non raggiungibile', other: '{n} gruppi non raggiungibili' }, + 'search.view_files': 'File', + 'search.view_videos': 'Video', + 'search.view_music': 'Musica', + 'search.connecting': 'Connessione ai gruppi…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Un node che ospita questo gruppo è online', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 2ea90cb..a421e25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -509,14 +509,17 @@ export default { 'search.title': 'ファイルを検索', 'search.placeholder': 'すべてのグループから検索…', 'search.no_results': '検索条件に一致するファイルはありません。', - 'search.synced': '{ago}に同期', - 'search.cache_note': '検索は、各グループを最後に開いたときの状態を参照します。' - + 'グループを開くと、そのファイルがここで最新になります。', 'search.col_group': 'グループ', 'search.result_count': { other: '{n} 個のファイルが見つかりました', }, - 'search.hint': 'キャッシュされたすべてのグループインデックスからファイル名を検索します。', + 'search.hint': '入力してすべてのグループを検索します。', + 'search.indexing': '{done} / {total} グループをインデックス中…', + 'search.unreachable': { other: '{n} グループに接続できません' }, + 'search.view_files': 'ファイル', + 'search.view_videos': '動画', + 'search.view_music': '音楽', + 'search.connecting': 'グループに接続中…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'このグループをホストする node が稼働中です', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 4b7ff12..b2e3b24 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -522,15 +522,18 @@ export default { 'search.title': 'Bestanden zoeken', 'search.placeholder': 'In alle groepen zoeken...', 'search.no_results': 'Geen bestand voldoet aan uw zoekopdracht.', - 'search.synced': 'gesynchroniseerd {ago}', - 'search.cache_note': 'De zoekopdracht gaat uit van hoe elke groep eruitzag toen u ' - + 'die voor het laatst opende. Open een groep om de bestanden hier bij te werken.', 'search.col_group': 'Groep', 'search.result_count': { one: '{n} bestand gevonden', other: '{n} bestanden gevonden', }, - 'search.hint': 'Zoekt bestandsnamen in alle groepsindexen in de cache.', + 'search.hint': 'Typ een zoekterm om in al uw groepen te zoeken.', + 'search.indexing': '{done} / {total} groepen indexeren…', + 'search.unreachable': { one: '{n} groep onbereikbaar', other: '{n} groepen onbereikbaar' }, + 'search.view_files': 'Bestanden', + 'search.view_videos': 'Video\'s', + 'search.view_music': 'Muziek', + 'search.connecting': 'Verbinding maken met groepen…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Een node die deze groep host is online', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 611c412..2c2bacd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -539,9 +539,6 @@ export default { 'search.title': 'Wyszukiwanie plików', 'search.placeholder': 'Szukaj we wszystkich grupach...', 'search.no_results': 'Żaden plik nie pasuje do zapytania.', - 'search.synced': 'zsynchronizowano {ago}', - 'search.cache_note': 'Wyszukiwanie opiera się na stanie każdej grupy z chwili jej ' - + 'ostatniego otwarcia. Otwarcie grupy odświeża tutaj jej pliki.', 'search.col_group': 'Grupa', 'search.result_count': { one: 'Znaleziono {n} plik', @@ -549,7 +546,13 @@ export default { many: 'Znaleziono {n} plików', other: 'Znaleziono {n} pliku', }, - 'search.hint': 'Przeszukuje nazwy plików we wszystkich zbuforowanych indeksach grup.', + 'search.hint': 'Wpisz frazę, aby wyszukać we wszystkich grupach.', + 'search.indexing': 'Indeksowanie {done} / {total} grup…', + 'search.unreachable': { one: '{n} grupa nieosiągalna', few: '{n} grupy nieosiągalne', many: '{n} grup nieosiągalnych', other: '{n} grupy nieosiągalnej' }, + 'search.view_files': 'Pliki', + 'search.view_videos': 'Filmy', + 'search.view_music': 'Muzyka', + 'search.connecting': 'Łączenie z grupami…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Node hostujący tę grupę jest dostępny', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 74e5047..96dbc75 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -519,15 +519,18 @@ export default { 'search.title': 'Pesquisar arquivos', 'search.placeholder': 'Pesquisar em todos os grupos...', 'search.no_results': 'Nenhum arquivo corresponde à sua pesquisa.', - 'search.synced': 'sincronizado {ago}', - 'search.cache_note': 'A pesquisa usa o estado de cada grupo na última vez que você o ' - + 'abriu. Abra um grupo para atualizar os arquivos dele aqui.', 'search.col_group': 'Grupo', 'search.result_count': { one: '{n} arquivo encontrado', other: '{n} arquivos encontrados', }, - 'search.hint': 'Pesquisa nomes de arquivo em todos os índices de grupo em cache.', + 'search.hint': 'Digite um termo para pesquisar em todos os seus grupos.', + 'search.indexing': 'Indexando {done} / {total} grupos…', + 'search.unreachable': { one: '{n} grupo inacessível', other: '{n} grupos inacessíveis' }, + 'search.view_files': 'Arquivos', + 'search.view_videos': 'Vídeos', + 'search.view_music': 'Música', + 'search.connecting': 'Conectando aos grupos…', // Presence, chat paging, leaving a group, the profile page 'presence.online': 'Um node que hospeda este grupo está on-line', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d2e6b48..45d89f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -495,14 +495,17 @@ export default { 'search.title': '搜索文件', 'search.placeholder': '在所有群组中搜索…', 'search.no_results': '没有文件符合您的搜索。', - 'search.synced': '{ago}同步', - 'search.cache_note': '搜索依据的是您上次打开每个群组时的状态。' - + '打开某个群组,即可在此更新它的文件。', 'search.col_group': '群组', 'search.result_count': { other: '找到 {n} 个文件', }, - 'search.hint': '在所有已缓存的群组索引中搜索文件名。', + 'search.hint': '输入关键词搜索所有群组。', + 'search.indexing': '正在索引 {done} / {total} 个群组…', + 'search.unreachable': { other: '{n} 个群组无法连接' }, + 'search.view_files': '文件', + 'search.view_videos': '视频', + 'search.view_music': '音乐', + 'search.connecting': '正在连接群组…', // Presence, chat paging, leaving a group, the profile page 'presence.online': '有一个服务本群组的 node 在线', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index dd354d9..418da89 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -174,8 +174,21 @@ function groupMusicEntries(entries, audioRoot) { // -- album cover, MusicBrainz fetched lazily and only when actually needed -- +const _musicMetaRetryListeners = new Set(); +function bumpMusicMetaGeneration() { + for (const fn of _musicMetaRetryListeners) fn(); +} + function useMusicMeta(transportRef, fileId, active) { const [meta, setMeta] = useState(null); + const [retryToken, setRetryToken] = useState(0); + + useEffect(() => { + const listener = () => setRetryToken((n) => n + 1); + _musicMetaRetryListeners.add(listener); + return () => _musicMetaRetryListeners.delete(listener); + }, []); + useEffect(() => { if (!active || !fileId) return; let cancelled = false; @@ -188,7 +201,7 @@ function useMusicMeta(transportRef, fileId, active) { } catch { if (!cancelled) setMeta({ confidence: 0 }); } })(); return () => { cancelled = true; }; - }, [fileId, active]); + }, [fileId, active, retryToken]); return meta; } @@ -236,21 +249,24 @@ function DiscPlaceholder({ cls }) { function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen }) { const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0]; - // Only when nothing in the library already gives us a cover -- the common - // case (a well-tagged rip with embedded art) needs no network call at all. + const tRef = repTrack._tRef || transportRef; + const gRef = repTrack._gRef || gekRef; const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash; - const meta = useMusicMeta(transportRef, repTrack.id, needsLookup); + const meta = useMusicMeta(tRef, repTrack.id, needsLookup); const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null; return html`
${coverHash ? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album} - cls="music-cover" transportRef=${transportRef} gekRef=${gekRef} />` + cls="music-cover" transportRef=${tRef} gekRef=${gRef} />` : html`<${DiscPlaceholder} cls="music-cover" />`}
${album.album}
${album.artist}
+ ${repTrack.groupName && html` +
${repTrack.groupName}
+ `}
`; @@ -260,8 +276,10 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen }) function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onClose, onPlayQueue }) { const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0]; + const tRef = repTrack._tRef || transportRef; + const gRef = repTrack._gRef || gekRef; const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash; - const meta = useMusicMeta(transportRef, repTrack.id, needsLookup); + const meta = useMusicMeta(tRef, repTrack.id, needsLookup); const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null; return html` @@ -278,7 +296,7 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC
${coverHash ? html`<${MediaThumb} thumbHash=${coverHash} alt=${album.album} - cls="music-detail-cover" transportRef=${transportRef} gekRef=${gekRef} />` + cls="music-detail-cover" transportRef=${tRef} gekRef=${gRef} />` : html`<${DiscPlaceholder} cls="music-detail-cover" />`}
${album.artist}
@@ -437,6 +455,7 @@ function FlatList({ tracks, artists, onPlayQueue }) { function MusicApp({ groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue, + hideFilter, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); @@ -487,11 +506,11 @@ function MusicApp({ onClick=${() => setModeAndSave('flat')}> ${t('music.mode_flat')} - `}
${empty && html`

${t('music.empty')}

`} ${!empty && needle && filteredArtists.length === 0 && filteredTracks.length === 0 && html` @@ -506,4 +525,4 @@ function MusicApp({ `; } -export { MusicApp }; +export { MusicApp, groupMusicEntries, bumpMusicMetaGeneration }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js index ffe092d..2d3c002 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js @@ -8,13 +8,14 @@ import { CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; /** * The Music app's persistent player bar (docs/musicbay.md §2.3, §7.2). * - * Owned and rendered by group-page.js, *not* by music-app.js: it is the one - * piece of this feature that lives outside the tab-switched area, so - * playback survives navigating to Chat or Files, exactly the way the - * video/preview modals are shell-owned rather than owned by whichever app - * opened them. music-app.js never touches audio state directly — it only - * calls `onPlayQueue(tracks, startIndex)`, threaded down from group-page.js, - * to hand this component a new queue. + * Owned and rendered by app.js — the router's parent — so playback survives + * navigating between groups, search, and other pages. Both group-page.js + * and search-page.js trigger playback by calling `onPlayQueue(tracks, + * startIndex)`, which flows up to app.js. + * + * Each track carries a `groupId`; the player calls `getConnection(groupId)` + * per track to obtain the right transport — a pool-based lazy connection + * that transparently handles single-group and cross-group queues. * * No streaming, no MSE, no node-side transcode pool for the common case: a * track is a few megabytes, so it is downloaded and decrypted once through @@ -177,7 +178,7 @@ function QueuePanel({ tracks, order, pos, onSelect, onClose }) { `; } -function MusicPlayerBar({ transportRef, gekRef, queue, onClose, userPrefs }) { +function MusicPlayerBar({ getConnection, queue, onClose, userPrefs }) { const audioRef = useRef(null); const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index } const blobInsertRef = useRef(0); @@ -284,13 +285,8 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose, userPrefs }) { const fetchTrackBlob = useCallback(async (entry) => { const cached = blobCacheRef.current.get(entry.id); if (cached) return cached.url; - const transport = transportRef.current; + const { transport, gek } = await getConnection(entry.groupId); if (!transport) throw new Error(t('music.err_transport')); - // A track ending (or "next") right after a screen-lock reconnect started - // is exactly when this used to throw: `connected` was still false because - // the reconnect it only had to wait a few seconds for hadn't landed yet. - // waitForReconnect is a no-op when nothing is in flight, so this costs - // nothing on the ordinary path. if (!transport.connected) await transport.waitForReconnect(); if (!transport.connected) throw new Error(t('music.err_transport')); @@ -310,17 +306,13 @@ function MusicPlayerBar({ transportRef, gekRef, queue, onClose, userPrefs }) { } const totalChunks = Math.ceil(downloadSize / CHUNK_SIZE); - const chunks = await pipelinedDownload(transport, gekRef.current, downloadId, totalChunks); + const chunks = await pipelinedDownload(transport, gek, downloadId, totalChunks); const blob = new Blob(chunks, { type: mime }); const url = URL.createObjectURL(blob); - // Keyed by the track's own id, not `downloadId` — the transcode cache - // hash is an implementation detail of getting there, and a second play - // of the same track must still hit this cache rather than re-requesting - // a transcode the node already ran once. blobCacheRef.current.set(entry.id, { url, order: blobInsertRef.current++ }); evictOldBlobs(); return url; - }, [transportRef, gekRef, evictOldBlobs]); + }, [getConnection, evictOldBlobs]); // Silently warms the cache for the next tracks so pressing "next" doesn't // visibly wait (musicbay.md §2.2) — best-effort, never surfaces an error. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js new file mode 100644 index 0000000..032d26a --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -0,0 +1,560 @@ +import { + html, useState, useEffect, useRef, useMemo, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { canPreview, downloadEntry } from './file-utils.js'; +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 { VideoPlayer } from './video-player.js'; +import { transfers } from './transfers.js'; + +const BATCH_SIZE = 3; +const MAX_POOL_SIZE = 3; +const DEBOUNCE_MS = 200; +const SEARCH_VIDEO_ROOT = '__search__'; +const SEARCH_AUDIO_ROOT = '__search__'; + +// -- Connection pool ---------------------------------------------------------- + +class ConnectionPool { + constructor(hubBase) { + this._hubBase = hubBase; + this._connections = new Map(); + this._connecting = new Map(); + } + + async connect(groupId, token, bundleKey, username, userId) { + const existing = this._connections.get(groupId); + if (existing && existing.transport.connected) { + existing.lastUsed = Date.now(); + return existing; + } + if (this._connecting.has(groupId)) return this._connecting.get(groupId); + + const p = this._doConnect(groupId, token, bundleKey, username, userId); + this._connecting.set(groupId, p); + try { + const conn = await p; + this._connections.set(groupId, conn); + this._evict(); + return conn; + } finally { + this._connecting.delete(groupId); + } + } + + async _doConnect(groupId, token, bundleKey, username, userId) { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (!nodesData.nodes || !nodesData.nodes.length) throw new Error('offline'); + + const live = (await ensureFreshToken()) || token; + const transport = new window.MeshBayTransport(this._hubBase, live); + transport.onNeedToken = async () => (await ensureFreshToken()) || token; + + await transport.connect( + nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, + username, userId, null); + + let gek = null; + if (transport.gekRaw && window.MeshBayCrypto) { + gek = await window.MeshBayCrypto.importGEK( + window.MeshBayCrypto.b64encode(transport.gekRaw)); + } + return { transport, gek, lastUsed: Date.now() }; + } + + _evict() { + while (this._connections.size > MAX_POOL_SIZE) { + let oldestId = null, oldestTime = Infinity; + for (const [id, conn] of this._connections) { + if (conn.lastUsed < oldestTime) { oldestTime = conn.lastUsed; oldestId = id; } + } + if (!oldestId) break; + const conn = this._connections.get(oldestId); + try { conn.transport.close(); } catch {} + this._connections.delete(oldestId); + } + } + + closeAll() { + for (const [, conn] of this._connections) { + try { conn.transport.close(); } catch {} + } + this._connections.clear(); + for (const [, p] of this._connecting) { + p.then(c => { try { c.transport.close(); } catch {} }).catch(() => {}); + } + this._connecting.clear(); + } +} + +// -- Index fetching ----------------------------------------------------------- + +async function fetchGroupIndex(groupId, token, bundleKey, username, userId) { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (!nodesData.nodes || !nodesData.nodes.length) return null; + + const live = (await ensureFreshToken()) || token; + const transport = new window.MeshBayTransport(HUB, live); + transport.onNeedToken = async () => (await ensureFreshToken()) || token; + + try { + const ack = await transport.connect( + nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, + username, userId, null); + + const indexMsg = await transport.fetchIndex(); + const roots = { + videoRoot: ack.video_root || '', + audioRoot: ack.audio_root || '', + photoRoots: ack.photo_roots || [], + }; + return { entries: indexMsg.entries || [], roots }; + } finally { + try { transport.close(); } catch {} + } +} + +async function fetchAllIndexes(groups, token, username, userId, onProgress, onBatch) { + const bundleKey = session.bundleKey || await _loadBundleKey(); + if (bundleKey) session.bundleKey = bundleKey; + + const total = groups.length; + let done = 0; + const unreachable = []; + const results = new Map(); + + for (let i = 0; i < groups.length; i += BATCH_SIZE) { + const batch = groups.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async (g) => { + try { + const result = await fetchGroupIndex(g.id, token, bundleKey, username, userId); + if (result) { + results.set(g.id, { + ...result, + groupName: g.name, + groupOwner: g.owner_username, + }); + cacheGroupIndex(g.id, g.name, g.owner_username, result.entries, result.roots); + } else { + unreachable.push(g.name || g.id); + } + } catch { + unreachable.push(g.name || g.id); + } + done++; + onProgress({ done, total, unreachable: [...unreachable] }); + })); + onBatch(new Map(results)); + } + return { results, unreachable }; +} + +// -- SearchPage --------------------------------------------------------------- + +function underRoot(entry, root) { + if (!root) return false; + const p = entry.path || ''; + return p === root || p.startsWith(root + '/'); +} + +function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) { + const [indexedGroups, setIndexedGroups] = useState(new Map()); + const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] }); + const [fetching, setFetching] = useState(false); + const [query, setQuery] = useState(''); + const [viewMode, setViewMode] = useState('files'); + const [videoEntry, setVideoEntry] = useState(null); + const [previewEntry, setPreviewEntry] = useState(null); + const [connecting, setConnecting] = useState(false); + const poolRef = useRef(null); + const modalTransportRef = useRef(null); + const modalGekRef = useRef(null); + const groupConns = useRef(new Map()); + 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(); }; + }, []); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS); + return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; + }, [query]); + + useEffect(() => { + if (!groups || !groups.length || !token || !window.MeshBayTransport) return; + let cancelled = false; + + (async () => { + setFetching(true); + setProgress({ done: 0, total: groups.length, unreachable: [] }); + + await fetchAllIndexes( + groups, token, username, userId, + (p) => { if (!cancelled) setProgress(p); }, + (results) => { if (!cancelled) setIndexedGroups(new Map(results)); }, + ); + + if (!cancelled) setFetching(false); + })(); + + return () => { cancelled = true; }; + }, [groups, token]); + + // -- Connection management -- + + const connectGroup = useCallback(async (groupId) => { + if (groupConns.current.has(groupId)) { + const c = groupConns.current.get(groupId); + if (c.transport && c.transport.connected) return c; + } + 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); + const entry = { + transport: conn.transport, + gek: conn.gek, + tRef: { current: conn.transport }, + gRef: { current: conn.gek }, + }; + groupConns.current.set(groupId, entry); + setConnectionGen((g) => g + 1); + bumpMediaMetaGeneration(); + bumpThumbGeneration(); + bumpMusicMetaGeneration(); + return entry; + }, [token, username, userId]); + + // Eagerly connect to groups when entering Videos or Music view + useEffect(() => { + if (viewMode !== 'videos' && viewMode !== 'music') return; + let cancelled = false; + + (async () => { + const groupIds = [...indexedGroups.keys()].filter((gid) => { + const data = indexedGroups.get(gid); + if (viewMode === 'videos') return !!data.roots.videoRoot; + return !!data.roots.audioRoot; + }); + + for (let i = 0; i < groupIds.length; i += BATCH_SIZE) { + if (cancelled) break; + const batch = groupIds.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async (gid) => { + try { await connectGroup(gid); } catch { /* skip */ } + })); + } + })(); + + return () => { cancelled = true; }; + }, [viewMode, indexedGroups, connectGroup]); + + // -- Entry preparation -- + + const q = debouncedQuery.trim().toLowerCase(); + + const matchesQuery = useCallback((e) => { + if (!q) return true; + return (e.name || '').toLowerCase().includes(q) + || (e.path || '').toLowerCase().includes(q) + || (e.display_title || '').toLowerCase().includes(q) + || (e.artist || '').toLowerCase().includes(q) + || (e.album || '').toLowerCase().includes(q); + }, [q]); + + const allEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + for (const e of data.entries) { + result.push({ + ...e, groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + }); + } + } + return result; + }, [indexedGroups]); + + // Files view: path-prefixed entries for FilesPanel directory navigation + const fileEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + for (const e of data.entries) { + if (q && !matchesQuery(e)) continue; + const conn = groupConns.current.get(groupId); + result.push({ + ...e, + path: data.groupName + (e.path ? '/' + e.path : ''), + _origPath: e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + + const fileNodeDirs = useMemo(() => { + const dirs = []; + for (const [, data] of indexedGroups) dirs.push(data.groupName); + return dirs; + }, [indexedGroups]); + + // Videos view: pre-filtered by videoRoot, path-prefixed + const videoEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + const root = data.roots.videoRoot; + if (!root) continue; + const conn = groupConns.current.get(groupId); + for (const e of data.entries) { + if (e.type !== 'video') continue; + if (!underRoot(e, root)) continue; + if (q && !matchesQuery(e)) continue; + result.push({ + ...e, + path: SEARCH_VIDEO_ROOT + '/' + e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + + // Music view: pre-filtered by audioRoot, path-prefixed + const musicEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + const root = data.roots.audioRoot; + if (!root) continue; + const conn = groupConns.current.get(groupId); + for (const e of data.entries) { + if (e.type !== 'audio') continue; + if (!underRoot(e, root)) continue; + if (q && !matchesQuery(e)) continue; + result.push({ + ...e, + path: SEARCH_AUDIO_ROOT + '/' + e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + + // -- Callbacks -- + + const onPreview = useCallback(async (entry) => { + const groupId = entry.groupId; + if (!groupId) return; + + if (entry.type === 'video') { + setConnecting(true); + try { + const conn = await connectGroup(groupId); + modalTransportRef.current = conn.transport; + modalGekRef.current = conn.gek; + setVideoEntry(entry); + } catch { /* ignore */ } + setConnecting(false); + return; + } + + if (entry.type === 'audio' && onPlayQueue) { + const origPath = entry._origPath != null ? entry._origPath : entry.path; + const siblings = allEntries + .filter((e) => e.type === 'audio' && e.path === origPath && e.groupId === groupId) + .sort((a, b) => + ((a.track_no == null ? 9999 : a.track_no) - (b.track_no == null ? 9999 : b.track_no)) + || (a.name || '').localeCompare(b.name || '')); + const startIndex = Math.max(0, siblings.findIndex((e) => e.id === entry.id)); + onPlayQueue(siblings, startIndex); + return; + } + + if (canPreview(entry)) { + setConnecting(true); + try { + const conn = await connectGroup(groupId); + modalTransportRef.current = conn.transport; + modalGekRef.current = conn.gek; + setPreviewEntry(entry); + } catch { /* ignore */ } + setConnecting(false); + } + }, [allEntries, onPlayQueue, connectGroup]); + + const handleMusicPlay = useCallback((tracks, startIndex) => { + setVideoEntry(null); + if (onPlayQueue) onPlayQueue(tracks, startIndex); + }, [onPlayQueue]); + + const downloadForModal = useCallback(async (entry) => { + const transport = modalTransportRef.current; + if (!transport || !transport.connected) return; + await downloadEntry(transfers, transport, modalGekRef.current, entry); + }, []); + + // No-op setters for FilesPanel (read-only mode) + const noop = useCallback(() => {}, []); + + // -- Render -- + + const totalEntries = allEntries.length; + const hasResults = totalEntries > 0; + const defaultTRef = useRef(null); + const defaultGRef = useRef(null); + + return html` +
+

${t('search.title')}

+ + + + ${fetching && html` +
+ + ${t('search.indexing', { done: progress.done, total: progress.total })} +
+
+
+
+ `} + + ${!fetching && progress.unreachable.length > 0 && html` +

+ ${t('search.unreachable', { n: progress.unreachable.length })} +

+ `} + + ${viewMode === 'files' && hasResults && html` + <${FilesPanel} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${fileEntries} + nodeDirs=${fileNodeDirs} + nodeRoots=${[]} + setEntries=${noop} + setNodeDirs=${noop} + setNodeRoots=${noop} + applyIndex=${noop} + isNodeAdmin=${false} + operatorPaired=${false} + mayUpload=${false} + userId=${userId} + setError=${noop} + onPreview=${onPreview} + showGroup=${true} + readOnly=${true} /> + `} + + ${viewMode === 'videos' && hasResults && html` + <${VideoApp} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${videoEntries} + onPreview=${onPreview} + videoRoot=${SEARCH_VIDEO_ROOT} + tmdbConfig=${{ enabled: true }} + isNodeAdmin=${false} + hideFilter=${true} /> + `} + + ${viewMode === 'music' && hasResults && html` + <${MusicApp} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${musicEntries} + audioRoot=${SEARCH_AUDIO_ROOT} + musicbrainzConfig=${{ enabled: true }} + onPlayQueue=${handleMusicPlay} + hideFilter=${true} /> + `} + + ${!hasResults && !fetching && html` +

${t('search.hint')}

+ `} + + ${connecting && html` +
+ +
+ `} + + ${previewEntry && html` + <${FilePreview} + entry=${previewEntry} + transportRef=${modalTransportRef} + gekRef=${modalGekRef} + onClose=${() => setPreviewEntry(null)} + onDownload=${() => downloadForModal(previewEntry)} /> + `} + ${videoEntry && html` + <${VideoPlayer} + entry=${videoEntry} + transportRef=${modalTransportRef} + gekRef=${modalGekRef} + onClose=${() => setVideoEntry(null)} + onDownload=${() => downloadForModal(videoEntry)} /> + `} +
+ `; +} + +export { SearchPage, ConnectionPool }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 6f31047..fbd1e70 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -79,7 +79,98 @@ body { a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } -.search-synced { font-size: 0.72em; color: var(--text-dim); margin-top: 3px; } +/* ── Cross-group search page ──────────────────────────────────────────────── */ + +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-raised); + margin-bottom: 12px; +} +.search-bar .icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-dim); } +.search-bar input { + border: none; outline: none; background: transparent; + flex: 1; font-size: 0.95em; color: var(--text); + min-width: 0; +} +.search-bar input::placeholder { color: var(--text-dim); } +.search-bar:focus-within { border-color: var(--border-focus); } +.search-bar-clear { + background: none; border: none; padding: 2px; cursor: pointer; + color: var(--text-dim); display: flex; align-items: center; +} +.search-bar-clear:hover { color: var(--text); } +.search-bar-clear .icon { width: 14px; height: 14px; } + +.search-progress { + display: flex; align-items: center; gap: 8px; + font-size: 0.85em; color: var(--text-dim); margin-bottom: 12px; +} +.search-progress-bar { + flex: 1; height: 4px; background: var(--border); + border-radius: 2px; overflow: hidden; +} +.search-progress-fill { + height: 100%; background: var(--accent); + transition: width 0.3s; +} +.search-unreachable { + font-size: 0.8em; color: var(--text-dim); margin-bottom: 8px; +} + +.view-toggle { + display: flex; gap: 0; flex-shrink: 0; margin-left: auto; +} +.view-toggle button { + padding: 6px 12px; border: 1px solid var(--border); + background: transparent; color: var(--text-dim); + cursor: pointer; border-radius: 0; display: flex; + align-items: center; gap: 4px; font-size: 0.85em; +} +.view-toggle button:first-child { border-radius: 6px 0 0 6px; } +.view-toggle button:last-child { border-radius: 0 6px 6px 0; } +.view-toggle button + button { margin-left: -1px; } +.view-toggle button.active { + background: var(--accent-bg, rgba(59, 130, 246, 0.1)); + color: var(--accent); border-color: var(--accent); + z-index: 1; +} +.view-toggle .icon { width: 16px; height: 16px; } + +.search-media-list { + display: flex; flex-direction: column; gap: 2px; +} +.search-media-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: 6px; cursor: pointer; +} +.search-media-row:hover { background: var(--bg-raised); } +.search-media-icon { + flex-shrink: 0; width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: var(--bg-raised); border-radius: 4px; color: var(--text-dim); +} +.search-media-icon .icon { width: 18px; height: 18px; } +.search-media-info { flex: 1; min-width: 0; } +.search-media-title { + font-weight: 500; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; +} +.search-media-meta { + font-size: 0.8em; color: var(--text-dim); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.search-media-section { + font-size: 0.75em; color: var(--text-dim); + text-transform: uppercase; letter-spacing: 0.05em; + padding: 12px 10px 4px; +} +.search-media-row .badge { flex-shrink: 0; font-size: 0.78em; } +.search-media-show { opacity: 0.85; } /* ── Icons ────────────────────────────────────────────────────────────────── */ @@ -2770,6 +2861,19 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } text-overflow: ellipsis; white-space: nowrap; } +.video-card-group, +.music-card-group { + font-size: 0.72em; + color: var(--accent); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.search-group-badge { + margin-left: auto; + flex-shrink: 0; +} /* Detail modal — sits inside the existing .video-overlay */ 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`
<${Icon} name=${emptyIcon} />
`; return html`${alt`; @@ -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
${metaReady && html` <${MediaThumb} thumbHash=${posterHash} alt=${title} - cls="video-poster" transportRef=${transportRef} gekRef=${gekRef} + cls="video-poster" transportRef=${tRef} gekRef=${gRef} onReady=${handleImageReady} /> `}
@@ -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}` : ''}
+ ${repEntry.groupName && html` +
${repEntry.groupName}
+ `} `} @@ -534,7 +551,7 @@ function VideoDetailModal({ - `} ${filteredMovies.length === 0 && filteredShows.length === 0 && html`

${needle ? t('group.empty_filter') : t('video.empty')}

@@ -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 }; -- cgit v1.2.3