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, hubFetch, ensureFreshToken, _loadBundleKey, } from './hub-client.js'; import { FilesPanel, FilePreview } from './files-app.js'; import { VideoApp, groupVideoEntries } from './video-app.js'; import { MusicApp, groupMusicEntries, foldKey } from './music-app.js'; import { PhotosApp, groupPhotoAlbums } from './photos-app.js'; import { VideoPlayer } from './video-player.js'; import { transfers } from './transfers.js'; import { mergeUnitEntries } from './source-merge.js'; import { useStickyBand } from './sticky.js'; // How many connections this page negotiates at once — a ceiling on concurrency, // never a batch (see `inFlight` below), and one ceiling for everything here: the // sweep, the warm-up and a tile asking for its group all go through // `ConnectionPool.connect`, which holds it. Three separate ceilings used to add // up behind each other's backs, past what the hub admits per account. // // Six, sized for twenty groups on a phone: a connection there takes about two // seconds, so three at a time is fourteen seconds to reach them all and six is // seven, and a node that is down holds one place in six rather than one in // three. The hub admits 32 pending offers per account (signaling.py), which // leaves room for this page on three devices at once plus their reconnections. const MAX_IN_FLIGHT = 6; // One WebRTC peer connection per group the search view touches. The cap bounds // how many a busy account (many groups on the hub) keeps open at once; groups // past it connect lazily when a tile of theirs scrolls into view (video-app.js's // LazyTile → onNeedConn). Was 3, which meant any account with more than three // groups thrashed the pool: an evicted transport left a stale `_tRef` on every // tile of that group, and MediaThumb / useMediaMeta gave up on it with no // retry — so posters rendered for a moment, then fell back to a spinner for // good. grenet (one shared group) never hit it; cbesson (many) always did. const MAX_POOL_SIZE = 12; const DEBOUNCE_MS = 200; // How long a connection attempt may make **no progress**, not how long it may // take. Search used a flat 10 s for the whole of `connect()`, which is the hub // round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the // DataChannel opening and the handshake's own round trips. Opening the same // group from the sidebar has no such deadline and gets the transport's own 30 s, // so a link slow enough to need twelve seconds — a phone on 4G — failed here and // worked from there, for the same work against the same node. // // A node that is not answering produces no progress event and still fails in // `SEARCH_STALL_MS`, which is what keeps the fan-out bounded: a dead group holds // one of the `MAX_IN_FLIGHT` places for this long, so the number that matters // for a page full of unreachable groups is this one. A hub that refuses an offer // for load *is* answering, and transport.js reports each retry as progress. // `SEARCH_MAX_MS` bounds the other case — a node that answers ICE and then stops // — because a deadline that only ever resets has none. const SEARCH_STALL_MS = 10000; const SEARCH_MAX_MS = 30000; const SEARCH_VIDEO_ROOT = '__search__'; const SEARCH_AUDIO_ROOT = '__search__'; const SEARCH_PHOTO_ROOTS = ['__search_photos__']; // -- Connecting to a group ---------------------------------------------------- /** * A live connection to one of the nodes serving `groupId`, and its ack — or * null when the hub lists no node at all. * * Every node the hub lists, in turn, exactly as group-page.js does since * 2026-09-11: the list is in hub registration order, and its head is not * necessarily a node that answers. Search took `nodes[0]` and stopped, so a * group with a second, working node counted as unreachable here while it * opened fine from the sidebar. A refusal naming a state of this browser (a * code, a passphrase, a device) is the same from every node and stops the * walk; `not_hosted`, a timeout or a failed connection moves on. */ async function connectToGroup(hubBase, 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; let lastErr = null; for (const n of nodesData.nodes) { const transport = new window.MeshBayTransport(hubBase, live); transport.onNeedToken = async () => (await ensureFreshToken()) || token; let stallTimer, capTimer; const stopTimers = () => { clearTimeout(stallTimer); clearTimeout(capTimer); transport.onConnectProgress = null; }; try { const ack = await Promise.race([ transport.connect( n.node_id, live, groupId, null, null, bundleKey, username, userId, null), new Promise((_, reject) => { const giveUp = () => reject(new Error('Connection timeout')); stallTimer = setTimeout(giveUp, SEARCH_STALL_MS); capTimer = setTimeout(giveUp, SEARCH_MAX_MS); // Each step the transport reports — the peer answering ICE, the // channel opening — buys another window, never more than the cap. transport.onConnectProgress = () => { clearTimeout(stallTimer); stallTimer = setTimeout(giveUp, SEARCH_STALL_MS); }; }), ]); stopTimers(); return { transport, ack }; } catch (e) { stopTimers(); lastErr = e; try { transport.close(); } catch {} if (e.reason && e.reason !== 'not_hosted') throw e; } } throw (lastErr || new Error('no node served this group')); } // -- Connection pool ---------------------------------------------------------- /** * Every connection this page makes, and the only way it makes one. * * The sweep, the warm-up and a tile asking for its group all come here, so the * page has one ceiling on how many it negotiates at once (`MAX_IN_FLIGHT`) and * one connection per group. The sweep used to open its own, fetch the index, * close it, and leave the warm-up to open the same group again straight after: * two offers per group per visit, and on 4G the two overlapped, which is how a * phone ran into the hub's per-account ceilings with five groups. */ class ConnectionPool { constructor(hubBase, onEvict) { this._hubBase = hubBase; this._connections = new Map(); this._connecting = new Map(); // Called with a groupId whenever this pool closes that group's connection // (eviction, close or closeAll). SearchPage uses it to drop its own record // so it never hands a tile a `_tRef` pointing at a transport closed here. this._onEvict = onEvict || (() => {}); // Negotiations under way, and those waiting for one of the places. this._active = 0; this._waiting = []; this._closed = false; } get size() { return this._connections.size; } get closed() { return this._closed; } /** Whether `groupId` has a connection here that using costs no offer. */ has(groupId) { const conn = this._connections.get(groupId); return !!(conn && conn.transport.connected); } /** * The group's connection, negotiating one if there is none. * * `hold` keeps it from being evicted until `release` — the sweep holds each * group while its index is on the way, because with more groups than the pool * keeps, the connection it is reading from would otherwise be the oldest one * and closed under it. */ async connect(groupId, token, bundleKey, username, userId, { hold = false } = {}) { if (this._closed) throw new Error('Search was closed'); let conn = this._connections.get(groupId); if (!(conn && conn.transport.connected)) { let p = this._connecting.get(groupId); if (!p) { p = this._negotiate(groupId, token, bundleKey, username, userId); this._connecting.set(groupId, p); p.finally(() => this._connecting.delete(groupId)).catch(() => {}); } conn = await p; } conn.lastUsed = Date.now(); if (hold) conn.holds += 1; this._evict(); return conn; } release(conn) { conn.holds = Math.max(0, conn.holds - 1); this._evict(); } /** Drop one group's connection, e.g. one that stopped answering. */ close(groupId) { const conn = this._connections.get(groupId); if (!conn) return; try { conn.transport.close(); } catch {} this._connections.delete(groupId); this._onEvict(groupId); } async _negotiate(groupId, token, bundleKey, username, userId) { await this._takePlace(); let conn; try { // Waiting for a place is not part of any deadline: `connectToGroup`'s // timers start inside `_doConnect`, once this negotiation is really on. if (this._closed) throw new Error('Search was closed'); conn = await this._doConnect(groupId, token, bundleKey, username, userId); } finally { this._givePlace(); } if (this._closed) { try { conn.transport.close(); } catch {} throw new Error('Search was closed'); } // A connection that dropped is replaced, and closed so its own reconnect // loop does not keep negotiating a second one for the same group. const stale = this._connections.get(groupId); if (stale && stale.transport !== conn.transport) { try { stale.transport.close(); } catch {} } this._connections.set(groupId, conn); return conn; } _takePlace() { if (this._active < MAX_IN_FLIGHT) { this._active += 1; return Promise.resolve(); } return new Promise((resolve) => this._waiting.push(resolve)); } _givePlace() { const next = this._waiting.shift(); if (next) next(); // handed straight over: `_active` is unchanged else this._active -= 1; } async _doConnect(groupId, token, bundleKey, username, userId) { const found = await connectToGroup( this._hubBase, groupId, token, bundleKey, username, userId); if (!found) throw new Error('offline'); const { transport, ack } = found; let gek = null; if (transport.gekRaw && window.MeshBayCrypto) { gek = await window.MeshBayCrypto.importGEK( window.MeshBayCrypto.b64encode(transport.gekRaw)); } return { transport, gek, ack, lastUsed: Date.now(), holds: 0 }; } _evict() { while (this._connections.size > MAX_POOL_SIZE) { let oldestId = null, oldestTime = Infinity; for (const [id, conn] of this._connections) { if (conn.holds > 0) continue; if (conn.lastUsed < oldestTime) { oldestTime = conn.lastUsed; oldestId = id; } } // Everything over the size is held: it goes when it is released. if (!oldestId) break; this.close(oldestId); } } closeAll() { // In-flight negotiations see this when they finish and close what they got. this._closed = true; for (const [id, conn] of this._connections) { try { conn.transport.close(); } catch {} this._onEvict(id); } this._connections.clear(); } } // -- Index fetching ----------------------------------------------------------- // How long a connection the pool already holds gets to prove it is alive before // its index is asked for. A phone that slept keeps reporting `connected` on a // channel that is gone, and an index request on it would wait thirty seconds. const REUSE_PING_MS = 4000; /** * One group's index, over the pool's connection to it — which the page then * keeps for its tiles, so reaching a group costs one offer and not two. */ async function fetchGroupIndex(pool, groupId, token, bundleKey, username, userId) { if (pool.has(groupId)) { const held = await pool.connect(groupId, token, bundleKey, username, userId); try { await held.transport.ping(REUSE_PING_MS); } catch { pool.close(groupId); } } const conn = await pool.connect( groupId, token, bundleKey, username, userId, { hold: true }); const { transport, ack } = conn; let unlisted = false; try { // The operator asked for this group to stay out of the global listing. // Decided here, before the index is asked for, so nothing of it is held, // cached or merged by this page. A listing preference and not a boundary: // the node cannot tell this request from the group page's, and opening the // group lists everything. if (ack.search_listed === false) { unlisted = true; return { unlisted: true }; } const indexMsg = await transport.fetchIndex(); // Plural, with the old scalars as the fallback for a node still speaking // MNP 1.0 — the same reading group-page.js does on its own handshake. const roots = { video: ack.video_directories || [], music: ack.music_directories || [], photo: ack.photo_directories || [], }; // Which of the reader's groups sit on their own node — the tie-breaker // when the same file is announced by several of them // (docs/MESHBAY_DESIGN.md §9.11). Computed by the node from its own // record of who it belongs to (webrtc/admin.py's _is_node_admin), never // from a hub claim, and deliberately not written to the index cache: it // describes this connection, not the group's content. return { entries: indexMsg.entries || [], roots, isNodeAdmin: !!ack.is_node_admin }; } finally { pool.release(conn); // Nothing of an unlisted group is shown, so nothing will use its connection. if (unlisted) pool.close(groupId); } } /** * Run `fn` over `items`, `n` at a time, starting the next the moment one ends. * * Deliberately not batches. A batch waits for its slowest member before the * next one starts, and a group whose node is down is always the slowest member * there is: it costs the full connection deadline. Measured on twelve groups * with four of them down — the shape a reader really has — batches of three * showed the first result after ten seconds and finished after forty, because * every dead group stalled its own batch and postponed all the ones behind it. * A dead group here occupies one of `n` places and holds up nothing else. */ async function inFlight(items, n, fn) { let next = 0; const worker = async () => { while (next < items.length) { const i = next; next += 1; await fn(items[i], i); } }; await Promise.all( Array.from({ length: Math.min(n, items.length) }, worker)); } // Which groups did not answer last time, so they can be dialled last. // // A ceiling of three still leaves one bad case: if the first three groups the // hub lists are all down, all three places are held for the full deadline and // nothing else starts. A node that is off tends to stay off, so the browser // remembers and puts the known-silent ones at the back — where their deadline // runs beside results that are already on screen instead of in front of them. // // Per browser and advisory: unreadable storage, a private window, a first visit // or a stale list all degrade to the hub's own order, which is the behaviour // without this. It is rewritten after every sweep, so a node coming back costs // one sweep of being last and then returns to its place. const DOWN_KEY = 'meshbay.search.down'; function lastKnownDown() { try { return new Set(JSON.parse(localStorage.getItem(DOWN_KEY) || '[]')); } catch { return new Set(); } } function rememberDown(ids) { try { localStorage.setItem(DOWN_KEY, JSON.stringify([...ids])); } catch { /* private window, or storage refused: the order is advisory */ } } async function fetchAllIndexes(pool, groups, token, username, userId, onProgress, onResult) { const bundleKey = session.bundleKey || await _loadBundleKey(); if (bundleKey) session.bundleKey = bundleKey; const total = groups.length; let done = 0; const unreachable = []; const downNow = new Set(); const results = new Map(); // Stable, so groups that answered keep the hub's order among themselves. const wasDown = lastKnownDown(); const queue = [...groups].sort( (a, b) => (wasDown.has(a.id) ? 1 : 0) - (wasDown.has(b.id) ? 1 : 0)); await inFlight(queue, MAX_IN_FLIGHT, async (g) => { try { const result = await fetchGroupIndex(pool, g.id, token, bundleKey, username, userId); if (result && result.unlisted) { // Left out of Search by its operator: not shown, and not down either. } else if (result) { results.set(g.id, { ...result, groupName: g.name, groupOwner: g.owner_username, }); // Drawn now, not when this group's neighbours are done. Its index is // already in hand; holding it back until a group that is not answering // has finished not answering is ten seconds of blank page for work that // completed in two hundred milliseconds. onResult(results); } else { unreachable.push(g.name || g.id); downNow.add(g.id); } } catch (e) { unreachable.push(g.name || g.id); // Still refused after transport.js's retries: the hub was busy, which // says nothing about this group's node, so it keeps its place next time. if (!(e && e.status === 429)) downNow.add(g.id); } done++; onProgress({ done, total, unreachable: [...unreachable] }); }); // A sweep cut short by the page closing saw nothing about any node: every // group left was refused by this page, not by its node. if (!(pool && pool.closed)) rememberDown(downNow); return { results, unreachable }; } // -- SearchPage --------------------------------------------------------------- function underRoot(entry, directories) { const dirs = directories || []; if (!dirs.length) return false; const p = entry.path || ''; return dirs.some((d) => p === d || p.startsWith(d + '/')); } /** * An app's directories out of a cached group, in either shape. * * The cache lives in IndexedDB and outlives a deploy, so a reader opening * Search after this ships still has entries written by the previous version — * `{videoRoot: 'X'}` where this now writes `{video: ['X']}`. Reading only the * new shape would empty their Videos results with no explanation and no way * to tell it from "nothing matched". */ function cachedDirs(roots, appKey, legacyKey) { if (!roots) return []; const fresh = roots[appKey]; if (Array.isArray(fresh)) return fresh; const legacy = roots[legacyKey]; if (Array.isArray(legacy)) return legacy; return legacy ? [legacy] : []; } // -- Merging the same file announced by several groups ------------------------ // // A directory shared by two groups — the reason two groups exist at all: // different people invited to different libraries — arrived here as two // entries per file, so a film showed as two poster cards and every episode // twice inside a show. `source-merge.js` folds them on the content hash and // resolves one source per *unit*. See docs/MESHBAY_DESIGN.md §9.11. // // The units come from video-app.js's own `groupVideoEntries`, never from a // second copy of its keys here: a copy would keep agreeing with the original // right up until one of them changed, and the symptom would be a show whose // episodes stream from two different nodes. Running it twice per recompute (it // runs again inside VideoApp) is a linear pass over an index already in memory // and already re-walked on every keystroke of the filter. // // One naive unit per copy of a film rather than a pre-grouped one: // `mergeUnitEntries` folds lists that share a key, so the two copies become // one unit without this having to group them first. function videoUnits(entries) { const { movies, shows } = groupVideoEntries(entries, [SEARCH_VIDEO_ROOT]); return [ ...movies.map((e) => ({ key: `movie:${e.id}`, entries: [e] })), ...shows.map((s) => ({ key: `show:${s.title}`, entries: s.episodes })), ]; } // An album is a unit, and so is a track loose enough to have no artist at all. // `groupMusicEntries` has already folded the two groups' copies into one album // object, so the key only has to name it stably — hence `foldKey` over the // display strings, which are whichever spelling arrived first. function musicUnits(entries) { const { tracks, albums } = groupMusicEntries(entries, [SEARCH_AUDIO_ROOT]); return [ ...albums.map((a) => ({ key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`, entries: a.tracks, })), ...tracks.map((e) => ({ key: `track:${e.id}`, entries: [e] })), ]; } // A photo album is its directory, which is already prefixed per group when the // entries are built — so two groups whose roots have different basenames stay // two albums, and the same photo belongs in both. Only same-named albums // collapse, which is the reported shape. function photoUnits(entries) { return groupPhotoAlbums(entries, SEARCH_PHOTO_ROOTS) .map((a) => ({ key: `album:${a.dir}`, entries: a.photos })); } 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); // Bumped by the Files breadcrumb refresh button — re-runs the all-groups // index fetch below, the only "cache" this page has. const [refreshTick, setRefreshTick] = useState(0); 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()); // groupId -> how many times its connection has been (re)established. Threaded // into each entry as `_connGen` and used by video-app.js's tiles as a refetch // key, so a group's posters/thumbnails recover the moment it reconnects // (after a pool eviction) instead of staying stuck on a spinner. const connGenRef = useRef(new Map()); // groupIds whose connection failed — see markGroupDown below. const downGroups = useRef(new Set()); const mountedRef = useRef(true); const [connectionGen, setConnectionGen] = useState(0); const debounceRef = useRef(null); const [debouncedQuery, setDebouncedQuery] = useState(''); useEffect(() => { mountedRef.current = true; poolRef.current = new ConnectionPool(HUB, (evictedId) => { groupConns.current.delete(evictedId); // Rebuild the entry lists so tiles of the evicted group fall back to a // null `_tRef` (and pick a live one up again once reconnected). if (mountedRef.current) setConnectionGen((g) => g + 1); }); return () => { mountedRef.current = false; if (poolRef.current) poolRef.current.closeAll(); }; }, []); useEffect(() => { 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: [] }); if (!poolRef.current) return; await fetchAllIndexes( poolRef.current, 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, refreshTick]); // -- Connection management -- // A group whose connection failed stops being chosen as a merged entry's // source, so a unit fails over to another group that has the file // (docs/MESHBAY_DESIGN.md §9.11). Without this the merge could make a // file *less* available than it was before it, which would be a regression // dressed as a feature. // // Held in a ref and read live by `mergeOpts.isDown`; what actually rebuilds // the entry lists is the `connectionGen` bump, which they already depend on. // Eviction is deliberately not a failure — `_onEvict` only drops the recorded // connection, and the group is picked again as readily as before. const markGroupDown = useCallback((groupId, down) => { let changed; if (down) { changed = !downGroups.current.has(groupId); downGroups.current.add(groupId); } else { changed = downGroups.current.delete(groupId); } if (changed && mountedRef.current) setConnectionGen((g) => g + 1); }, []); 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(); let conn; try { conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId); } catch (e) { markGroupDown(groupId, true); throw e; } markGroupDown(groupId, false); // A concurrent caller for the same group (several tiles mounting at once) // may already have recorded this exact transport while we awaited. Only // treat it as new — bump the per-group generation, force a rebuild — when // it genuinely is, so a group's tiles refetch once per (re)connect rather // than once per mounting tile. const existing = groupConns.current.get(groupId); if (existing && existing.transport === conn.transport) { existing.lastUsed = Date.now(); return existing; } const gen = (connGenRef.current.get(groupId) || 0) + 1; connGenRef.current.set(groupId, gen); const entry = { transport: conn.transport, gek: conn.gek, gen, tRef: { current: conn.transport }, gRef: { current: conn.gek }, }; groupConns.current.set(groupId, entry); // No bumpMediaMetaGeneration() / bumpThumbGeneration() here: a fresh // transport does not invalidate metadata another group already resolved, // and firing the module-wide reset on every connect is what made the whole // grid flicker through the pre-connect walk. Recovery for *this* group's // tiles comes from `_connGen` (threaded into its entries) instead; the // module-wide bumps stay for an operator's TMDB override/rematch only. setConnectionGen((g) => g + 1); return entry; }, [token, username, userId, markGroupDown]); // Hand the tiles their connections as soon as indexing finishes, so // thumbnails start loading before the user switches views. The sweep left // its connections in the pool, so for those this costs no offer at all; a // group is dialled here only when its connection has gone since and the pool // has room — never to evict one that is already open, which would be an offer // spent to lose another. Groups past the pool's size connect lazily when a // tile of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps // fetched thumbnails across evictions. useEffect(() => { if (fetching || indexedGroups.size === 0) return; let cancelled = false; (async () => { const pool = poolRef.current; if (!pool) return; const ids = [...indexedGroups.keys()]; const open = ids.filter((gid) => pool.has(gid)); for (const gid of open) { if (cancelled) return; try { await connectGroup(gid); } catch { /* skip */ } } const room = Math.max(0, MAX_POOL_SIZE - pool.size); const closed = ids.filter((gid) => !pool.has(gid)).slice(0, room); await inFlight(closed, MAX_IN_FLIGHT, async (gid) => { if (cancelled) return; try { await connectGroup(gid); } catch { /* skip */ } }); })(); return () => { cancelled = true; }; }, [fetching, 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, _connGen: conn ? conn.gen : 0, }); } } return result; }, [indexedGroups, q, matchesQuery, connectionGen]); const fileNodeDirs = useMemo(() => { const dirs = []; for (const [, data] of indexedGroups) dirs.push(data.groupName); return dirs; }, [indexedGroups]); // How a unit's source is chosen, shared by every merged view // (docs/MESHBAY_DESIGN.md §9.11). `isLocal` reads the flag the node itself // put in the handshake ack — computed from its own record of who it belongs // to (webrtc/admin.py's `_is_node_admin`), never from a hub claim. const mergeOpts = useMemo(() => ({ salt: userId || '', isLocal: (gid) => { const data = indexedGroups.get(gid); return !!(data && data.isNodeAdmin); }, // Read live off the ref rather than captured: what rebuilds the lists is // the `connectionGen` bump markGroupDown fires, and they already depend on // it. Putting the set in this memo's own dependencies would only add a // second reason to rebuild the same thing. isDown: (gid) => downGroups.current.has(gid), }), [indexedGroups, userId]); // Videos view: pre-filtered by videoRoot, path-prefixed, then merged so a // file several groups share is one card and one list entry. const videoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const dirs = cachedDirs(data.roots, 'video', 'videoRoot'); if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'video') continue; if (!underRoot(e, dirs)) 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, _connGen: conn ? conn.gen : 0, }); } } return mergeUnitEntries(videoUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // Music view: pre-filtered by audioRoot, path-prefixed, then merged per album const musicEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const dirs = cachedDirs(data.roots, 'music', 'audioRoot'); if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'audio') continue; if (!underRoot(e, dirs)) 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, _connGen: conn ? conn.gen : 0, }); } } return mergeUnitEntries(musicUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // Photos view: pre-filtered by photoRoots, path-prefixed, then merged per album const photoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const dirs = cachedDirs(data.roots, 'photo', 'photoRoots'); if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'image') continue; const p = e.path || ''; if (!dirs.some((d) => p === d || p.startsWith(d + '/'))) continue; if (q && !matchesQuery(e)) continue; result.push({ ...e, path: '__search_photos__/' + e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, _tRef: conn ? conn.tRef : null, _gRef: conn ? conn.gRef : null, _connGen: conn ? conn.gen : 0, }); } } return mergeUnitEntries(photoUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // -- 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]); // Same as group-page.js's wrapper: `op` must be named to survive the hop. const handleMusicPlay = useCallback((tracks, startIndex, op) => { if (!op || op === 'replace') setVideoEntry(null); if (onPlayQueue) onPlayQueue(tracks, startIndex, null, op); }, [onPlayQueue]); const downloadForModal = useCallback(async (entry) => { const transport = modalTransportRef.current; if (!transport || !transport.connected) return; await downloadEntry(transfers, transport, modalGekRef.current, entry); }, []); const getTransport = useCallback(async (entry) => { const conn = await connectGroup(entry.groupId); return { transport: conn.transport, gek: conn.gek }; }, [connectGroup]); // No-op setters for FilesPanel const noop = useCallback(() => {}, []); // The search field and its view toggle are this page's equivalent of a // group's tab bar: the same band, pinned the same way, publishing the same // property for the toolbar underneath (style.css, "Sticky chrome"). const searchBand = useStickyBand('--chrome-h'); // -- Render -- const totalEntries = allEntries.length; const hasResults = totalEntries > 0; const defaultTRef = useRef(null); const defaultGRef = useRef(null); return 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} userId=${userId} setError=${noop} onPreview=${onPreview} showGroup=${true} readOnly=${true} getTransport=${getTransport} showRefresh=${true} onRefreshIndex=${() => setRefreshTick((n) => n + 1)} /> `} ${viewMode === 'videos' && hasResults && html` <${VideoApp} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${videoEntries} onPreview=${onPreview} videoDirectories=${[SEARCH_VIDEO_ROOT]} tmdbConfig=${{ enabled: true }} isNodeAdmin=${false} onNeedConn=${connectGroup} userPrefs=${userPrefs} pageResetKey=${q} hideFilter=${true} /> `} ${viewMode === 'music' && hasResults && html` <${MusicApp} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${musicEntries} musicDirectories=${[SEARCH_AUDIO_ROOT]} musicbrainzConfig=${{ enabled: true }} onPlayQueue=${handleMusicPlay} userId=${userId} userPrefs=${userPrefs} pageResetKey=${q} hideFilter=${true} /> `} ${viewMode === 'photos' && hasResults && html` <${PhotosApp} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${photoEntries} photoDirectories=${SEARCH_PHOTO_ROOTS} setError=${noop} hideFilter=${true} readOnly=${true} /> `} ${!hasResults && !fetching && html` `} ${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)} /> `}