Prefer direct copies for playback, saves and exports within verified device groups
This commit is contained in:
@@ -1795,18 +1795,24 @@ function recordDeviceFile(id, res) {
|
||||
const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed';
|
||||
const now = Date.now();
|
||||
window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state })
|
||||
.then(() => { if (window.P2PClient) window.P2PClient.changed(); })
|
||||
.then(() => { if (window.P2PClient) window.P2PClient.changed(); window.DirectMedia?.publish(); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Download a video into the permanent offline cache. Safe to call repeatedly.
|
||||
async function preload(video, { quiet = false, mux = false, retry = false } = {}) {
|
||||
async function preload(video, { quiet = false, mux = false, retry = false, directFallback = false } = {}) {
|
||||
const id = video.id;
|
||||
// Custom (edited) videos have no YouTube source to (re)download — their
|
||||
// media is produced once by the editor. Never route them through the normal
|
||||
// cache-download path (a fake edit_… id would 404 on /api/download).
|
||||
if (video.custom) return;
|
||||
if (!id || cachedIds.has(id) || downloading.has(id)) return;
|
||||
if (!directFallback && window.DirectMedia?.hasSource(id)) {
|
||||
const received = await DirectMedia.obtain(id);
|
||||
if (received) return;
|
||||
toast('Direct transfer did not complete. Choose Use server copy in the transfer message to download through the server.');
|
||||
return;
|
||||
}
|
||||
downloading.add(id);
|
||||
downloadMeta.set(id, slim(video));
|
||||
markCardCacheState(id, 'downloading');
|
||||
@@ -2259,6 +2265,11 @@ const Player = {
|
||||
_currentBlobUrl = null;
|
||||
}
|
||||
try {
|
||||
if (WEB && !cachedIds.has(videoObj.id) && window.DirectMedia?.hasSource(videoObj.id)) {
|
||||
const received = await DirectMedia.obtain(videoObj.id);
|
||||
if (seq !== this._loadSeq) return;
|
||||
if (!received) { showSpinner(false); return; }
|
||||
}
|
||||
// Play from the offline cache when available — instant and works offline.
|
||||
if (!preferStream && cachedIds.has(videoObj.id)) {
|
||||
let localUrl = null;
|
||||
@@ -3104,6 +3115,7 @@ async function renderAvailability(id) {
|
||||
// Fetch a verified copy from another device (docs/p2p-architecture.md flow 6).
|
||||
// Download-then-play: on success the file is saved offline like any save.
|
||||
async function getFromPeers(videoObj, c, peers, onProgress) {
|
||||
if (window.DirectMedia?.enabled()) { const ok = await DirectMedia.obtain(videoObj.id); if (!ok) toast('Pair the source device through Remote or use the same profile to receive its saved copy.'); return !!ok; }
|
||||
const id = videoObj.id;
|
||||
downloading.add(id);
|
||||
markCardCacheState(id, 'downloading');
|
||||
@@ -4781,6 +4793,7 @@ const Remote = (() => {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '' } : null,
|
||||
cur: M ? Math.floor((M.currentTime || 0) * 10) / 10 : 0,
|
||||
dur: (M && isFinite(M.duration) && M.duration) || (meta && meta.duration) || 0,
|
||||
@@ -5063,7 +5076,7 @@ const Remote = (() => {
|
||||
}
|
||||
|
||||
function onRemoteMessage(m) {
|
||||
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen' }] });
|
||||
DirectMedia.attach('remote-client', { send: rcSend, peers: () => [{ id: 'host', name: 'Paired screen', files: rc.state?.directAvailable && rc.state?.v ? [rc.state.v.id] : [] }] });
|
||||
if (m.type === 'direct') { DirectMedia.handle('remote-client', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
rc.online = true;
|
||||
@@ -5103,6 +5116,11 @@ const Remote = (() => {
|
||||
const v = s && s.v;
|
||||
$('rvTitle').textContent = v ? v.title : 'Nothing playing';
|
||||
$('rvChannel').textContent = v ? v.channel : '';
|
||||
let directPlay = $('rvDirectPlay');
|
||||
if (!directPlay) { directPlay = document.createElement('button'); directPlay.id = 'rvDirectPlay'; directPlay.className = 'np-btn'; directPlay.style.minHeight = '44px'; directPlay.textContent = 'Play here from paired screen'; $('rvChannel').after(directPlay); }
|
||||
directPlay.hidden = !v || !s.directAvailable || !DirectMedia.enabled();
|
||||
directPlay.onclick = () => v && playVideoAt({ ...v, duration: s.dur || 0 }, liveCur());
|
||||
|
||||
const thumb = $('rvThumb');
|
||||
if (v && v.thumbnail) { if (thumb.getAttribute('src') !== v.thumbnail) thumb.src = v.thumbnail; thumb.classList.remove('hidden'); }
|
||||
else thumb.classList.add('hidden');
|
||||
@@ -6073,6 +6091,8 @@ const Presenter = (() => {
|
||||
st.ws = ws;
|
||||
ws.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch { return; }
|
||||
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
|
||||
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); return; }
|
||||
if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); }
|
||||
else if (m.type === 'state') onState(m.state);
|
||||
else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
|
||||
@@ -7456,7 +7476,7 @@ 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 })) });
|
||||
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
|
||||
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
|
||||
if (m.type === 'hello') {
|
||||
st.code = m.code;
|
||||
@@ -7507,6 +7527,7 @@ const Party = (() => {
|
||||
const meta = current && current.meta;
|
||||
const M = Player.master;
|
||||
return {
|
||||
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
|
||||
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
|
||||
cur: M ? M.currentTime || 0 : 0,
|
||||
paused: !M || M.paused,
|
||||
@@ -12533,10 +12554,18 @@ 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,
|
||||
DirectMedia.configure({ settings: () => data.settings, persist, current: () => current?.meta, meta: id => data.history.find(v => v.id === id) || data.playlists.flatMap(p => p.videos).find(v => v.id === id), 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 }); } }]); },
|
||||
});
|
||||
// Resolve peer bytes before opening the existing native export sheet.
|
||||
// The sheet itself and its user-gesture actions remain unchanged.
|
||||
const exportWithExistingSheet = exportToDevice;
|
||||
exportToDevice = async (...args) => {
|
||||
const video = args[0];
|
||||
if (video?.id && !cachedIds.has(video.id) && DirectMedia.hasSource(video.id) && !await DirectMedia.obtain(video.id)) return;
|
||||
return exportWithExistingSheet(...args);
|
||||
};
|
||||
Remote.boot();
|
||||
LyricsDisplay.register();
|
||||
LowerThird.register(() => $('remoteChip').click());
|
||||
@@ -12571,7 +12600,7 @@ async function boot() {
|
||||
startCacheResyncWatch();
|
||||
// Peer-to-peer: report what this device holds (on by default; settings.p2pShare).
|
||||
if (WEB && window.P2PClient) {
|
||||
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' });
|
||||
window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '', getProfileSecret: () => ProfileSecret.getFor(data.profile?.name) });
|
||||
if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => data.settings.p2pShare !== false });
|
||||
}
|
||||
data.playlists.forEach(preloadPlaylist);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Paired-room transport; signalling only goes to the server. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const waiting = new Map();
|
||||
let profilePeers = [];
|
||||
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;
|
||||
@@ -24,7 +26,7 @@
|
||||
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))); }
|
||||
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))); }
|
||||
}
|
||||
function connection(s, receiver) {
|
||||
const pc = s.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
@@ -77,13 +79,14 @@
|
||||
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);
|
||||
await hooks.saved(result.file); waiting.get(result.file.id)?.resolve(true); waiting.delete(result.file.id); publish(); 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;
|
||||
if (m.action === 'request') { if (enabled()) offer(key, m.from, hooks.meta?.(m.id) || { id: m.id, title: m.id }).catch(e => hooks.notify(e.message)); return; }
|
||||
try {
|
||||
if (m.action === 'invite') {
|
||||
if (!enabled() || !root.DirectProtocol.file(m.file)) return send(key, { type: 'direct', action: 'decline', token: m.token });
|
||||
@@ -114,15 +117,37 @@
|
||||
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(); };
|
||||
const decline = () => { waiting.get(m.file.id)?.resolve(false); waiting.delete(m.file.id); 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();
|
||||
}
|
||||
async function publish() {
|
||||
if (!root.P2PClient?.isConnected()) return;
|
||||
const files = await root.OPFS.listVideos({ strict: true });
|
||||
root.P2PClient.send({ type: 'direct-inventory', ids: enabled() && hooks.settings().p2pShare !== false ? files.map(f => f.id) : [] });
|
||||
}
|
||||
function source(id) { if (!enabled()) return null; for (const [key, room] of rooms) { const peer = room.peers().find(p => p.files?.includes(id)); if (peer) return { key, peer }; } return null; }
|
||||
function hasSource(id) { return !!source(id); }
|
||||
function obtain(id) {
|
||||
if (!hasSource(id)) return Promise.resolve(null);
|
||||
if (waiting.has(id)) return waiting.get(id).promise;
|
||||
const { key, peer } = source(id);
|
||||
let resolve; const promise = new Promise(r => { resolve = r; });
|
||||
waiting.set(id, { promise, resolve });
|
||||
send(key, { type: 'direct', action: 'request', to: peer.id, id });
|
||||
setTimeout(() => { if (waiting.get(id)?.promise === promise) { waiting.delete(id); resolve(false); hooks.notify('The other device did not accept or is unavailable'); hooks.fallback(hooks.meta?.(id) || { id, title: id }); } }, 60000);
|
||||
return promise;
|
||||
}
|
||||
function configure(h) {
|
||||
hooks = { ...hooks, ...h };
|
||||
if (root.P2PClient) {
|
||||
root.P2PClient.onMessage('hello', () => { attach('profile', { send: root.P2PClient.send, peers: () => profilePeers }); publish(); });
|
||||
root.P2PClient.onMessage('direct-peers', m => { profilePeers = m.peers || []; });
|
||||
root.P2PClient.onMessage('direct', m => handle('profile', m));
|
||||
}
|
||||
root.SettingsSections.register({ id: 'direct-transfer', title: 'Direct device transfer', cluster: 'Library & storage', summary: () => enabled() ? 'On · paired devices only' : 'Off or unsupported', icon: '<path d="M4 6h6v12H4zM14 6h6v12h-6M10 10h4m-4 4h4"/>', render(container) {
|
||||
const label = document.createElement('label'); const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = hooks.settings().directTransfer !== false;
|
||||
toggle.onchange = () => { hooks.settings().directTransfer = toggle.checked; hooks.persist(); }; label.append(toggle, ' Direct device transfer (P2P)');
|
||||
toggle.onchange = () => { hooks.settings().directTransfer = toggle.checked; hooks.persist(); publish(); }; label.append(toggle, ' Direct device transfer (P2P)');
|
||||
const help = document.createElement('p'); help.textContent = 'Pair through Remote or join a Watch Party, then send your currently playing saved song. The receiver confirms before saving. Keep both devices open. No TURN relay is used.';
|
||||
container.append(label, help);
|
||||
for (const [key, r] of rooms) for (const peer of r.peers()) {
|
||||
@@ -130,5 +155,5 @@
|
||||
}
|
||||
} });
|
||||
}
|
||||
root.DirectMedia = { configure, attach, handle, offer, enabled };
|
||||
root.DirectMedia = { configure, attach, handle, offer, enabled, obtain, hasSource, publish };
|
||||
}(globalThis));
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
if (typeof value === 'string') { if (value.length > 65536) return null; try { value = JSON.parse(value); } catch { return null; } }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value.type !== 'direct') return null;
|
||||
const m = { type: 'direct', action: value.action };
|
||||
if (value.action === 'request') return id(value.to) && id(value.id) ? { ...m, to: value.to, id: value.id } : null;
|
||||
if (value.action === 'invite') {
|
||||
const f = file(value.file); if (!id(value.to) || !f) return null;
|
||||
return { ...m, to: value.to, file: f };
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
ws = sock;
|
||||
let ping = null;
|
||||
sock.onopen = () => {
|
||||
sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret }));
|
||||
sock.send(JSON.stringify({ type: 'auth', device: d.deviceId, secret: d.secret, profile: hooks.getProfile(), profileSecret: hooks.getProfileSecret?.() || '' }));
|
||||
// The server drops sockets idle for 120 s (server.js websocketHandler).
|
||||
ping = setInterval(() => { try { sock.send('{"type":"ping"}'); } catch { /* closing */ } }, 50_000);
|
||||
};
|
||||
@@ -304,6 +304,7 @@
|
||||
|
||||
window.P2PClient = {
|
||||
start, changed, sync, device, authHeaders, config, contribute, unknownVideos,
|
||||
send: m => { if (!ws || ws.readyState !== 1) return false; ws.send(JSON.stringify(m)); return true; },
|
||||
onMessage, signal, peer: () => myPeer, isConnected: () => !!(ws && ws.readyState === 1 && myPeer),
|
||||
};
|
||||
}());
|
||||
|
||||
Reference in New Issue
Block a user