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/src/meshbay_hub/static/app.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-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 199 |
1 files changed, 190 insertions, 9 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=${() => { |