summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-02 08:39:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-02 08:39:56 +0200
commite941cc4c39c38a12220153ea572bd4c7bb92fde0 (patch)
treeb7a0c1f04eb642f4aa050508b3e24bb4cf66a67a /packages/meshbay-hub
parentfee265e0046e85c3db0e5bb9608b666d2e9547ab (diff)
downloadmeshbay-e941cc4c39c38a12220153ea572bd4c7bb92fde0.tar.gz
feat(client): list cast receivers as they answer
The scan still runs six seconds, but the picker polls what it has found and shows each receiver immediately. A rescan no longer has its timer cut short by the scan it replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js31
-rw-r--r--packages/meshbay-hub/tests/test_cast_discovery.py108
3 files changed, 155 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 19f12a6..749f93e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -430,6 +430,8 @@ export async function waitForRootsIndexed(groupId, onProgress,
* Absent in a browser, where the relay cannot run: there is no main process
* to bind a server socket in, and a page cannot open one.
*/
+const CAST_POLL_MS = 250;
+
export const cast = {
available: Boolean(bridge && bridge.cast),
async start(opts) {
@@ -456,9 +458,32 @@ export const cast = {
if (!bridge || !bridge.cast) return null;
return bridge.cast.status();
},
- async discover() {
- if (!bridge || !bridge.cast) return [];
- return bridge.cast.discover();
+ // Lists receivers as they answer rather than at the end of the scan: most
+ // answer within two seconds, and the scan runs on for the ones coming back
+ // from a reset. `onUpdate(devices, scanning)` is called on each poll until
+ // the scan ends; the returned function stops it being called at all.
+ discover(onUpdate) {
+ if (!bridge || !bridge.cast) {
+ onUpdate([], false);
+ return () => {};
+ }
+ let stopped = false;
+ let timer = null;
+ const poll = async () => {
+ const snap = await bridge.cast.devices().catch(() => null);
+ if (stopped) return;
+ if (!snap) {
+ onUpdate([], false);
+ return;
+ }
+ onUpdate(snap.devices, snap.scanning);
+ if (snap.scanning) timer = setTimeout(poll, CAST_POLL_MS);
+ };
+ bridge.cast.scan().then(poll, () => { if (!stopped) onUpdate([], false); });
+ return () => {
+ stopped = true;
+ clearTimeout(timer);
+ };
},
async chromecastConnect(opts) {
if (!bridge || !bridge.cast) return null;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index fa10d15..2a4d2ea 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -1433,6 +1433,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
}, [phase]);
+ // The scan lives as long as the picker is open: closing it, or picking a
+ // receiver, stops the updates. Receivers are listed as they answer, and the
+ // spinner sits below them so one landing never moves the row being aimed at.
+ useEffect(() => {
+ if (!castPickerOpen) return undefined;
+ setCastDevices([]);
+ setCastScanning(true);
+ return platform.cast.discover((devices, scanning) => {
+ setCastDevices(devices || []);
+ setCastScanning(scanning);
+ });
+ }, [castPickerOpen]);
+
useEffect(() => {
return () => {
if (blobUrlRef.current) {
@@ -1685,12 +1698,6 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
setCastPickerOpen(false);
} else {
setCastPickerOpen(true);
- setCastScanning(true);
- setCastDevices([]);
- platform.cast.discover().then((devices) => {
- setCastDevices(devices || []);
- setCastScanning(false);
- }).catch(() => setCastScanning(false));
}
}
}}
@@ -1698,12 +1705,6 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<${Icon} name="cast" /></button>
${castPickerOpen && html`
<div class="cast-picker">
- ${castScanning && html`
- <div class="cast-picker-item cast-picker-scanning">
- <span class="spinner" style="width:14px;height:14px"></span>
- ${t('cast.scanning')}
- </div>
- `}
${castDevices.map(d => html`
<button class="cast-picker-item" onClick=${() => {
setCastPickerOpen(false);
@@ -1718,6 +1719,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<${Icon} name="cast" /> ${d.name}
</button>
`)}
+ ${castScanning && html`
+ <div class="cast-picker-item cast-picker-scanning">
+ <span class="spinner" style="width:14px;height:14px"></span>
+ ${t('cast.scanning')}
+ </div>
+ `}
${!castScanning && castDevices.length === 0 && html`
<div class="cast-picker-item cast-picker-empty">
${t('cast.no_devices')}
diff --git a/packages/meshbay-hub/tests/test_cast_discovery.py b/packages/meshbay-hub/tests/test_cast_discovery.py
new file mode 100644
index 0000000..9e9ce85
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_cast_discovery.py
@@ -0,0 +1,108 @@
+"""
+Cast receivers are listed as they answer, not at the end of the scan.
+
+The scan runs its full length because a receiver coming back from a reset can
+take several seconds to answer, but most answer within two; a picker that showed
+nothing until the end was slow every time it was opened. These tests run the real
+`CastChromecast` with mDNS replaced by a stub that answers on cue, and the clock
+shortened, so what they measure is what the page's poll would see.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
+CHROMECAST = CLIENT / "src" / "cast-chromecast.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not CHROMECAST.exists(),
+ reason="node or the desktop client sources are not available")
+
+# Stubs the two dependencies at `require` time, and makes six seconds of scan
+# last sixty milliseconds by scaling every timer the module arms.
+SCRIPT = r"""
+const Module = require('node:module');
+const browsers = [];
+const realLoad = Module._load;
+Module._load = function (request, ...rest) {
+ if (request === 'bonjour-service') {
+ return { Bonjour: class {
+ find(_q, onUp) {
+ const b = { onUp, stopped: false, stop() { this.stopped = true; } };
+ browsers.push(b);
+ return b;
+ }
+ } };
+ }
+ if (request === 'castv2-client') return { Client: class {}, DefaultMediaReceiver: {} };
+ return realLoad.call(this, request, ...rest);
+};
+const realSetTimeout = global.setTimeout;
+global.setTimeout = (fn, ms, ...a) => realSetTimeout(fn, ms / 100, ...a);
+const wait = (ms) => new Promise((r) => realSetTimeout(r, ms));
+
+const CastChromecast = require(process.argv[2]);
+const tv = (id) => ({ name: id, txt: { id, fn: `TV ${id}` },
+ addresses: ['10.0.0.9'], port: 8009 });
+
+(async () => {
+ const cc = new CastChromecast();
+ const out = {};
+
+ cc.startScan();
+ out.atStart = cc.devices();
+ browsers[0].onUp(tv('a'));
+ out.afterFirstAnswer = cc.devices();
+ await wait(100);
+ out.afterScan = cc.devices();
+ out.firstBrowserStopped = browsers[0].stopped;
+
+ // A second scan started over a first: the first's timer must not end it.
+ cc.startScan();
+ await wait(40);
+ cc.startScan();
+ await wait(40);
+ out.restartedStillScanning = cc.devices().scanning;
+ out.restartClearedOldDevices = cc.devices().devices.length === 0;
+ await wait(60);
+ out.restartedEnds = !cc.devices().scanning;
+ console.log(JSON.stringify(out));
+})();
+"""
+
+
+@pytest.fixture(scope="module")
+def run(tmp_path_factory):
+ script = tmp_path_factory.mktemp("cd") / "discover.cjs"
+ script.write_text(SCRIPT, encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(script), str(CHROMECAST)],
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_a_receiver_is_listed_before_the_scan_ends(run):
+ assert run["atStart"] == {"devices": [], "scanning": True}
+ assert run["afterFirstAnswer"]["scanning"] is True
+ assert [d["name"] for d in run["afterFirstAnswer"]["devices"]] == ["TV a"]
+
+
+def test_the_scan_ends_on_its_own_and_keeps_what_it_found(run):
+ assert run["afterScan"]["scanning"] is False
+ assert [d["id"] for d in run["afterScan"]["devices"]] == ["a"]
+ assert run["firstBrowserStopped"] is True
+
+
+def test_a_new_scan_is_not_cut_short_by_the_one_it_replaced(run):
+ """
+ The old code left the first scan's timer armed, and it stopped whichever
+ browser was current when it fired — the new one, two thirds early.
+ """
+ assert run["restartedStillScanning"] is True
+ assert run["restartClearedOldDevices"] is True
+ assert run["restartedEnds"] is True