From e608b95bf1fe225915eaeafca2a933f687844733 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 16:47:01 +0200 Subject: feat: Phase 10c — MSE video streaming (real-time playback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace download-then-play VideoPlayer with MSE (MediaSource Extensions) streaming. Node remuxes to fMP4 via ffmpeg, probes codecs with ffprobe, and sends encrypted segments over DataChannel. Browser decrypts and appends to SourceBuffer — playback starts within seconds. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 177 +++++++++++++++------ .../meshbay-hub/src/meshbay_hub/static/i18n.js | 2 + .../meshbay-hub/src/meshbay_hub/static/style.css | 25 +++ .../src/meshbay_hub/static/transport.js | 22 +++ 4 files changed, 174 insertions(+), 52 deletions(-) (limited to 'packages/meshbay-hub') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 34c2517..cb3e05b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1468,72 +1468,140 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex }) `; } -// ── Video Player ──────────────────────────────────────────────────────── +// ── Video Player (MSE streaming) ──────────────────────────────────────── -const VIDEO_MIMES = { - '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska', - '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.m4v': 'video/mp4', - '.flv': 'video/x-flv', '.wmv': 'video/x-ms-wmv', -}; - -function videoMime(name) { - const dot = name.lastIndexOf('.'); - if (dot < 0) return 'video/mp4'; - return VIDEO_MIMES[name.slice(dot).toLowerCase()] || 'video/mp4'; +function _mseSupported(codec) { + if (!window.MediaSource) return false; + const mime = `video/mp4; codecs="${codec}"`; + return MediaSource.isTypeSupported(mime); } function VideoPlayer({ entry, transportRef, gekRef, onClose }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [error, setError] = useState(''); + const [buffered, setBuffered] = useState(0); const videoRef = useRef(null); + const msRef = useRef(null); + const sbRef = useRef(null); const blobUrlRef = useRef(null); + const queueRef = useRef([]); + const appendingRef = useRef(false); + const endedRef = useRef(false); + + const flushQueue = useCallback(() => { + const sb = sbRef.current; + if (!sb || appendingRef.current || sb.updating) return; + if (queueRef.current.length === 0) { + if (endedRef.current && msRef.current?.readyState === 'open') { + try { msRef.current.endOfStream(); } catch {} + } + return; + } + appendingRef.current = true; + const chunk = queueRef.current.shift(); + try { + sb.appendBuffer(chunk); + } catch (e) { + appendingRef.current = false; + console.error('[MSE] appendBuffer error:', e); + } + }, []); useEffect(() => { let cancelled = false; + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + return; + } - const load = async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) { - setError(t('video.err_transport')); - setPhase('error'); - return; + const startStream = async () => { + if (!gekRef.current && window.MeshBayCrypto) { + const gekB64 = await transport.fetchGEK(); + gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); } - try { - if (!gekRef.current && window.MeshBayCrypto) { - const gekB64 = await transport.fetchGEK(); - gekRef.current = await window.MeshBayCrypto.importGEK(gekB64); - } - - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - let downloaded = 0; - const chunks = await pipelinedDownload( - transport, gekRef.current, entry.id, totalChunks, - (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, - ); + let streamCodec = null; + let totalBytes = 0; + transport.onStreamInit = (msg) => { if (cancelled) return; + streamCodec = msg.codec; + const mime = `video/mp4; codecs="${streamCodec}"`; - const blob = new Blob(chunks, { type: videoMime(entry.name) }); - const url = URL.createObjectURL(blob); - blobUrlRef.current = url; - setPhase('ready'); - } catch (err) { - if (!cancelled) { - setError(err.message); + if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { + setError(t('video.err_mse', { codec: streamCodec })); setPhase('error'); + return; } - } + + const ms = new MediaSource(); + msRef.current = ms; + const url = URL.createObjectURL(ms); + blobUrlRef.current = url; + + ms.addEventListener('sourceopen', () => { + if (cancelled) return; + const sb = ms.addSourceBuffer(mime); + sbRef.current = sb; + sb.addEventListener('updateend', () => { + appendingRef.current = false; + if (videoRef.current) { + setBuffered(videoRef.current.buffered.length > 0 + ? videoRef.current.buffered.end(0) : 0); + } + flushQueue(); + }); + setPhase('streaming'); + flushQueue(); + }); + + if (videoRef.current) { + videoRef.current.src = url; + } + }; + + transport.onStreamData = async (msg) => { + if (cancelled) return; + try { + const plaintext = await window.MeshBayCrypto.decryptChunkBin( + gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); + totalBytes += plaintext.byteLength; + setProgress(entry.size > 0 ? totalBytes / entry.size : 0); + queueRef.current.push(plaintext); + flushQueue(); + } catch (e) { + console.error('[MSE] decrypt error:', e); + } + }; + + transport.onStreamEnd = () => { + if (cancelled) return; + endedRef.current = true; + flushQueue(); + }; + + transport.requestStream(entry.id); }; - load(); - return () => { cancelled = true; }; - }, [entry]); + startStream().catch(err => { + if (!cancelled) { setError(err.message); setPhase('error'); } + }); + + return () => { + cancelled = true; + if (transport) { + transport.onStreamInit = null; + transport.onStreamData = null; + transport.onStreamEnd = null; + } + }; + }, [entry, flushQueue]); useEffect(() => { - if (phase === 'ready' && videoRef.current && blobUrlRef.current) { - videoRef.current.src = blobUrlRef.current; + if (phase === 'streaming' && videoRef.current) { videoRef.current.play().catch(() => {}); } }, [phase]); @@ -1558,29 +1626,34 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { if (e.target.classList.contains('video-overlay')) onClose(); }}>
- ${entry.name} + ${entry.name} (${formatSize(entry.size)})
${phase === 'loading' && html`
-
${t('video.loading', { name: entry.name })}
-
-
-
-
- ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)} -
+
${t('video.buffering')}
`} - ${phase === 'ready' && html` + ${(phase === 'streaming' || phase === 'loading') && html`
`} + ${phase === 'streaming' && progress < 1 && html` +
+
+
+
+ + ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)} + +
+ `} + ${phase === 'error' && html`
${error}
`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 8ea4600..2a91407 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -108,8 +108,10 @@ const en = { // Video player 'video.loading': 'Loading {name}...', + 'video.buffering': 'Buffering...', 'video.close': 'Close (Esc)', 'video.err_transport': 'Transport not connected', + 'video.err_mse': 'Codec not supported for streaming: {codec}', // Settings 'settings.title': 'Settings', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 542c672..faf8b0f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -909,6 +909,31 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } max-width: 400px; } +.video-stream-bar { + position: absolute; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + background: rgba(0, 0, 0, 0.6); + padding: 4px 12px; + border-radius: 12px; + z-index: 10; +} + +.video-progress-bar.small { + width: 120px; + height: 4px; +} + +.video-stream-label { + font-size: 0.7em; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; +} + .play-btn { background: none; border: 1px solid var(--border); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 011c33e..1dc1ded 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -27,11 +27,17 @@ class MeshBayTransport { this._recvBuf = new Uint8Array(0); this._connected = false; this._onChat = null; + this._onStreamInit = null; + this._onStreamData = null; + this._onStreamEnd = null; } get connected() { return this._connected; } set onChat(fn) { this._onChat = fn; } + set onStreamInit(fn) { this._onStreamInit = fn; } + set onStreamData(fn) { this._onStreamData = fn; } + set onStreamEnd(fn) { this._onStreamEnd = fn; } async connect(nodeId, jwtToken, groupId) { this._pc = new RTCPeerConnection({ @@ -186,6 +192,10 @@ class MeshBayTransport { return msg; } + requestStream(fileId) { + this._send({ type: 'stream_req', v: '0.1', file_id: fileId }); + } + async uploadChunk(filename, chunkIndex, totalChunks, data) { const msg = await this._sendAndWait({ type: 'file_upload', @@ -259,6 +269,18 @@ class MeshBayTransport { this._onChat(msg); return; } + if (msg.type === 'stream_init' && this._onStreamInit) { + this._onStreamInit(msg); + return; + } + if (msg.type === 'stream_data' && this._onStreamData) { + this._onStreamData(msg); + return; + } + if (msg.type === 'stream_end' && this._onStreamEnd) { + this._onStreamEnd(msg); + return; + } const oldest = this._pending.entries().next(); if (!oldest.done) { -- cgit v1.2.3