summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js175
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js59
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js39
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js560
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css106
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js46
19 files changed, 916 insertions, 250 deletions
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
</div>`;
}
-
-// ── 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`
- <div>
- <h2>${t('search.title')}</h2>
- <div class="file-toolbar" style="margin-bottom:16px">
- <input type="text" class="admin-search" style="width:100%"
- placeholder="${t('search.placeholder')}"
- value=${query} onInput=${onInput} autofocus />
- </div>
- ${searched && results.length === 0 && html`
- <p class="page-message">${t('search.no_results')}</p>
- `}
- ${results.length > 0 && html`
- <table class="file-table">
- <thead>
- <tr>
- <th></th>
- <th>${t('group.col_name')}</th>
- <th>${t('group.col_size')}</th>
- <th>${t('search.col_group')}</th>
- <th>${t('group.col_type')}</th>
- </tr>
- </thead>
- <tbody>
- ${results.map(r => html`
- <tr class="file-row" key=${r.id + r.groupId}>
- <td>${FILE_ICONS[r.type] || FILE_ICONS.other}</td>
- <td class="file-name">
- <a href="#/group/${r.groupId}" style="color:inherit">${r.name}</a>
- </td>
- <td class="file-size">${formatSize(r.size)}</td>
- <td>
- <a href="#/group/${r.groupId}" class="badge">${r.groupName
- ? html`<${GroupName} name=${r.groupName} owner=${r.groupOwner} inline=${true} />`
- : r.groupId.slice(0, 8)}</a>
- ${r.syncedAt && html`
- <div class="search-synced">
- ${t('search.synced', { ago: formatAgo(r.syncedAt) })}
- </div>
- `}
- </td>
- <td class="file-type">${r.type}</td>
- </tr>
- `)}
- </tbody>
- </table>
- <p class="settings-value" style="margin-top:8px">
- ${t('search.result_count', { n: results.length })}
- </p>
- `}
- ${!searched && html`
- <p class="page-message">${t('search.hint')}</p>
- `}
- <p class="settings-hint" style="margin-top:12px">${t('search.cache_note')}</p>
- </div>
- `;
-}
-
// ── 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}
</main>
</div>
+ ${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({
</div>
<div class="toolbar-group right">
- <div class="tb-search">
+ ${!readOnly && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
value=${filter} onInput=${e => setFilter(e.target.value)} />
- </div>
- <button class="tb-btn ${selecting ? 'active' : ''}"
+ </div>`}
+ ${!readOnly && html`<button class="tb-btn ${selecting ? 'active' : ''}"
onClick=${() => {
setSelecting(v => !v);
setSelected(new Set());
}}>
<${Icon} name=${selecting ? 'check' : 'checkbox'} />
${selecting ? t('group.select_done') : t('group.select')}
- </button>
+ </button>`}
${selecting && html`<div class="tb-actions">${actionItems}</div>`}
</div>
</div>
@@ -358,6 +359,7 @@ function FilesPanel({
<th class="sortable" onClick=${() => toggleSort('size')}>
${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
</th>
+ ${showGroup && html`<th>${t('search.col_group')}</th>`}
<th class="sortable th-type" onClick=${() => toggleSort('type')}>
${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
</th>
@@ -386,6 +388,7 @@ function FilesPanel({
<span class="root-offline"> ${t('group.root_unavailable')}</span>
` : ''}</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
+ ${showGroup && html`<td></td>`}
<td class="td-type"></td>
<td class="td-date"></td>
</tr>
@@ -411,12 +414,15 @@ function FilesPanel({
onClick=${() => { setFilter(''); setCurrentPath(e.path); }}>${e.path}</a>`}
</td>
<td class="file-size">${formatSize(e.size)}</td>
+ ${showGroup && html`<td>
+ <a href="#/group/${e.groupId}" class="badge">${e.groupName || ''}</a>
+ </td>`}
<td class="file-type td-type">${e.type}</td>
<td class="file-date td-date">${formatDate(e.added_at)}</td>
</tr>
`)}
${sorted.length === 0 && subdirs.length === 0 && html`
- <tr><td colspan=${selecting ? 6 : 5} class="file-empty">
+ <tr><td colspan=${(selecting ? 6 : 5) + (showGroup ? 1 : 0)} class="file-empty">
${filter ? t('group.empty_filter') : t('group.empty_dir')}
</td></tr>
`}
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)} />
- `}
</div>
`;
}
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`
<div class="music-card" onClick=${onOpen}>
${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" />`}
<div class="music-card-info">
<div class="music-card-title">${album.album}</div>
<div class="music-card-sub">${album.artist}</div>
+ ${repTrack.groupName && html`
+ <div class="music-card-group">${repTrack.groupName}</div>
+ `}
</div>
</div>
`;
@@ -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
<div class="music-detail-header">
${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" />`}
<div class="music-detail-meta">
<div class="music-detail-artist">${album.artist}</div>
@@ -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')}
</button>
- <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>
${empty && html`<p class="page-message">${t('music.empty')}</p>`}
${!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`
+ <div>
+ <h2>${t('search.title')}</h2>
+
+ <div class="search-bar">
+ <${Icon} name="search" />
+ <input type="text"
+ placeholder=${t('search.placeholder')}
+ value=${query}
+ onInput=${(e) => setQuery(e.target.value)}
+ autofocus />
+ ${query && html`
+ <button class="search-bar-clear" onClick=${() => setQuery('')}>
+ <${Icon} name="close" /></button>
+ `}
+ ${hasResults && html`
+ <div class="view-toggle">
+ <button class=${viewMode === 'files' ? 'active' : ''}
+ onClick=${() => setViewMode('files')}
+ title=${t('search.view_files')}>
+ <${Icon} name="folder" /></button>
+ <button class=${viewMode === 'videos' ? 'active' : ''}
+ onClick=${() => setViewMode('videos')}
+ title=${t('search.view_videos')}>
+ <${Icon} name="video" /></button>
+ <button class=${viewMode === 'music' ? 'active' : ''}
+ onClick=${() => setViewMode('music')}
+ title=${t('search.view_music')}>
+ <${Icon} name="music" /></button>
+ </div>
+ `}
+ </div>
+
+ ${fetching && html`
+ <div class="search-progress">
+ <span class="spinner"></span>
+ <span>${t('search.indexing', { done: progress.done, total: progress.total })}</span>
+ <div class="search-progress-bar">
+ <div class="search-progress-fill"
+ style="width:${progress.total ? Math.round(100 * progress.done / progress.total) : 0}%"></div>
+ </div>
+ </div>
+ `}
+
+ ${!fetching && progress.unreachable.length > 0 && html`
+ <p class="search-unreachable">
+ ${t('search.unreachable', { n: progress.unreachable.length })}
+ </p>
+ `}
+
+ ${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`
+ <p class="page-message">${t('search.hint')}</p>
+ `}
+
+ ${connecting && html`
+ <div class="video-overlay" style="background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center">
+ <span class="spinner"></span>
+ </div>
+ `}
+
+ ${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)} />
+ `}
+ </div>
+ `;
+}
+
+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`<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 };