aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/cast-chromecast.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-21 13:52:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-21 13:52:11 +0200
commit8730281d739d9ed1d6f2d366772582eea8ba0294 (patch)
tree4df2f3c8d42dc17147bcdec23109aa9c0f1b5b35 /packages/meshbay-client/src/cast-chromecast.js
parentae52b69b14a6997d01aad66f49fbea7b5ca2dfe6 (diff)
downloadmeshbay-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.js157
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;