diff --git a/frontend/app.js b/frontend/app.js
index 6bf57ca..7e64f63 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -4754,6 +4754,8 @@ const Remote = (() => {
const hostSend = (m) => { if (host.ws && host.ws.readyState === 1) host.ws.send(JSON.stringify(m)); };
function onHostMessage(m) {
+ DirectMedia.attach('remote-host', { send: hostSend, peers: () => host.remotes.map(r => ({ id: r.rid, name: r.name })) });
+ if (m.type === 'direct') { DirectMedia.handle('remote-host', m); return; }
if (m.type === 'hello') {
host.code = m.code; host.codeExpires = m.codeExpires; host.remotes = m.remotes || [];
host.lastState = ''; host.lastQueue = '';
@@ -5061,6 +5063,8 @@ const Remote = (() => {
}
function onRemoteMessage(m) {
+ DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen' }] });
+ if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
if (m.type === 'hello') {
rc.online = true;
$('rvHostName').textContent = m.hostName || 'Screen';
@@ -7452,6 +7456,8 @@ const Party = (() => {
}
function onMessage(m) {
+ DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name })) });
+ if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
if (m.type === 'hello') {
st.code = m.code;
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
@@ -12527,6 +12533,10 @@ async function boot() {
Party.boot();
PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
+ DirectMedia.configure({ settings: () => data.settings, persist, current: () => current?.meta, notify: toast,
+ saved: async file => { cachedIds.add(file.id); await DeviceDB.putFile({ videoId: file.id, cid: file.cid, size: file.size, savedAt: Date.now(), state: 'unverified' }); if (!data.history.some(v => v.id === file.id)) data.history.unshift({ ...file, thumbnail: '' }); persist(); renderSidebar(); },
+ fallback: file => { const body = document.createElement('p'); body.textContent = 'The devices could not finish a direct connection. Keep both screens open and send again to resume, or choose the existing server download.'; showModal('Direct transfer unavailable', body, [{ label: 'Keep partial copy', onClick: closeModal }, { label: 'Use server copy', onClick: () => { closeModal(); preload(file, { retry: true, directFallback: true }); } }]); },
+ });
Remote.boot();
LyricsDisplay.register();
LowerThird.register(() => $('remoteChip').click());
diff --git a/frontend/direct-media.css b/frontend/direct-media.css
new file mode 100644
index 0000000..1b3742b
--- /dev/null
+++ b/frontend/direct-media.css
@@ -0,0 +1,6 @@
+.direct-transfer-dialog{max-width:min(420px,calc(100vw - 32px));padding:24px;border:1px solid var(--border);border-radius:var(--radius,16px);background:var(--surface,#202026);color:var(--text,#fff)}
+.direct-transfer-dialog::backdrop{background:rgba(0,0,0,.65)}
+.direct-transfer-dialog p{line-height:1.6;color:var(--text-2);overflow-wrap:anywhere}
+.direct-transfer-dialog button,#settings-section-direct-transfer button{min-height:44px;padding:10px 16px;margin:4px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2,#303039);color:var(--text,#fff)}
+.direct-transfer-dialog button:focus-visible,#settings-section-direct-transfer button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+#settings-section-direct-transfer label{display:flex;align-items:center;gap:12px;min-height:44px}
diff --git a/frontend/direct-media.js b/frontend/direct-media.js
new file mode 100644
index 0000000..7077115
--- /dev/null
+++ b/frontend/direct-media.js
@@ -0,0 +1,134 @@
+/* Paired-room transport; signalling only goes to the server. */
+(function (root) {
+ 'use strict';
+ const rooms = new Map(), transfers = new Map(), pending = new Map();
+ let hooks = { settings: () => ({}), persist() {}, current: () => null, saved() {}, notify() {}, fallback() {} };
+ const enabled = () => hooks.settings().directTransfer !== false && !!root.RTCPeerConnection;
+ function attach(key, room) { rooms.set(key, room); }
+ function send(key, m) { const r = rooms.get(key); if (!r) throw Error('Paired device disconnected'); r.send(m); }
+ function signal(key, token, data) { send(key, { type: 'direct', action: 'signal', token, data }); }
+ async function offer(key, to, meta) {
+ if (!enabled()) throw Error('Direct device transfer is unavailable or turned off');
+ if (!meta?.id) throw Error('Choose a saved song first');
+ const file = await root.OPFS.getFileObject(meta.id);
+ 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 });
+ if (!claim) throw Error('Unsupported file metadata');
+ pending.set(`${key}:${to}:${claim.id}`, { file, claim });
+ send(key, { type: 'direct', action: 'invite', to, file: claim });
+ hooks.notify('Waiting for the receiving device to accept');
+ }
+ 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 (s.worker) { s.worker.postMessage({ op: 'abort' }); setTimeout(() => s.worker.terminate(), 1000); }
+ if (error) { 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))); }
+ }
+ function connection(s, receiver) {
+ const pc = s.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
+ s.ice = [];
+ pc.onicecandidate = e => { if (e.candidate) signal(s.key, s.token, { kind: 'ice', candidate: e.candidate.toJSON() }); };
+ s.timer = setTimeout(() => finish(s, 'Direct connection failed after 10 seconds'), 10000);
+ pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) finish(s, 'Direct connection closed'); };
+ const bump = () => { clearTimeout(s.idle); s.idle = setTimeout(() => finish(s, 'Direct transfer stalled'), 30000); };
+ const channel = dc => {
+ s.dc = dc; dc.binaryType = 'arraybuffer'; dc.bufferedAmountLowThreshold = 1024 * 1024;
+ dc.onopen = () => { clearTimeout(s.timer); bump(); if (receiver && s.offset != null) dc.send(JSON.stringify({ t: 'get', offset: s.offset })); };
+ dc.onclose = () => { if (!s.done) setTimeout(() => { if (!s.done) finish(s, 'Direct channel closed'); }, 3000); };
+ dc.onmessage = async e => {
+ bump();
+ 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 {
+ 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');
+ let pos = root.DirectProtocol.offset(m.offset, s.file.size); s.started = true;
+ while (pos < s.file.size && !s.done) {
+ if (dc.bufferedAmount > 4 * 1024 * 1024) await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => { cleanup(); reject(Error('Peer stopped receiving')); }, 10000);
+ const cleanup = () => { clearTimeout(timeout); dc.removeEventListener('bufferedamountlow', low); dc.removeEventListener('close', closed); };
+ const low = () => { cleanup(); resolve(); }, closed = () => { cleanup(); reject(Error('Peer disconnected')); };
+ dc.addEventListener('bufferedamountlow', low); dc.addEventListener('close', closed);
+ });
+ const range = root.DirectProtocol.nextChunk(pos, s.file.size);
+ dc.send(await s.file.slice(range.start, range.end).arrayBuffer()); pos = range.end; bump();
+ }
+ if (!s.done) dc.send(JSON.stringify({ t: 'end' }));
+ }
+ } catch (err) { finish(s, err.message); }
+ };
+ };
+ if (receiver) channel(pc.createDataChannel('file', { ordered: true })); else pc.ondatachannel = e => channel(e.channel);
+ return pc;
+ }
+ async function accept(key, m) {
+ const s = { key, token: m.token, claim: m.file, peer: m.from, receiver: true }; transfers.set(m.token, s);
+ connection(s, true);
+ s.worker = new Worker('/direct-recv-worker.js');
+ s.worker.onmessage = async e => {
+ const result = e.data;
+ if (result.op === 'opened') {
+ s.offset = result.offset;
+ send(key, { type: 'direct', action: 'accept', token: m.token });
+ const offer = await s.pc.createOffer(); await s.pc.setLocalDescription(offer); signal(key, m.token, { kind: 'offer', sdp: offer.sdp });
+ } else if (result.op === 'done') {
+ send(key, { type: 'direct', action: 'complete', token: m.token });
+ await hooks.saved(result.file); hooks.notify(`Saved ${result.file.title} directly from the other device`); finish(s);
+ } else if (result.op === 'error') finish(s, result.error);
+ };
+ s.worker.postMessage({ op: 'open', file: m.file });
+ }
+ async function handle(key, m) {
+ if (m.type !== 'direct') return;
+ try {
+ if (m.action === 'invite') {
+ if (!enabled() || !root.DirectProtocol.file(m.file)) return send(key, { type: 'direct', action: 'decline', token: m.token });
+ confirmReceive(key, m); return;
+ }
+ if (m.action === 'invited') {
+ const p = pending.get(`${key}:${m.to}:${m.file.id}`); if (!p) return;
+ pending.delete(`${key}:${m.to}:${m.file.id}`);
+ transfers.set(m.token, { key, token: m.token, peer: m.to, claim: p.claim, file: p.file }); return;
+ }
+ const s = transfers.get(m.token); if (!s || s.key !== key || m.from !== s.peer) return;
+ if (m.action === 'accept') connection(s, false);
+ else if (m.action === 'decline' || m.action === 'complete') finish(s);
+ else if (m.action === 'signal' && s.pc) {
+ const d = m.data;
+ if (d.kind === 'ice') { if (s.pc.remoteDescription) await s.pc.addIceCandidate(d.candidate); else s.ice.push(d.candidate); }
+ else {
+ await s.pc.setRemoteDescription({ type: d.kind, sdp: d.sdp });
+ for (const c of s.ice.splice(0)) await s.pc.addIceCandidate(c);
+ if (d.kind === 'offer') { const a = await s.pc.createAnswer(); await s.pc.setLocalDescription(a); signal(key, m.token, { kind: 'answer', sdp: a.sdp }); }
+ }
+ }
+ } catch (e) { const s = transfers.get(m.token); if (s) finish(s, e.message); else hooks.notify(e.message); }
+ }
+ function confirmReceive(key, m) {
+ const dialog = document.createElement('dialog'); dialog.className = 'direct-transfer-dialog';
+ const heading = document.createElement('h2'); heading.textContent = 'Save from your paired device?';
+ const text = document.createElement('p'); text.textContent = `${m.file.title} · ${(m.file.size / 1024 ** 2).toFixed(1)} MB. Media travels directly between devices. Keep both screens open. Playback starts after the full copy is verified.`;
+ const yes = document.createElement('button'); yes.textContent = 'Accept & save';
+ const no = document.createElement('button'); no.textContent = 'Decline';
+ const decline = () => { send(key, { type: 'direct', action: 'decline', token: m.token }); dialog.remove(); };
+ yes.onclick = () => { dialog.remove(); accept(key, m).catch(e => hooks.notify(e.message)); }; no.onclick = decline; dialog.oncancel = decline;
+ dialog.append(heading, text, yes, no); document.body.append(dialog); dialog.showModal();
+ }
+ function configure(h) {
+ hooks = { ...hooks, ...h };
+ root.SettingsSections.register({ id: 'direct-transfer', title: 'Direct device transfer', cluster: 'Library & storage', summary: () => enabled() ? 'On · paired devices only' : 'Off or unsupported', icon: '