aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 10:25:44 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 10:25:44 +0200
commit950f4aa6d107e127fb6dc0579beedbff0e6f1af5 (patch)
tree9891a96220afac04ea06a650aa2f75a951385b94
parentf1ed3e6be452f6211cee8db1afef8b56473a04db (diff)
downloadmeshbay-950f4aa6d107e127fb6dc0579beedbff0e6f1af5.tar.gz
refactor(client): move the connection pool and the media tiles to modules
ConnectionPool (with connectToGroup and its limits) leaves search-page.js for connection-pool.js, and LazyTile/MediaThumb leave video-app.js for media-tiles.js, cut as text. The shell and the Music and Photos apps now reach them without importing the search page or the Videos app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js260
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js119
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js255
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js118
7 files changed, 392 insertions, 369 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a757e61..856a774 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -20,7 +20,8 @@ import {
} from './hub-client.js';
import { startIdleWatch, markActive } from './idle.js';
import { GroupPage } from './group-page.js';
-import { SearchPage, ConnectionPool } from './search-page.js';
+import { SearchPage } from './search-page.js';
+import { ConnectionPool } from './connection-pool.js';
import { MusicPlayerBar } from './music-player.js';
import { NameModal } from './playlist-menu.js';
import {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
new file mode 100644
index 0000000..aac8225
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
@@ -0,0 +1,260 @@
+// The connections to groups' nodes that search opens — and the music queue,
+// which plays across groups — one per group, with one ceiling on how many are
+// negotiated at once. A module of its own so the shell can hold a pool without
+// loading the search page.
+
+import { hubFetch, ensureFreshToken } from './hub-client.js';
+
+// How many connections search negotiates at once — a ceiling on concurrency,
+// never a batch (see `inFlight` in search-page.js), and one ceiling for everything: 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 the search 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 (media-tiles.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;
+
+// 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;
+
+// -- 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 the search 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();
+ }
+}
+
+export { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js b/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js
new file mode 100644
index 0000000..f56ec9a
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/media-tiles.js
@@ -0,0 +1,119 @@
+// Tiles the media apps share: a slot that mounts its content only once it
+// scrolls near (virtualization), and a thumbnail decrypted through the chunk
+// path. Videos, Music and Photos all use them; a module of their own so that
+// opening one app does not load another.
+
+import { html, useState, useEffect, useRef } from './vendor/htm-preact.js';
+import { Icon } from './icon.js';
+import { pipelinedDownload } from './file-utils.js';
+
+// ── lazy-mount tile (virtualization) ───────────────────────────────────────
+
+const LAZY_TILE_MARGIN = 300;
+
+function LazyTile({ cls = 'video-tile-slot', children }) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (visible || !ref.current) return;
+ // A tile that is already on screen (or within the margin) the moment
+ // it mounts — the overwhelmingly common case, since a merge (§V6) or a
+ // tab revisit mounts tiles into a grid that was already scrolled to
+ // wherever the operator was looking — doesn't need to wait for
+ // IntersectionObserver's own first callback at all: that first
+ // delivery is only a *microtask/next-paint* guarantee, not an
+ // immediate one, and was observed live taking upwards of 30 seconds
+ // (matching the browser's own periodic intersection-computation
+ // cadence exactly) — which read as "the poster never finishes
+ // loading" even though every fetch behind it had already completed.
+ // Checked synchronously so a genuinely below-the-fold tile still only
+ // mounts once actually scrolled near.
+ const rect = ref.current.getBoundingClientRect();
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN;
+ if (alreadyNear) { setVisible(true); return; }
+ const obs = new IntersectionObserver((obsEntries) => {
+ if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); }
+ }, { rootMargin: `${LAZY_TILE_MARGIN}px` });
+ obs.observe(ref.current);
+ return () => obs.disconnect();
+ }, [visible]);
+
+ return html`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`;
+}
+
+// ── thumbnail/poster image, decrypted via the chunk path ────────────────────
+//
+// Cached per session by thumb_hash (a content hash, so it never goes stale):
+// the same poster reused across a season's worth of episode tiles is
+// decrypted once, not once per tile. Blob URLs are not revoked — the number
+// of distinct thumbnails one session ever visits is bounded by the library
+// size, and reference-counting revocation across many tile mounts/unmounts
+// would cost real complexity for a benefit that only matters in a very long
+// session.
+const _thumbBlobCache = new Map();
+
+function MediaThumb({
+ thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, emptyIcon = 'video',
+ reloadKey,
+}) {
+ const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
+ const [retryToken, setRetryToken] = useState(0);
+
+ useEffect(() => {
+ const listener = () => setRetryToken((n) => n + 1);
+ _thumbRetryListeners.add(listener);
+ return () => _thumbRetryListeners.delete(listener);
+ }, []);
+
+ // Re-checks the cache by the CURRENT thumbHash on every change rather than
+ // trusting the `blobUrl` state variable — a PosterCard swaps this same
+ // component instance's thumbHash prop from the raw fallback frame to the
+ // TMDB poster once metadata resolves, and gating on "is blobUrl already
+ // set" (from the *previous* hash) would leave the fallback frame on
+ // screen forever instead of ever fetching the poster.
+ //
+ // `onReady` fires exactly once per settled thumbHash — cache hit, fetch
+ // success, fetch failure, or no hash at all — so a caller that hides this
+ // component until its image has actually arrived (PosterCard) always
+ // gets unstuck, even when there's nothing to show.
+ useEffect(() => {
+ const cached = _thumbBlobCache.get(thumbHash);
+ if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; }
+ setBlobUrl(null);
+ if (!thumbHash) { if (onReady) onReady(null); return; }
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) { if (onReady) onReady(null); return; }
+ try {
+ const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1);
+ if (cancelled) return;
+ const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' }));
+ _thumbBlobCache.set(thumbHash, url);
+ setBlobUrl(url);
+ if (onReady) onReady(url);
+ } catch {
+ /* leave the placeholder — a transient fetch failure isn't an error state */
+ if (!cancelled && onReady) onReady(null);
+ }
+ })();
+ return () => { cancelled = true; };
+ // `reloadKey` — bumped by the caller when the transport behind `transportRef`
+ // was replaced (a search-page pool reconnect). Without it, a thumb whose
+ // first fetch hit a not-yet-connected transport and bailed above would stay
+ // an empty placeholder for good, since neither `thumbHash` nor `retryToken`
+ // changes when only the connection does.
+ }, [thumbHash, retryToken, reloadKey]);
+
+ if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name=${emptyIcon} /></div>`;
+ return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`;
+}
+
+const _thumbRetryListeners = new Set();
+function bumpThumbGeneration() {
+ for (const fn of _thumbRetryListeners) fn();
+}
+
+export { LazyTile, MediaThumb, bumpThumbGeneration };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index 764331a..67be344 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -3,7 +3,7 @@ import {
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
-import { MediaThumb, LazyTile } from './video-app.js';
+import { MediaThumb, LazyTile } from './media-tiles.js';
import { formatTime } from './music-player.js';
import { SourceTag } from './group-name.js';
import { usePager, Pager, pageSizeFrom } from './pager.js';
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index 261f2af..73b8c15 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -7,7 +7,7 @@ import {
formatSize, CHUNK_SIZE, pipelinedDownload, downloadDirectory,
} from './file-utils.js';
import { transfers } from './transfers.js';
-import { MediaThumb, LazyTile } from './video-app.js';
+import { MediaThumb, LazyTile } from './media-tiles.js';
import { SourceTag } from './group-name.js';
// ── Photos ───────────────────────────────────────────────────────────────────
@@ -129,7 +129,7 @@ function PhotoTile({ entry, transportRef, gekRef, onOpen }) {
// ── lightbox: full image, next/previous, per-photo info, zoom ──────────────
//
-// Cached per session by file id, same shape as video-app.js's
+// Cached per session by file id, same shape as media-tiles.js's
// _thumbBlobCache — clicking back and forth between two photos decrypts
// each once, not once per visit.
const _fullBlobCache = new Map();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index ba2ae63..874b7b0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -5,7 +5,7 @@ import { t } from './i18n.js';
import { Icon } from './icon.js';
import { canPreview, downloadEntry } from './file-utils.js';
import {
- HUB, session, hubFetch, ensureFreshToken, _loadBundleKey,
+ HUB, session, _loadBundleKey,
} from './hub-client.js';
import { FilesPanel, FilePreview } from './files-app.js';
import { VideoApp, groupVideoEntries } from './video-app.js';
@@ -15,262 +15,13 @@ 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';
-// 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
@@ -1051,4 +802,4 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
`;
}
-export { SearchPage, ConnectionPool };
+export { SearchPage };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 5f7e9ea..124033e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -3,9 +3,10 @@ import {
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
-import { formatSize, pipelinedDownload } from './file-utils.js';
+import { formatSize } from './file-utils.js';
import { SourceTag } from './group-name.js';
import { usePager, Pager, pageSizeFrom } from './pager.js';
+import { LazyTile, MediaThumb } from './media-tiles.js';
// ── Videos ───────────────────────────────────────────────────────────────────
//
@@ -17,9 +18,9 @@ import { usePager, Pager, pageSizeFrom } from './pager.js';
// deep a show folder sits, which display_title already settled once.
//
// TMDB metadata is fetched lazily, only for a tile once it is actually
-// visible (LazyTile below) — the virtualization requirement for a grid of
+// visible (LazyTile, media-tiles.js) — the virtualization requirement for a grid of
// many tiles. Thumbnails go through the same `file_req`/chunk path
-// as a real file (docs/MESHBAY_DESIGN.md §6.5) via MediaThumb, reusing
+// as a real file (docs/MESHBAY_DESIGN.md §6.5) via MediaThumb (media-tiles.js), reusing
// chat-app.js's ChatImage pattern.
const VIEW_MODE_KEY = 'meshbay_video_view_mode';
@@ -121,110 +122,6 @@ function groupVideoEntries(entries, videoDirectories) {
return { movies, shows };
}
-// ── lazy-mount tile (virtualization) ───────────────────────────────────────
-
-const LAZY_TILE_MARGIN = 300;
-
-function LazyTile({ cls = 'video-tile-slot', children }) {
- const ref = useRef(null);
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- if (visible || !ref.current) return;
- // A tile that is already on screen (or within the margin) the moment
- // it mounts — the overwhelmingly common case, since a merge (§V6) or a
- // tab revisit mounts tiles into a grid that was already scrolled to
- // wherever the operator was looking — doesn't need to wait for
- // IntersectionObserver's own first callback at all: that first
- // delivery is only a *microtask/next-paint* guarantee, not an
- // immediate one, and was observed live taking upwards of 30 seconds
- // (matching the browser's own periodic intersection-computation
- // cadence exactly) — which read as "the poster never finishes
- // loading" even though every fetch behind it had already completed.
- // Checked synchronously so a genuinely below-the-fold tile still only
- // mounts once actually scrolled near.
- const rect = ref.current.getBoundingClientRect();
- const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
- const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN;
- if (alreadyNear) { setVisible(true); return; }
- const obs = new IntersectionObserver((obsEntries) => {
- if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); }
- }, { rootMargin: `${LAZY_TILE_MARGIN}px` });
- obs.observe(ref.current);
- return () => obs.disconnect();
- }, [visible]);
-
- return html`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`;
-}
-
-// ── thumbnail/poster image, decrypted via the chunk path ────────────────────
-//
-// Cached per session by thumb_hash (a content hash, so it never goes stale):
-// the same poster reused across a season's worth of episode tiles is
-// decrypted once, not once per tile. Blob URLs are not revoked — the number
-// of distinct thumbnails one session ever visits is bounded by the library
-// size, and reference-counting revocation across many tile mounts/unmounts
-// would cost real complexity for a benefit that only matters in a very long
-// session.
-const _thumbBlobCache = new Map();
-
-function MediaThumb({
- thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, emptyIcon = 'video',
- reloadKey,
-}) {
- const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
- const [retryToken, setRetryToken] = useState(0);
-
- useEffect(() => {
- const listener = () => setRetryToken((n) => n + 1);
- _thumbRetryListeners.add(listener);
- return () => _thumbRetryListeners.delete(listener);
- }, []);
-
- // Re-checks the cache by the CURRENT thumbHash on every change rather than
- // trusting the `blobUrl` state variable — a PosterCard swaps this same
- // component instance's thumbHash prop from the raw fallback frame to the
- // TMDB poster once metadata resolves, and gating on "is blobUrl already
- // set" (from the *previous* hash) would leave the fallback frame on
- // screen forever instead of ever fetching the poster.
- //
- // `onReady` fires exactly once per settled thumbHash — cache hit, fetch
- // success, fetch failure, or no hash at all — so a caller that hides this
- // component until its image has actually arrived (PosterCard) always
- // gets unstuck, even when there's nothing to show.
- useEffect(() => {
- const cached = _thumbBlobCache.get(thumbHash);
- if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; }
- setBlobUrl(null);
- if (!thumbHash) { if (onReady) onReady(null); return; }
- let cancelled = false;
- (async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) { if (onReady) onReady(null); return; }
- try {
- const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1);
- if (cancelled) return;
- const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' }));
- _thumbBlobCache.set(thumbHash, url);
- setBlobUrl(url);
- if (onReady) onReady(url);
- } catch {
- /* leave the placeholder — a transient fetch failure isn't an error state */
- if (!cancelled && onReady) onReady(null);
- }
- })();
- return () => { cancelled = true; };
- // `reloadKey` — bumped by the caller when the transport behind `transportRef`
- // was replaced (a search-page pool reconnect). Without it, a thumb whose
- // first fetch hit a not-yet-connected transport and bailed above would stay
- // an empty placeholder for good, since neither `thumbHash` nor `retryToken`
- // changes when only the connection does.
- }, [thumbHash, retryToken, reloadKey]);
-
- if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name=${emptyIcon} /></div>`;
- return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`;
-}
-
// ── TMDB metadata, fetched once per visible tile ────────────────────────────
// An operator correcting a wrong match (TmdbSearchOverlay below) changes
@@ -239,11 +136,6 @@ function bumpMediaMetaGeneration() {
for (const fn of _mediaMetaListeners) fn();
}
-const _thumbRetryListeners = new Set();
-function bumpThumbGeneration() {
- for (const fn of _thumbRetryListeners) fn();
-}
-
// `enrichSig` — a value that changes when the entry's index-time
// enrichment lands (display_title fills in). The node answers confidence 0
// for a not-yet-enriched video (it can't tell it apart from a movie and
@@ -1189,4 +1081,4 @@ function VideoApp({
// blob" and "mount only once actually scrolled near" mechanisms apply to a
// track's cover art unchanged, so Music imports them here rather than
// re-implementing (docs/MESHBAY_DESIGN.md §9.4's checklist).
-export { VideoApp, MediaThumb, LazyTile, groupVideoEntries, bumpMediaMetaGeneration, bumpThumbGeneration };
+export { VideoApp, groupVideoEntries, bumpMediaMetaGeneration };