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, _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'; import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js'; const DEBOUNCE_MS = 200; const SEARCH_VIDEO_ROOT = '__search__'; const SEARCH_AUDIO_ROOT = '__search__'; const SEARCH_PHOTO_ROOTS = ['__search_photos__']; // -- 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`
${fetching && html`
${t('search.indexing', { done: progress.done, total: progress.total })}
`} ${progress.unreachable.length > 0 && html`

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

`} ${viewMode === 'files' && hasResults && html` <${FilesPanel} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${fileEntries} nodeDirs=${fileNodeDirs} nodeRoots=${[]} setEntries=${noop} setNodeDirs=${noop} setNodeRoots=${noop} applyIndex=${noop} isNodeAdmin=${false} operatorPaired=${false} 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`

${t('search.hint')}

`} ${connecting && html`
`} ${previewEntry && html` <${FilePreview} entry=${previewEntry} transportRef=${modalTransportRef} gekRef=${modalGekRef} onClose=${() => setPreviewEntry(null)} onDownload=${() => downloadForModal(previewEntry)} /> `} ${videoEntry && html` <${VideoPlayer} entry=${videoEntry} transportRef=${modalTransportRef} gekRef=${modalGekRef} onClose=${() => setVideoEntry(null)} onDownload=${() => downloadForModal(videoEntry)} /> `}
`; } export { SearchPage };