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',