From e941cc4c39c38a12220153ea572bd4c7bb92fde0 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 2 Oct 2026 08:39:56 +0200 Subject: 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 --- packages/meshbay-client/src/cast-chromecast.js | 73 ++++++++------ packages/meshbay-client/src/main.js | 7 +- packages/meshbay-client/src/preload.js | 3 +- .../meshbay-hub/src/meshbay_hub/static/platform.js | 31 +++++- .../src/meshbay_hub/static/video-player.js | 31 +++--- packages/meshbay-hub/tests/test_cast_discovery.py | 108 +++++++++++++++++++++ 6 files changed, 206 insertions(+), 47 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_cast_discovery.py (limited to 'packages') diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 1355cdd..7fc8079 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -78,19 +78,25 @@ class CastChromecast { constructor() { this._bonjour = null; this._browser = null; + this._scanTimer = null; this._devices = new Map(); this._client = null; this._player = null; this._connectedDevice = null; } - async discover() { + /** + * Start a scan and return at once; `devices()` reads what it has found so far. + * + * 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, and a + * picker that waits the full length to show any of them is a picker that is + * slow every single time. So the page polls and lists each one as it lands. + * A scan started over an unfinished one replaces it, timer included. + */ + startScan() { this._devices.clear(); - - if (this._browser) { - this._browser.stop(); - this._browser = null; - } + this._stopScan(); if (!this._bonjour) { this._bonjour = new Bonjour(); @@ -98,30 +104,39 @@ class CastChromecast { debug('[cast-chromecast] scanning for devices...'); - return new Promise((resolve) => { - this._browser = this._bonjour.find({ type: 'googlecast' }, (service) => { - const id = service.txt?.id || service.name; - const name = service.txt?.fn || service.name; - const host = service.addresses?.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) - || (service.referer && service.referer.address); - const port = service.port || 8009; - - if (host && id) { - this._devices.set(id, { id, name, host, port }); - debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); - } - }); - - setTimeout(() => { - if (this._browser) { - this._browser.stop(); - this._browser = null; - } - const devices = Array.from(this._devices.values()); - debug(`[cast-chromecast] scan complete: ${devices.length} device(s)`); - resolve(devices); - }, SCAN_DURATION_MS); + this._browser = this._bonjour.find({ type: 'googlecast' }, (service) => { + const id = service.txt?.id || service.name; + const name = service.txt?.fn || service.name; + const host = service.addresses?.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) + || (service.referer && service.referer.address); + const port = service.port || 8009; + + if (host && id) { + this._devices.set(id, { id, name, host, port }); + debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); + } }); + + this._scanTimer = setTimeout(() => { + this._stopScan(); + debug(`[cast-chromecast] scan complete: ${this._devices.size} device(s)`); + }, SCAN_DURATION_MS); + } + + devices() { + return { + devices: Array.from(this._devices.values()), + scanning: this._browser !== null, + }; + } + + _stopScan() { + clearTimeout(this._scanTimer); + this._scanTimer = null; + if (this._browser) { + this._browser.stop(); + this._browser = null; + } } async connect(deviceId, mediaUrl, subtitle) { diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index c1ff540..cbaabc4 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -2330,10 +2330,13 @@ function registerBridge() { // ── Chromecast discovery + control ────────────────────────────────────── - handle('cast:discover', async () => { - return castChromecast.discover(); + handle('cast:scan', async () => { + castChromecast.startScan(); + return true; }); + handle('cast:devices', async () => castChromecast.devices()); + handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { return castChromecast.connect(deviceId, mediaUrl, subtitle === undefined ? castRelay.subtitle : subtitle); diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index e9c34d2..665ec1f 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -175,7 +175,8 @@ contextBridge.exposeInMainWorld('meshbay', { subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub), finish: () => ipcRenderer.invoke('cast:finish'), status: () => ipcRenderer.invoke('cast:status'), - discover: () => ipcRenderer.invoke('cast:discover'), + scan: () => ipcRenderer.invoke('cast:scan'), + devices: () => ipcRenderer.invoke('cast:devices'), chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), 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" /> ${castPickerOpen && html`
- ${castScanning && html` -
- - ${t('cast.scanning')} -
- `} ${castDevices.map(d => html` `)} + ${castScanning && html` +
+ + ${t('cast.scanning')} +
+ `} ${!castScanning && castDevices.length === 0 && html`
${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 -- cgit v1.2.3