diff --git a/frontend/direct-media.js b/frontend/direct-media.js
index 069dcaa..9cf6e72 100644
--- a/frontend/direct-media.js
+++ b/frontend/direct-media.js
@@ -16,7 +16,7 @@
if (!file) throw Error('Save this song on this device before sending it');
const hash = root.Sha256.create();
for (let at = 0; at < file.size; at += 1024 * 1024) hash.update(new Uint8Array(await file.slice(at, at + 1024 * 1024).arrayBuffer()));
- const claim = root.DirectProtocol.file({ ...meta, cid: hash.hex(), size: file.size, title: meta.title || meta.id });
+ const claim = root.DirectProtocol.file({ ...meta, cid: hash.hex(), size: file.size, extension: file.name.split('.').pop().toLowerCase(), streamMime: await root.DirectStream?.inspect(file), title: meta.title || meta.id });
if (!claim) throw Error('Unsupported file metadata');
pending.set(`${key}:${to}:${claim.id}`, { file, claim });
send(key, { type: 'direct', action: 'invite', to, file: claim });
@@ -25,6 +25,7 @@
function finish(s, error) {
if (s.done) return; s.done = true; clearTimeout(s.timer); clearTimeout(s.idle);
s.pc?.close(); transfers.delete(s.token);
+ if (error) s.preview?.close(); else s.preview?.end();
if (s.worker) { s.worker.postMessage({ op: 'abort' }); setTimeout(() => s.worker.terminate(), 1000); }
if (error) { waiting.get(s.claim.id)?.resolve(false); waiting.delete(s.claim.id); hooks.notify(`${error}. Partial copy kept for resume.`); hooks.fallback(s.claim, () => offer(s.key, s.peer, s.claim).catch(e => hooks.notify(e.message))); }
}
@@ -44,7 +45,7 @@
try {
if (receiver) {
if (typeof e.data === 'string') { const m = JSON.parse(e.data); if (m.t === 'end') s.worker.postMessage({ op: 'finish' }); else throw Error('Unexpected file control'); }
- else s.worker.postMessage({ op: 'chunk', buf: e.data }, [e.data]);
+ else { s.preview?.push(e.data); s.worker.postMessage({ op: 'chunk', buf: e.data }, [e.data]); }
} else {
if (typeof e.data !== 'string' || s.started) throw Error('Unexpected file request');
const m = JSON.parse(e.data); if (m.t !== 'get') throw Error('Unexpected file request');
@@ -69,6 +70,7 @@
}
async function accept(key, m) {
const s = { key, token: m.token, claim: m.file, peer: m.from, receiver: true }; transfers.set(m.token, s);
+ s.preview = m.file.streamMime ? root.DirectStream?.create(m.file.streamMime, m.file.title) : null;
connection(s, true);
s.worker = new Worker('/direct-recv-worker.js');
s.worker.onmessage = async e => {
diff --git a/frontend/direct-protocol.js b/frontend/direct-protocol.js
index 3e8620e..48585eb 100644
--- a/frontend/direct-protocol.js
+++ b/frontend/direct-protocol.js
@@ -7,7 +7,8 @@
function file(v) {
if (!v || !id(v.id) || !/^[a-f0-9]{64}$/.test(v.cid) || !Number.isSafeInteger(v.size) || v.size <= 0 || v.size > 4 * 1024 ** 3) return null;
if (!text(v.title, 300) || !text(v.channel || '', 200)) return null;
- return { id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
+ const streamMime = typeof v.streamMime === 'string' && /^video\/mp4; codecs="avc1\.[a-f0-9]{6}(, mp4a\.40\.2)?"$/.test(v.streamMime) ? v.streamMime : '';
+ return { streamMime, extension: /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(v.extension) ? v.extension : 'mp4', id: v.id, cid: v.cid, size: v.size, title: v.title, channel: v.channel || '', duration: Number.isFinite(v.duration) && v.duration >= 0 ? v.duration : 0 };
}
function parse(value) {
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
diff --git a/frontend/direct-recv-worker.js b/frontend/direct-recv-worker.js
index aeafe54..da6c14e 100644
--- a/frontend/direct-recv-worker.js
+++ b/frontend/direct-recv-worker.js
@@ -27,11 +27,13 @@ async function run(m) {
else if (m.op === 'finish') {
await close();
if (pos !== claim.size || hash.hex() !== claim.cid) { await dir.removeEntry(name); throw Error('File verification failed; retry starts from zero'); }
- const final = `${claim.id}.mp4`;
+ const extension = /^(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(claim.extension) ? claim.extension : 'mp4';
+ const final = `${claim.id}.${extension}`;
// Copy only after verification; existing saved playback remains intact until then.
const out = await (await dir.getFileHandle(final, { create: true })).createSyncAccessHandle();
try { const f = await handle.getFile(); for (let at = 0; at < f.size; at += 1024 * 1024) out.write(new Uint8Array(await f.slice(at, at + 1024 * 1024).arrayBuffer()), { at }); out.truncate(f.size); out.flush(); } finally { out.close(); }
await dir.removeEntry(name);
+ for await (const [old, entry] of dir.entries()) { if (entry.kind === 'file' && old !== final && old.startsWith(claim.id + '.') && /\.(mp4|m4a|webm|ogg|opus|mkv|mp3|wav|aac)$/.test(old)) await dir.removeEntry(old); }
self.postMessage({ op: 'done', file: claim });
}
} catch (e) { await close(); self.postMessage({ op: 'error', error: e.message }); }
diff --git a/frontend/direct-stream.js b/frontend/direct-stream.js
new file mode 100644
index 0000000..696a160
--- /dev/null
+++ b/frontend/direct-stream.js
@@ -0,0 +1,44 @@
+/* Progressive preview is only safe for fragmented MP4, never ordinary MP4 chunks. */
+(function (root) {
+ 'use strict';
+ function inspectBytes(bytes) {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const str = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
+ let at = 0, moov = null;
+ while (at + 8 <= bytes.length) {
+ const size = view.getUint32(at); if (size < 8 || at + size > bytes.length) break;
+ if (str(at + 4, 4) === 'moov') { moov = bytes.subarray(at + 8, at + size); break; }
+ at += size;
+ }
+ if (!moov) return null;
+ const tagAt = (tag) => { for (let i = 4; i + 4 <= moov.length; i++) if (String.fromCharCode(...moov.subarray(i, i + 4)) === tag) return i; return -1; };
+ if (tagAt('mvex') < 0) return null;
+ const avc = tagAt('avcC'), audio = tagAt('mp4a');
+ if (avc < 0 || avc + 8 > moov.length || moov[avc + 4] !== 1) return null;
+ const codec = Array.from(moov.subarray(avc + 5, avc + 8), b => b.toString(16).padStart(2, '0')).join('');
+ return `video/mp4; codecs="avc1.${codec}${audio >= 0 ? ', mp4a.40.2' : ''}"`;
+ }
+ async function inspect(file) { return inspectBytes(new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer())); }
+ function create(mime, title) {
+ if (!root.MediaSource?.isTypeSupported(mime)) return null;
+ const media = new MediaSource(), url = URL.createObjectURL(media), queue = [];
+ let buffer, queued = 0, ended = false, stopped = false;
+ const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
+ const heading = document.createElement('h2'); heading.textContent = title;
+ const note = document.createElement('p'); note.textContent = 'Direct preview ยท the full copy is still being saved and verified. Tap Play when ready.';
+ const video = document.createElement('video'); video.controls = true; video.playsInline = true; video.src = url; video.style.width = '100%';
+ const close = document.createElement('button'); close.textContent = 'Close preview';
+ const dispose = () => { stopped = true; queue.length = 0; video.pause(); video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url); dialog.remove(); };
+ close.onclick = dispose; dialog.oncancel = e => { e.preventDefault(); dispose(); }; dialog.append(heading, note, video, close); document.body.append(dialog); dialog.showModal();
+ function flush() {
+ if (stopped || !buffer || buffer.updating) return;
+ if (queue.length) { const b = queue.shift(); queued -= b.byteLength; try { buffer.appendBuffer(b); } catch { dispose(); } }
+ else if (ended && media.readyState === 'open') { try { media.endOfStream(); } catch {} }
+ }
+ media.addEventListener('sourceopen', () => { try { buffer = media.addSourceBuffer(mime); buffer.addEventListener('updateend', flush); buffer.addEventListener('error', dispose); flush(); } catch { dispose(); } }, { once: true });
+ return { push(bytes) { if (stopped) return; queued += bytes.byteLength; if (queued > 8 * 1024 * 1024) { dispose(); return; } queue.push(bytes.slice(0)); flush(); }, end() { ended = true; flush(); }, close: dispose };
+ }
+ const api = { inspectBytes, inspect, create };
+ if (typeof module !== 'undefined') module.exports = api;
+ root.DirectStream = api;
+}(globalThis));
diff --git a/frontend/direct-stream.test.js b/frontend/direct-stream.test.js
new file mode 100644
index 0000000..203e251
--- /dev/null
+++ b/frontend/direct-stream.test.js
@@ -0,0 +1,12 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { inspectBytes } = require('./direct-stream.js');
+function box(type, payload = Buffer.alloc(0)) { const b = Buffer.alloc(8 + payload.length); b.writeUInt32BE(b.length); b.write(type, 4); payload.copy(b, 8); return b; }
+test('progressive playback only accepts a complete fragmented MP4 initialization', () => {
+ const codec = box('avcC', Buffer.from([1, 0x64, 0, 0x1f]));
+ const fragmented = Buffer.concat([box('ftyp'), box('moov', Buffer.concat([box('mvex'), codec, box('mp4a')]))]);
+ assert.equal(inspectBytes(fragmented), 'video/mp4; codecs="avc1.64001f, mp4a.40.2"');
+ assert.equal(inspectBytes(Buffer.concat([box('ftyp'), box('moov', codec)])), null);
+ assert.equal(inspectBytes(fragmented.subarray(0, fragmented.length - 1)), null);
+ assert.equal(inspectBytes(Buffer.alloc(0)), null);
+});
diff --git a/frontend/index.html b/frontend/index.html
index 67fd73f..adacb2a 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -662,6 +662,7 @@
+
diff --git a/frontend/sw.js b/frontend/sw.js
index 5b9ee6f..37532de 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -84,6 +84,7 @@ const SHELL = [
'/direct-media.css',
'/direct-protocol.js',
'/direct-media.js',
+ '/direct-stream.js',
'/direct-recv-worker.js',
'/p2p-transfer.js',
'/p2p-recv-worker.js',