aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 16:47:01 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 16:47:01 +0200
commite608b95bf1fe225915eaeafca2a933f687844733 (patch)
tree255b28a2d667af0729bb7829d6307324c0119fce /packages/meshbay-hub
parentfc509ae281c8cd0aa69870f6123a11e3519fb390 (diff)
downloadmeshbay-e608b95bf1fe225915eaeafca2a933f687844733.tar.gz
feat: Phase 10c — MSE video streaming (real-time playback)
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js177
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js22
4 files changed, 174 insertions, 52 deletions
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();
}}>
<div class="video-top-bar">
- <span class="video-title">${entry.name}</span>
+ <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
<button class="video-close" onClick=${onClose} title="${t('video.close')}">✕</button>
</div>
${phase === 'loading' && html`
<div class="video-loading">
- <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
- <div class="video-progress-bar">
- <div class="video-progress-fill"
- style="width:${Math.round(progress * 100)}%"></div>
- </div>
- <div class="video-progress-text">
- ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)}
- </div>
+ <div class="video-loading-label">${t('video.buffering')}</div>
</div>
`}
- ${phase === 'ready' && html`
+ ${(phase === 'streaming' || phase === 'loading') && html`
<div class="video-container">
<video ref=${videoRef} controls autoplay />
</div>
`}
+ ${phase === 'streaming' && progress < 1 && html`
+ <div class="video-stream-bar">
+ <div class="video-progress-bar small">
+ <div class="video-progress-fill"
+ style="width:${Math.round(progress * 100)}%"></div>
+ </div>
+ <span class="video-stream-label">
+ ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)}
+ </span>
+ </div>
+ `}
+
${phase === 'error' && html`
<div class="video-error">${error}</div>
`}
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) {