summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js177
1 files changed, 125 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>
`}