diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 10:25:44 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 10:25:44 +0200 |
| commit | 950f4aa6d107e127fb6dc0579beedbff0e6f1af5 (patch) | |
| tree | 9891a96220afac04ea06a650aa2f75a951385b94 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | f1ed3e6be452f6211cee8db1afef8b56473a04db (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
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 }; |