From 283196938e7765f425c7f99a6580876b40707590 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 14:35:41 +0800 Subject: [PATCH] feat(web): stream video and audio while decrypting, paste images, 16px inputs on touch --- web/public/stream-sw.js | 79 +++++++++++ web/src/core-bridge/streamClient.ts | 48 +++++++ web/src/core-bridge/worker/playbackStream.ts | 125 ++++++++++++++++++ web/src/core-bridge/worker/presentation.ts | 6 +- web/src/features/channel/ChatAttachment.tsx | 13 +- web/src/features/channel/ChatPane.module.css | 24 ++++ web/src/features/channel/ChatPane.tsx | 19 ++- .../features/item/previews/MediaPreview.tsx | 21 ++- web/src/features/item/source.ts | 9 ++ web/src/features/item/useMediaUrl.ts | 20 +++ web/src/features/item/useStreamUrl.ts | 22 +++ web/src/styles/base.css | 6 +- 12 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 web/public/stream-sw.js create mode 100644 web/src/core-bridge/streamClient.ts create mode 100644 web/src/core-bridge/worker/playbackStream.ts create mode 100644 web/src/features/item/useMediaUrl.ts create mode 100644 web/src/features/item/useStreamUrl.ts diff --git a/web/public/stream-sw.js b/web/public/stream-sw.js new file mode 100644 index 0000000..25e2012 --- /dev/null +++ b/web/public/stream-sw.js @@ -0,0 +1,79 @@ +// 加密视频/音频的边下边播。 +// 播放器请求 /__wd_stream/(带 Range),这里不联网:把「要哪一段」告诉发起请求的页面, +// 页面把一个 MessagePort 交给加密线程,加密线程解密出这一段后一块一块从端口推回来, +// 这里再把它们作为 206 响应流式交给播放器。密钥始终只在加密线程里。 +const PREFIX = '/__wd_stream/'; +/** 一次最多让加密线程领先播放器多少块,避免整个文件都堆在内存里 */ +const WINDOW = 6; + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(PREFIX)) return; + event.respondWith(serve(event).catch((error) => new Response(String(error && error.message || error), { status: 500 }))); +}); + +function parseRange(header) { + const m = /^bytes=(\d*)-(\d*)$/.exec(header || ''); + if (!m) return { start: 0, end: undefined }; + if (m[1] === '' && m[2] !== '') return { suffix: Number(m[2]) }; + return { start: Number(m[1] || 0), end: m[2] === '' ? undefined : Number(m[2]) }; +} + +async function serve(event) { + const id = decodeURIComponent(new URL(event.request.url).pathname.slice(PREFIX.length)); + const client = event.clientId ? await self.clients.get(event.clientId) : undefined; + const target = client ?? (await self.clients.matchAll({ type: 'window' }))[0]; + if (!target) return new Response('no client', { status: 503 }); + const range = parseRange(event.request.headers.get('Range')); + + const channel = new MessageChannel(); + const port = channel.port1; + // 第一条消息:{ total, mime, start, end } 或 { error };之后是 { data } / { done } / { error } + const first = new Promise((resolve) => { + port.onmessage = (message) => resolve(message.data); + }); + target.postMessage({ type: 'wd-stream', id, range, port: channel.port2 }, [channel.port2]); + const head = await Promise.race([first, new Promise((resolve) => setTimeout(() => resolve({ error: 'timeout' }), 20000))]); + if (head.error) return new Response(head.error, { status: head.status || 502 }); + + const { total, mime, start, end } = head; + let pending = []; + let waiting; + let finished = false; + let failure; + port.onmessage = (message) => { + const m = message.data; + if (m.data) pending.push(m.data); + else if (m.done) finished = true; + else if (m.error) failure = m.error; + if (waiting) { const wake = waiting; waiting = undefined; wake(); } + }; + port.postMessage({ credit: WINDOW }); + + const body = new ReadableStream({ + async pull(controller) { + while (!pending.length && !finished && !failure) await new Promise((resolve) => { waiting = resolve; }); + if (pending.length) { + controller.enqueue(new Uint8Array(pending.shift())); + port.postMessage({ credit: 1 }); + } else if (failure) { controller.error(new Error(failure)); port.close(); } + else { controller.close(); port.close(); } + }, + cancel() { port.postMessage({ cancel: true }); port.close(); }, + }); + + const partial = !(start === 0 && end === total - 1 && !event.request.headers.get('Range')); + return new Response(body, { + status: partial ? 206 : 200, + headers: { + 'Content-Type': mime || 'application/octet-stream', + 'Content-Length': String(end - start + 1), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + ...(partial ? { 'Content-Range': `bytes ${start}-${end}/${total}` } : {}), + }, + }); +} diff --git a/web/src/core-bridge/streamClient.ts b/web/src/core-bridge/streamClient.ts new file mode 100644 index 0000000..466d583 --- /dev/null +++ b/web/src/core-bridge/streamClient.ts @@ -0,0 +1,48 @@ +import { postToWorker } from './workerClient'; + +/** + * 页面这一侧的「边下边播」:把条目登记成一个 /__wd_stream/ 地址交给