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-hub | |
| 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-hub')
14 files changed, 360 insertions, 11 deletions
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; } |