diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-21 13:52:11 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-21 13:52:11 +0200 |
| commit | 8730281d739d9ed1d6f2d366772582eea8ba0294 (patch) | |
| tree | 4df2f3c8d42dc17147bcdec23109aa9c0f1b5b35 /packages/meshbay-client/src/cast-chromecast.js | |
| parent | ae52b69b14a6997d01aad66f49fbea7b5ca2dfe6 (diff) | |
| download | meshbay-8730281d739d9ed1d6f2d366772582eea8ba0294.tar.gz | |
feat: LAN Wi-Fi casting to Chromecast via local HTTP relay
Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can
play the stream. The relay runs in the Electron main process — same trust
boundary as downloads and MSE playback.
- cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC
chunks into moof+mdat pairs), ring buffer, backpressure, finish() for
clean end-of-stream, fixed port range 19550-19553
- cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol
(castv2-client), connect/reload/disconnect lifecycle
- Seek-aware: relay restarts on every seek, Chromecast reloads new URL;
generation counter prevents stale async errors from killing active
restarts; landingPlayheadRef suppresses programmatic seeking events
- Device picker in video top bar with scan, device selection, copy-URL
fallback, and cast status indicator
- IPC bridge (main/preload/platform) for start/push/stop/finish/status/
discover/chromecastConnect/chromecastReload/chromecastDisconnect
- Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/cast-chromecast.js')
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 157 |
1 files changed, 157 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js new file mode 100644 index 0000000..50f79fd --- /dev/null +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -0,0 +1,157 @@ +'use strict'; + +const Bonjour = require('bonjour-service').Bonjour; +const CastClient = require('castv2-client').Client; +const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver; + +const SCAN_DURATION_MS = 6000; + +class CastChromecast { + constructor() { + this._bonjour = null; + this._browser = null; + this._devices = new Map(); + this._client = null; + this._player = null; + this._connectedDevice = null; + } + + async discover() { + this._devices.clear(); + + if (this._browser) { + this._browser.stop(); + this._browser = null; + } + + if (!this._bonjour) { + this._bonjour = new Bonjour(); + } + + console.log('[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 }); + console.log(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); + } + }); + + setTimeout(() => { + if (this._browser) { + this._browser.stop(); + this._browser = null; + } + const devices = Array.from(this._devices.values()); + console.log(`[cast-chromecast] scan complete: ${devices.length} device(s)`); + resolve(devices); + }, SCAN_DURATION_MS); + }); + } + + async connect(deviceId, mediaUrl) { + const device = this._devices.get(deviceId); + if (!device) throw new Error(`Unknown device: ${deviceId}`); + + await this.disconnect(); + + console.log(`[cast-chromecast] connecting to "${device.name}" (${device.host}:${device.port})`); + + const client = new CastClient(); + + await new Promise((resolve, reject) => { + client.on('error', (err) => { + console.log(`[cast-chromecast] client error: ${err.message}`); + this._cleanup(); + }); + client.connect(device.host, () => resolve()); + setTimeout(() => reject(new Error('Connection timeout')), 10000); + }); + + this._client = client; + this._connectedDevice = device; + + const player = await new Promise((resolve, reject) => { + client.launch(DefaultMediaReceiver, (err, p) => { + if (err) return reject(err); + resolve(p); + }); + }); + + this._player = player; + + player.on('status', (status) => { + console.log(`[cast-chromecast] player status: ${status.playerState}`); + }); + + const media = { + contentId: mediaUrl, + contentType: 'video/mp4', + streamType: 'LIVE', + }; + + const status = await new Promise((resolve, reject) => { + player.load(media, { autoplay: true }, (err, s) => { + if (err) return reject(err); + resolve(s); + }); + }); + + console.log(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); + return { deviceName: device.name, playerState: status.playerState }; + } + + async reload(mediaUrl) { + if (!this._player) throw new Error('Not connected'); + console.log(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"`); + const media = { + contentId: mediaUrl, + contentType: 'video/mp4', + streamType: 'LIVE', + }; + const status = await new Promise((resolve, reject) => { + this._player.load(media, { autoplay: true }, (err, s) => { + if (err) return reject(err); + resolve(s); + }); + }); + console.log(`[cast-chromecast] reloaded, state: ${status.playerState}`); + return { playerState: status.playerState }; + } + + async disconnect() { + if (this._player) { + try { + await new Promise((resolve) => { + this._player.stop(() => resolve()); + }); + } catch { /* already stopped */ } + } + this._cleanup(); + } + + getStatus() { + return { + connected: this._client !== null, + deviceName: this._connectedDevice?.name || null, + }; + } + + _cleanup() { + if (this._client) { + try { this._client.close(); } catch { /* ignore */ } + } + this._client = null; + this._player = null; + this._connectedDevice = null; + } +} + +module.exports = CastChromecast; |