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 | |
| 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')
20 files changed, 1080 insertions, 17 deletions
diff --git a/packages/meshbay-client/package-lock.json b/packages/meshbay-client/package-lock.json index b1ad68a..5b705b9 100644 --- a/packages/meshbay-client/package-lock.json +++ b/packages/meshbay-client/package-lock.json @@ -8,6 +8,10 @@ "name": "meshbay-client", "version": "0.1.0", "license": "AGPL-3.0-or-later", + "dependencies": { + "bonjour-service": "^1.4.4", + "castv2-client": "^1.2.0" + }, "devDependencies": { "electron": "^42.0.0", "electron-builder": "^25.0.0" @@ -383,6 +387,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -478,6 +488,69 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -564,6 +637,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -575,7 +654,6 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1004,6 +1082,16 @@ "bluebird": "^3.5.5" } }, + "node_modules/bonjour-service": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -1233,6 +1321,41 @@ "node": ">= 0.4" } }, + "node_modules/castv2": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz", + "integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "protobufjs": "^6.8.8" + } + }, + "node_modules/castv2-client": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/castv2-client/-/castv2-client-1.2.0.tgz", + "integrity": "sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==", + "license": "MIT", + "dependencies": { + "castv2": "~0.1.4", + "debug": "^2.2.0" + } + }, + "node_modules/castv2-client/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/castv2-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1611,7 +1734,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1791,6 +1913,18 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2062,7 +2196,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2945,6 +3078,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -3254,9 +3393,21 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -3620,6 +3771,32 @@ "node": ">=10" } }, + "node_modules/protobufjs": { + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4192,6 +4369,12 @@ "fs-extra": "^10.0.0" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -4251,7 +4434,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unique-filename": { diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json index 61d75d7..b9c5a50 100644 --- a/packages/meshbay-client/package.json +++ b/packages/meshbay-client/package.json @@ -1,7 +1,7 @@ { "name": "meshbay-client", "version": "0.1.0", - "description": "MeshBay desktop client \u2014 the interface ships with the application, not from the hub", + "description": "MeshBay desktop client — the interface ships with the application, not from the hub", "license": "AGPL-3.0-or-later", "main": "src/main.js", "private": true, @@ -39,5 +39,12 @@ "python3-meshbay-common" ] } + }, + "dependencies": { + "bonjour-service": "^1.4.4", + "castv2-client": "^1.2.0" + }, + "overrides": { + "protobufjs": "^7.5.5" } } 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; diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js new file mode 100644 index 0000000..12939c4 --- /dev/null +++ b/packages/meshbay-client/src/cast-relay.js @@ -0,0 +1,293 @@ +/** + * Local HTTP relay for LAN casting. + * + * Re-serves decrypted fMP4 segments from the renderer over HTTP so that a + * Chromecast, Smart TV or any player on the same Wi-Fi can stream the video. + * + * The renderer sends raw byte chunks from the WebRTC DataChannel — these are + * arbitrary-sized slices of the fMP4 stream, NOT aligned to MP4 box boundaries. + * MSE handles this internally, but external players (VLC, Chromecast) need + * properly framed fMP4 fragments. The BoxAccumulator reassembles the byte + * stream and emits complete moof+mdat pairs. + * + * Mitigations: + * · Bind to the LAN interface, never 0.0.0.0 + * · Fixed port range (19550-19553), opened only during active cast + * · Unguessable token in the URL (32 hex chars) + * · Cache-Control: no-store on every response + * · Server destroyed when playback stops — zero residual surface + */ + +'use strict'; + +const crypto = require('node:crypto'); +const http = require('node:http'); +const os = require('node:os'); + +const RING_CAP = 64; +const BACKPRESSURE_HIGH = 8 * 1024 * 1024; +const MOOF = 0x6d6f6f66; +const PORT_BASE = 19550; +const PORT_COUNT = 4; + +function lanAddress() { + const ifaces = os.networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const iface of ifaces[name]) { + if (!iface.internal && iface.family === 'IPv4') { + return iface.address; + } + } + } + return '127.0.0.1'; +} + +class BoxAccumulator { + constructor() { + this._buf = Buffer.alloc(0); + this._synced = false; + } + + push(data) { + this._buf = Buffer.concat([this._buf, data]); + const fragments = []; + + if (!this._synced) { + const idx = this._findMoof(); + if (idx === -1) return fragments; + console.log(`[cast-relay] box sync: found first moof at byte offset ${idx}, discarded ${idx} bytes`); + this._buf = this._buf.subarray(idx); + this._synced = true; + } + + while (this._buf.length >= 8) { + const size = this._buf.readUInt32BE(0); + const type = this._buf.readUInt32BE(4); + + if (size < 8) { + console.log(`[cast-relay] box sync lost: invalid size ${size}, rescanning`); + this._synced = false; + const idx = this._findMoof(); + if (idx === -1) return fragments; + this._buf = this._buf.subarray(idx); + this._synced = true; + continue; + } + + if (type === MOOF) { + if (this._buf.length < size + 8) break; + const mdatSize = this._buf.readUInt32BE(size); + const pairSize = size + mdatSize; + if (this._buf.length < pairSize) break; + + fragments.push(Buffer.from(this._buf.subarray(0, pairSize))); + this._buf = this._buf.subarray(pairSize); + } else { + if (this._buf.length < size) break; + this._buf = this._buf.subarray(size); + } + } + + return fragments; + } + + reset() { + this._buf = Buffer.alloc(0); + this._synced = false; + } + + _findMoof() { + for (let i = 0; i <= this._buf.length - 8; i++) { + if (this._buf.readUInt32BE(i + 4) === MOOF) { + const size = this._buf.readUInt32BE(i); + if (size >= 8 && size < 1_000_000) { + return i; + } + } + } + return -1; + } +} + +class CastRelay { + constructor() { + this._server = null; + this._port = null; + this._token = null; + this._lanIP = null; + this._initSegment = null; + this._ring = []; + this._clients = new Set(); + this._accum = new BoxAccumulator(); + this._fragCount = 0; + this._chunkCount = 0; + this._bytesSent = 0; + } + + get active() { return this._server !== null; } + + get url() { + if (!this._server) return null; + return `http://${this._lanIP}:${this._port}/stream.mp4?t=${this._token}`; + } + + async start({ codec, initSegment }) { + if (this._server) await this.stop(); + + this._token = crypto.randomBytes(16).toString('hex'); + this._lanIP = lanAddress(); + this._initSegment = initSegment ? Buffer.from(initSegment) : null; + this._ring = []; + this._clients = new Set(); + this._accum = new BoxAccumulator(); + this._fragCount = 0; + this._chunkCount = 0; + this._bytesSent = 0; + + const server = http.createServer((req, res) => this._handle(req, res)); + + let bound = false; + for (let i = 0; i < PORT_COUNT && !bound; i++) { + const port = PORT_BASE + i; + try { + await new Promise((resolve, reject) => { + const onError = (err) => { + server.removeListener('error', onError); + reject(err); + }; + server.on('error', onError); + server.listen(port, this._lanIP, () => { + server.removeListener('error', onError); + this._port = port; + resolve(); + }); + }); + bound = true; + } catch (err) { + if (err.code !== 'EADDRINUSE') throw err; + console.log(`[cast-relay] port ${port} busy, trying next`); + } + } + if (!bound) throw new Error('All cast relay ports are in use'); + + this._server = server; + console.log(`[cast-relay] started on ${this.url}`); + console.log(`[cast-relay] init segment: ${this._initSegment ? this._initSegment.length + ' bytes' : 'none'}`); + return { url: this.url, port: this._port, token: this._token }; + } + + pushSegment(data) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + this._chunkCount++; + + const fragments = this._accum.push(buf); + + for (const frag of fragments) { + this._fragCount++; + if (this._fragCount <= 3 || this._fragCount % 50 === 0) { + console.log(`[cast-relay] fragment #${this._fragCount}: ${frag.length} bytes (from ${this._chunkCount} chunks), ${this._clients.size} client(s)`); + } + + if (this._ring.length >= RING_CAP) { + this._ring.shift(); + } + this._ring.push(frag); + + for (const res of this._clients) { + if (res.writableLength > BACKPRESSURE_HIGH) { + console.log(`[cast-relay] backpressure: dropping fragment for slow client`); + continue; + } + res.write(frag); + this._bytesSent += frag.length; + } + } + } + + finish() { + console.log('[cast-relay] finishing stream'); + for (const res of this._clients) { + try { res.end(); } catch { /* already closed */ } + } + } + + async stop() { + console.log(`[cast-relay] stopping — ${this._chunkCount} chunks, ${this._fragCount} fragments, ${(this._bytesSent / 1048576).toFixed(1)} MB sent`); + for (const res of this._clients) { + try { res.end(); } catch { /* already closed */ } + } + this._clients.clear(); + + if (this._server) { + const srv = this._server; + this._server = null; + await new Promise((resolve) => srv.close(resolve)); + } + + this._port = null; + this._token = null; + this._initSegment = null; + this._ring = []; + this._accum.reset(); + this._fragCount = 0; + this._chunkCount = 0; + this._bytesSent = 0; + } + + _handle(req, res) { + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + + let url; + try { + url = new URL(req.url, `http://${req.headers.host}`); + } catch { + res.writeHead(400); + res.end(); + return; + } + + if (url.searchParams.get('t') !== this._token) { + res.writeHead(403); + res.end(); + return; + } + + if (url.pathname !== '/stream.mp4') { + res.writeHead(404); + res.end(); + return; + } + + let sent = 0; + res.writeHead(200, { + 'Content-Type': 'video/mp4', + 'Cache-Control': 'no-store', + 'Accept-Ranges': 'none', + 'Connection': 'keep-alive', + }); + + if (this._initSegment) { + res.write(this._initSegment); + sent += this._initSegment.length; + } + + for (const frag of this._ring) { + res.write(frag); + sent += frag.length; + } + + console.log(`[cast-relay] client connected from ${req.socket.remoteAddress} — sent init + ${this._ring.length} fragments (${(sent / 1024).toFixed(0)} KB)`); + + this._clients.add(res); + req.on('close', () => { + this._clients.delete(res); + console.log(`[cast-relay] client disconnected, ${this._clients.size} remaining`); + }); + } +} + +module.exports = CastRelay; diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f2ec645..45e8462 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -351,6 +351,11 @@ function createWindow() { // renderer parses decrypted content from nodes, which is attacker-controlled // input, so it is treated as hostile even though it is our own code. +const CastRelay = require('./cast-relay.js'); +const castRelay = new CastRelay(); +const CastChromecast = require('./cast-chromecast.js'); +const castChromecast = new CastChromecast(); + function registerBridge() { ipcMain.handle('hub:set', async (_e, base) => { const url = String(base || '').trim().replace(/\/+$/, ''); @@ -738,6 +743,60 @@ function registerBridge() { _nodePairingCode = code || null; return true; }); + + // ── LAN cast relay ────────────────────────────────────────────────────── + // + // A local HTTP server that re-serves decrypted fMP4 segments so a + // Chromecast or Smart TV on the same Wi-Fi can stream the video. The + // renderer feeds it segments via IPC; the relay serves them over HTTP. + // Same trust boundary as the MSE player and the download-to-disk path. + + ipcMain.handle('cast:start', async (_e, opts) => { + return castRelay.start({ + codec: opts.codec, + initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null, + }); + }); + + ipcMain.handle('cast:push', async (_e, data) => { + castRelay.pushSegment(Buffer.from(data)); + return true; + }); + + ipcMain.handle('cast:stop', async () => { + await castRelay.stop(); + return true; + }); + + ipcMain.handle('cast:finish', async () => { + castRelay.finish(); + return true; + }); + + ipcMain.handle('cast:status', async () => ({ + active: castRelay.active, + url: castRelay.url, + chromecast: castChromecast.getStatus(), + })); + + // ── Chromecast discovery + control ────────────────────────────────────── + + ipcMain.handle('cast:discover', async () => { + return castChromecast.discover(); + }); + + ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl }) => { + return castChromecast.connect(deviceId, mediaUrl); + }); + + ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl }) => { + return castChromecast.reload(mediaUrl); + }); + + ipcMain.handle('cast:chromecast:disconnect', async () => { + await castChromecast.disconnect(); + return true; + }); } /** diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 04dfe44..e9fd375 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld('meshbay', { nodeAdmin: true, localFolders: true, nativeSave: true, + lanCast: true, }, // Ask the main process to call the hub. The renderer has an `app://` origin, @@ -90,6 +91,21 @@ contextBridge.exposeInMainWorld('meshbay', { setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), }, + // LAN cast relay. The main process runs a local HTTP server and the + // renderer feeds it decrypted segments. A Chromecast or Smart TV on the + // same Wi-Fi plays from the URL. + cast: { + start: (opts) => ipcRenderer.invoke('cast:start', opts), + push: (data) => ipcRenderer.invoke('cast:push', data), + stop: () => ipcRenderer.invoke('cast:stop'), + finish: () => ipcRenderer.invoke('cast:finish'), + status: () => ipcRenderer.invoke('cast:status'), + discover: () => ipcRenderer.invoke('cast:discover'), + chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), + chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), + chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), + }, + // A sink that writes to disk as chunks arrive, never a buffer handed over at // the end. `auto` uses the remembered folder without a dialog, which is what // "save automatically" means; without one, or when the person asked to be diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 50ee9f6..7780d08 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -406,6 +406,9 @@ const ICON_PATHS = { server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', 'M8 7.5h.01', 'M8 16.5h.01'], + cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21', + 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6', + 'M2 21h.01'], }; // The M of the wordmark is a picture; the rest is text. Resolved from this @@ -3766,6 +3769,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // live; the render needs to reach it for "start from the beginning". const requestSeekRef = useRef(null); const [resumedFrom, setResumedFrom] = useState(0); + const [castActive, setCastActive] = useState(false); + const [castUrl, setCastUrl] = useState(null); + const [castPickerOpen, setCastPickerOpen] = useState(false); + const [castDevices, setCastDevices] = useState([]); + const [castScanning, setCastScanning] = useState(false); + const [castDeviceName, setCastDeviceName] = useState(null); + const castActiveRef = useRef(false); + const castCodecRef = useRef(null); + const initSegmentRef = useRef(null); + const castRestartPendingRef = useRef(false); + const castDeviceRef = useRef(null); + const castRestartGenRef = useRef(0); + const landingPlayheadRef = useRef(false); /** * The buffered range the playhead is actually in, or null. @@ -3872,6 +3888,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { * So the clock drives this, not the data. */ const pump = useCallback(() => { + if (awaitingInitRef.current) return; const transport = transportRef.current; evictBehind(); flushQueue(); @@ -3984,6 +4001,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { seekTargetRef.current = target; outstandingRef.current = STREAM_WINDOW; setPhase('loading'); + console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW); t.requestStream(entry.id, STREAM_WINDOW, target); }, SEEK_DEBOUNCE_MS); }; @@ -4010,6 +4028,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // the range can begin slightly later than the target; never seek // behind what is actually there. if (Math.abs(v.currentTime - target) > 0.5) { + landingPlayheadRef.current = true; v.currentTime = Math.max(target, a); } v.play().catch(() => {}); @@ -4064,25 +4083,33 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { quotaRef.current = 0; awaitingInitRef.current = false; seekTargetRef.current = start; + console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length); setPhase('streaming'); pump(); }; const onSeeking = () => { + if (landingPlayheadRef.current) { + landingPlayheadRef.current = false; + return; + } const v = videoRef.current; if (!v || cancelled) return; const target = v.currentTime; // Inside what is buffered, the browser handles it and the node need not - // hear about it at all. - const sb = sbRef.current; - if (sb) { - try { - for (let i = 0; i < sb.buffered.length; i++) { - if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) { - return; + // hear about it at all — unless a cast is active, because the relay + // cannot seek within its HTTP stream and must be restarted. + if (!castActiveRef.current) { + const sb = sbRef.current; + if (sb) { + try { + for (let i = 0; i < sb.buffered.length; i++) { + if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) { + return; + } } - } - } catch { /* fall through and ask the node */ } + } catch { /* fall through and ask the node */ } + } } requestSeek(target); }; @@ -4100,6 +4127,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { if (cancelled) return; if (msg.file_id && msg.file_id !== entry.id) return; const mime = `video/mp4; codecs="${msg.codec}"`; + castCodecRef.current = msg.codec; if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { setError(t('video.err_mse', { codec: msg.codec })); @@ -4115,6 +4143,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // scrubber is drawn from. if (sbRef.current && msRef.current && msRef.current.readyState === 'open') { + console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current); + initSegmentRef.current = null; + if (castActiveRef.current && platform.cast.available) { + platform.cast.stop().catch(() => {}); + castRestartPendingRef.current = true; + } reinitAt(msg.start || 0).catch(() => { setError(t('video.err_transport')); setPhase('error'); @@ -4122,8 +4156,24 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { return; } + // If we reach here during a seek (readyState was 'ended' after the + // previous stream finished), the seek-landing path above could not run. + // A fresh MediaSource is needed, but the seek state must still be reset + // or awaitingInit stays true and every segment is dropped forever. + awaitingInitRef.current = false; + endedRef.current = false; + appendingRef.current = false; + queueRef.current = []; + sbRef.current = null; + initSegmentRef.current = null; + if (castActiveRef.current && platform.cast.available) { + platform.cast.stop().catch(() => {}); + castRestartPendingRef.current = true; + } + const ms = new MediaSource(); msRef.current = ms; + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); const url = URL.createObjectURL(ms); blobUrlRef.current = url; @@ -4187,6 +4237,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // for credit that cannot come. A race, which is why the same seek // worked twice and hung on the third. outstandingRef.current = Math.max(0, outstandingRef.current - 1); + if (awaitingInitRef.current) { + console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current); + } // Between asking for a seek and its `stream_init`, everything on the // channel is the film we just left. Same file, so `file_id` cannot // tell them apart — ordering can. @@ -4199,6 +4252,44 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { try { const plaintext = await window.MeshBayCrypto.decryptChunkBin( gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); + if (initSegmentRef.current === null) { + initSegmentRef.current = plaintext; + if (castRestartPendingRef.current && castActiveRef.current + && platform.cast.available) { + castRestartPendingRef.current = false; + const gen = ++castRestartGenRef.current; + const device = castDeviceRef.current; + platform.cast.start({ + codec: castCodecRef.current, + initSegment: plaintext, + }).then(async (result) => { + if (castRestartGenRef.current !== gen) return; + if (!result) return; + setCastUrl(result.url); + const status = await platform.cast.status(); + if (status && status.chromecast && status.chromecast.connected) { + await platform.cast.chromecastReload({ mediaUrl: result.url }); + } else if (device) { + await platform.cast.chromecastConnect({ + deviceId: device.id, mediaUrl: result.url, + }); + setCastDeviceName(device.name); + } else { + navigator.clipboard.writeText(result.url).catch(() => {}); + } + }).catch((err) => { + if (castRestartGenRef.current !== gen) return; + console.error('[cast] restart failed:', err); + platform.cast.stop().catch(() => {}); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); setCastDeviceName(null); + }); + } + } + if (castActiveRef.current && !castRestartPendingRef.current + && platform.cast.available) { + platform.cast.push(plaintext).catch(() => {}); + } queueRef.current.push(plaintext); // pump(), not flushQueue(): arriving data is the moment to top the // window back up, and that is what keeps the stream continuous. @@ -4216,6 +4307,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // would call endOfStream() and truncate the film at the seek point. if (awaitingInitRef.current) return; endedRef.current = true; + if (castActiveRef.current && platform.cast.available) { + platform.cast.finish().catch(() => {}); + } flushQueue(); }; @@ -4305,6 +4399,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { writeResumePosition(entry.id, videoRef.current.currentTime, durationRef.current); } + if (castActiveRef.current && platform.cast.available) { + platform.cast.chromecastDisconnect().catch(() => {}); + platform.cast.stop().catch(() => {}); + castActiveRef.current = false; + } window.removeEventListener('pagehide', onPageHide); document.removeEventListener('visibilitychange', onVisibility); if (videoRef.current) { @@ -4366,6 +4465,88 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }}> <div class="video-top-bar"> <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${platform.capabilities.lanCast && html` + <div class="cast-wrapper" style="position:relative"> + <button class="video-close ${castActive ? 'cast-active' : ''}" + onClick=${async () => { + if (castActive) { + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop(); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); + setCastDeviceName(null); + castDeviceRef.current = null; + } else if (castCodecRef.current && initSegmentRef.current) { + if (castPickerOpen) { + setCastPickerOpen(false); + } else { + setCastPickerOpen(true); + setCastScanning(true); + setCastDevices([]); + platform.cast.discover().then((devices) => { + setCastDevices(devices || []); + setCastScanning(false); + }).catch(() => setCastScanning(false)); + } + } + }} + title="${castActive ? t('cast.stop') : t('cast.start')}"> + <${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); + setCastDeviceName(d.name); + setCastActive(true); castActiveRef.current = true; + castRestartPendingRef.current = true; + castDeviceRef.current = d; + if (videoRef.current && requestSeekRef.current) { + requestSeekRef.current(videoRef.current.currentTime); + } + }}> + <${Icon} name="cast" /> ${d.name} + </button> + `)} + ${!castScanning && castDevices.length === 0 && html` + <div class="cast-picker-item cast-picker-empty"> + ${t('cast.no_devices')} + </div> + `} + <div class="cast-picker-sep"></div> + <button class="cast-picker-item" onClick=${async () => { + setCastPickerOpen(false); + setCastActive(true); castActiveRef.current = true; + castRestartPendingRef.current = true; + if (videoRef.current && requestSeekRef.current) { + requestSeekRef.current(videoRef.current.currentTime); + } + }}> + <${Icon} name="clip" /> ${t('cast.copy_url')} + </button> + </div> + `} + </div> + `} + ${castUrl && html` + <span class="cast-status-label"> + ${castDeviceName + ? castDeviceName + : html`<input class="cast-url-input" readOnly value=${castUrl} + onClick=${(e) => { + e.target.select(); + navigator.clipboard.writeText(castUrl).catch(() => {}); + }} + title="${t('cast.copy_url')}" />` + } + </span> + `} ${onDownload && html` <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy} onClick=${() => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index ec56f03..e309834 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -154,6 +154,14 @@ export default { 'video.err_transport': 'Transport nicht verbunden', 'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}', + // LAN-Cast + 'cast.start': 'Auf Gerät übertragen', + 'cast.stop': 'Übertragung beenden', + 'cast.copy_url': 'Stream-URL kopieren', + 'cast.copied': 'URL kopiert', + 'cast.scanning': 'Suche nach Geräten…', + 'cast.no_devices': 'Keine Geräte gefunden', + // Settings 'settings.title': 'Einstellungen', 'settings.coming_soon': 'Demnächst verfügbar.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a8392c7..9a5058e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -152,6 +152,14 @@ export default { 'video.err_transport': 'Transport not connected', 'video.err_mse': 'Codec not supported for streaming: {codec}', + // LAN cast + 'cast.start': 'Cast to device', + 'cast.stop': 'Stop casting', + 'cast.copy_url': 'Copy stream URL', + 'cast.copied': 'URL copied', + 'cast.scanning': 'Scanning for devices…', + 'cast.no_devices': 'No devices found', + // Settings 'settings.title': 'Settings', 'settings.coming_soon': 'Coming soon.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 5afb3c3..5a95347 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -152,6 +152,14 @@ export default { 'video.err_transport': 'Transporte no conectado', 'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}', + // LAN cast + 'cast.start': 'Enviar a dispositivo', + 'cast.stop': 'Detener envío', + 'cast.copy_url': 'Copiar URL del stream', + 'cast.copied': 'URL copiada', + 'cast.scanning': 'Buscando dispositivos…', + 'cast.no_devices': 'No se encontraron dispositivos', + // Settings 'settings.title': 'Ajustes', 'settings.coming_soon': 'Próximamente.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 9eb2f30..4139fc9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -153,6 +153,14 @@ export default { 'video.err_transport': 'Transport non connecté', 'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}', + // LAN cast + 'cast.start': 'Diffuser sur un appareil', + 'cast.stop': 'Arrêter la diffusion', + 'cast.copy_url': "Copier l'URL du flux", + 'cast.copied': 'URL copiée', + 'cast.scanning': "Recherche d'appareils…", + 'cast.no_devices': 'Aucun appareil trouvé', + // Settings 'settings.title': 'Paramètres', 'settings.coming_soon': 'Bientôt disponible.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 5653ec0..531c8f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -153,6 +153,14 @@ export default { 'video.err_transport': 'Trasporto non connesso', 'video.err_mse': 'Codec non supportato per lo streaming: {codec}', + // LAN cast + 'cast.start': 'Trasmetti al dispositivo', + 'cast.stop': 'Interrompi trasmissione', + 'cast.copy_url': 'Copia URL dello stream', + 'cast.copied': 'URL copiato', + 'cast.scanning': 'Ricerca dispositivi…', + 'cast.no_devices': 'Nessun dispositivo trovato', + // Settings 'settings.title': 'Impostazioni', 'settings.coming_soon': 'Prossimamente.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index aa3899d..1a68827 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -150,6 +150,14 @@ export default { 'video.err_transport': 'トランスポートが接続されていません', 'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}', + // LAN cast + 'cast.start': 'デバイスにキャスト', + 'cast.stop': 'キャストを停止', + 'cast.copy_url': 'ストリームURLをコピー', + 'cast.copied': 'URLをコピーしました', + 'cast.scanning': 'デバイスを検索中…', + 'cast.no_devices': 'デバイスが見つかりません', + // Settings 'settings.title': '設定', 'settings.coming_soon': '近日公開。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index c5841dd..d0c9649 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -154,6 +154,14 @@ export default { 'video.err_transport': 'Transport niet verbonden', 'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}', + // LAN cast + 'cast.start': 'Naar apparaat casten', + 'cast.stop': 'Casten stoppen', + 'cast.copy_url': 'Stream-URL kopiëren', + 'cast.copied': 'URL gekopieerd', + 'cast.scanning': 'Apparaten zoeken…', + 'cast.no_devices': 'Geen apparaten gevonden', + // Settings 'settings.title': 'Instellingen', 'settings.coming_soon': 'Binnenkort beschikbaar.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index eec144b..630548b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -159,6 +159,14 @@ export default { 'video.err_transport': 'Transport nie jest połączony', 'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}', + // LAN cast + 'cast.start': 'Przesyłaj na urządzenie', + 'cast.stop': 'Zatrzymaj przesyłanie', + 'cast.copy_url': 'Kopiuj URL strumienia', + 'cast.copied': 'URL skopiowany', + 'cast.scanning': 'Wyszukiwanie urządzeń…', + 'cast.no_devices': 'Nie znaleziono urządzeń', + // Settings 'settings.title': 'Ustawienia', 'settings.coming_soon': 'Wkrótce.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index ce2adf2..9a6809a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -154,6 +154,14 @@ export default { 'video.err_transport': 'Transporte não conectado', 'video.err_mse': 'Codec sem suporte para transmissão: {codec}', + // LAN cast + 'cast.start': 'Transmitir para dispositivo', + 'cast.stop': 'Parar transmissão', + 'cast.copy_url': 'Copiar URL do stream', + 'cast.copied': 'URL copiada', + 'cast.scanning': 'Procurando dispositivos…', + 'cast.no_devices': 'Nenhum dispositivo encontrado', + // Settings 'settings.title': 'Configurações', 'settings.coming_soon': 'Em breve.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index f249835..549b77e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -147,6 +147,14 @@ export default { 'video.err_transport': '传输未连接', 'video.err_mse': '该编解码器不支持流式播放:{codec}', + // LAN cast + 'cast.start': '投射到设备', + 'cast.stop': '停止投射', + 'cast.copy_url': '复制流媒体链接', + 'cast.copied': '链接已复制', + 'cast.scanning': '正在搜索设备…', + 'cast.no_devices': '未找到设备', + // Settings 'settings.title': '设置', 'settings.coming_soon': '即将推出。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 02107f5..d400b4e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -46,6 +46,8 @@ export const capabilities = { localFolders: Boolean(bridge && bridge.capabilities && bridge.capabilities.localFolders), // A real save dialog and a write that does not pass through the page. nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave), + // Cast decrypted video to a device on the same LAN via a local HTTP relay. + lanCast: Boolean(bridge && bridge.capabilities && bridge.capabilities.lanCast), }; /** @@ -234,8 +236,56 @@ export const node = { }, }; +/** + * LAN cast relay — re-serve decrypted video segments over HTTP so a + * Chromecast or Smart TV on the same Wi-Fi can play the stream. + * + * 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. + */ +export const cast = { + available: Boolean(bridge && bridge.cast), + async start(opts) { + if (!bridge || !bridge.cast) return null; + return bridge.cast.start(opts); + }, + async push(data) { + if (!bridge || !bridge.cast) return false; + return bridge.cast.push(data); + }, + async stop() { + if (!bridge || !bridge.cast) return false; + return bridge.cast.stop(); + }, + async finish() { + if (!bridge || !bridge.cast) return false; + return bridge.cast.finish(); + }, + async status() { + if (!bridge || !bridge.cast) return null; + return bridge.cast.status(); + }, + async discover() { + if (!bridge || !bridge.cast) return []; + return bridge.cast.discover(); + }, + async chromecastConnect(opts) { + if (!bridge || !bridge.cast) return null; + return bridge.cast.chromecastConnect(opts); + }, + async chromecastReload(opts) { + if (!bridge || !bridge.cast) return null; + return bridge.cast.chromecastReload(opts); + }, + async chromecastDisconnect() { + if (!bridge || !bridge.cast) return false; + return bridge.cast.chromecastDisconnect(); + }, +}; + export default { isNative, hubBase, capabilities, secrets, nativeSave, - apiFetch, device, bridgeMessage, folder, rootPicker, node }; + apiFetch, device, bridgeMessage, folder, rootPicker, node, + cast }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The @@ -244,5 +294,6 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave, if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, - bridgeMessage, folder, rootPicker, node }; + bridgeMessage, folder, rootPicker, node, + cast }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 7ce073a..ee51a8a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1060,6 +1060,39 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .video-close:hover { background: rgba(255, 255, 255, 0.25); } .video-close.dl-active { background: rgba(34, 197, 94, 0.25); pointer-events: none; } +.video-close.cast-active { background: rgba(59, 130, 246, 0.35); } +.video-close.cast-active:hover { background: rgba(59, 130, 246, 0.5); } +.cast-url-input { + background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; color: #e2e8f0; font-size: 0.75em; font-family: inherit; + padding: 4px 8px; min-width: 0; flex: 1; max-width: 420px; + cursor: text; outline: none; +} +.cast-url-input:focus { border-color: rgba(59, 130, 246, 0.6); } +.cast-wrapper { display: inline-block; } +.cast-picker { + position: absolute; top: calc(100% + 4px); right: 0; z-index: 100; + background: rgba(28, 28, 32, 0.97); border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; padding: 4px 0; min-width: 230px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); +} +.cast-picker-item { + display: flex; align-items: center; gap: 8px; width: 100%; + padding: 9px 14px; background: none; border: none; + color: #e2e8f0; font-size: 0.82em; cursor: pointer; text-align: left; + font-family: inherit; +} +.cast-picker-item:hover { background: rgba(255, 255, 255, 0.09); } +.cast-picker-item svg { width: 16px; height: 16px; flex-shrink: 0; } +.cast-picker-scanning { color: #94a3b8; cursor: default; } +.cast-picker-scanning:hover { background: none; } +.cast-picker-empty { color: #64748b; cursor: default; font-style: italic; } +.cast-picker-empty:hover { background: none; } +.cast-picker-sep { height: 1px; margin: 4px 10px; background: rgba(255,255,255,0.1); } +.cast-status-label { + color: #94a3b8; font-size: 0.78em; display: flex; align-items: center; + max-width: 420px; overflow: hidden; white-space: nowrap; +} .video-container { width: 100%; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 17886f4..2cdf15d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -742,12 +742,14 @@ class MeshBayTransport { // `start` is a seek: the node retires whatever this session was streaming // and spawns ffmpeg again from there. Omitted or zero is the film's // beginning, which is what an 0.1 node understands. + console.log('[stream] sending stream_req start:', start, 'credits:', credits); this._send({ type: 'stream_req', v: '0.1', file_id: fileId, credits, start }); } /** Room for `n` more segments. */ grantStreamCredit(n = 1) { if (!this._connected) return; + console.log('[stream] grant credit:', n); this._send({ type: 'stream_more', v: '0.1', n }); } @@ -1200,6 +1202,7 @@ class MeshBayTransport { return; } if (msg.type === 'stream_init') { + console.log('[stream] recv stream_init, start:', msg.start, 'codec:', msg.codec, 'handler:', !!this._onStreamInit); if (this._onStreamInit) this._onStreamInit(msg); return; } @@ -1208,6 +1211,7 @@ class MeshBayTransport { return; } if (msg.type === 'stream_end') { + console.log('[stream] recv stream_end'); if (this._onStreamEnd) this._onStreamEnd(msg); return; } |