1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
|
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`
<div class="sticky-chrome">
<div class="search-bar" ref=${searchBand}>
<${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>
<button class=${viewMode === 'photos' ? 'active' : ''}
onClick=${() => setViewMode('photos')}
title=${t('search.view_photos')}>
<${Icon} name="image" /></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>
`}
${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}
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`
<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 };
|