Add save-to-device export, server export route and an On other devices P2P list

This commit is contained in:
Claude
2026-10-01 18:53:00 +00:00
parent 08e95814e5
commit 02b337fcbc
10 changed files with 385 additions and 0 deletions

View File

@@ -7179,6 +7179,7 @@ function renderList() {
setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted);
if (view.type === 'settings') { renderSettings(); return; }
if (view.type === 'saved') { renderSaved(); return; }
if (view.type === 'p2p') { renderP2pList(); return; }
if (view.type === 'downloads') { renderDownloads(); return; }
if (view.type === 'stats') { renderStats(); return; }
if (view.type === 'notes') { renderNotesView(); return; }
@@ -7391,6 +7392,180 @@ function addBrowseCta(empty, label) {
// ============================================================================
// Saved videos page — every offline-cached file with sizes + totals
// ============================================================================
// ============================================================================
// Save to this device (gallery / Files / Downloads) — docs: export.js
// ============================================================================
// Sources, best first: the copy already saved in this browser; the server's copy
// (direct download on desktop, via the saved copy on phones so the share sheet
// can put it in Photos); another device that holds it; finally a normal save
// through the server. Buttons appear only when the file is ready so each one
// runs inside its own tap (the share sheet needs a fresh user gesture).
async function exportToDevice(video) {
if (!video || !video.id || !window.DeviceExport) return;
const id = video.id;
const title = video.title || videoTitleById(id) || id;
const box = document.createElement('div');
box.className = 'export-box';
box.innerHTML = '<div class="export-status" role="status"></div><div class="export-bar"><i></i></div><div class="export-actions"></div>';
const status = box.querySelector('.export-status');
const bar = box.querySelector('.export-bar');
const fill = bar.firstChild;
const actions = box.querySelector('.export-actions');
const say = (t) => { status.textContent = t; };
const pct = (p) => { bar.classList.remove('busy'); fill.style.width = Math.max(0, Math.min(100, p)) + '%'; };
const busy = () => { bar.classList.add('busy'); fill.style.width = ''; };
const button = (label, primary, fn) => {
const b = document.createElement('button');
b.className = 'btn' + (primary ? ' primary' : '');
b.textContent = label;
b.onclick = fn;
actions.appendChild(b);
return b;
};
let open = true;
showModal('Save to this device', box, [{ label: 'Close', onClick: () => { open = false; closeModal(); } }]);
const DE = window.DeviceExport;
const done = (msg) => { open = false; closeModal(); toast(msg, { duration: 4500 }); };
const ready = (file) => {
const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1];
const name = DE.fileName(title, ext, id);
pct(100);
say(`${name} · ${fmtBytes(file.size)}`);
actions.innerHTML = '';
if (DE.canShareFile(file)) {
button('Save to Photos / Files', true, async () => {
try { if (await DE.share(file, name, title) === 'shared') done('Shared ✓ — choose “Save Video” or “Save to Files”'); }
catch (e) { say('Could not open the share sheet: ' + (e && e.message ? e.message : e)); }
});
}
if (DE.canPickLocation()) {
button('Choose where to save…', !DE.canShareFile(file), async () => {
try { if (await DE.pick(file, name) === 'saved') done('Saved to your device ✓'); }
catch (e) { say('Could not save: ' + (e && e.message ? e.message : e)); }
});
}
button('Download file', !DE.canShareFile(file) && !DE.canPickLocation(), () => {
DE.download(file, name);
done('Download started — see your Downloads folder');
});
};
const localFile = async () => (cachedIds.has(id) && window.OPFS ? window.OPFS.getFileObject(id) : null);
try {
say('Looking for the video…');
busy();
let file = await localFile();
if (file) return ready(file);
// Is it on the server? (a 1-byte ranged probe; nothing is downloaded)
let serverUrl = '';
if (WEB && !video.custom) {
const url = `/api/export/${encodeURIComponent(id)}?name=${encodeURIComponent(title)}${hevcCapable() ? '&hevc=1' : ''}`;
const r = await fetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null);
if (r && (r.status === 206 || r.ok)) serverUrl = url;
}
const phone = DE.canShareFile(new File([], 'x.mp4', { type: 'video/mp4' }));
if (serverUrl && !phone) {
say('The server has this video. It downloads straight to your Downloads folder.');
pct(100);
actions.innerHTML = '';
button('Download file', true, () => { DE.download(serverUrl, DE.fileName(title, 'mp4', id)); done('Download started — see your Downloads folder'); });
return;
}
// Other devices, only when the server can't help.
if (!serverUrl && WEB && window.P2PTransfer && window.P2PCore && data.settings.p2pReceive !== false && !video.custom) {
let payload = null;
try { const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`); payload = r.ok ? await r.json() : null; } catch { /* offline */ }
const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null;
const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : [];
if (peers.length) {
say(`Getting it from another device (${peers.length} online)…`);
pct(0);
const res = await getFromPeers(video, c, peers, (got, total) => { if (open) { pct((got / total) * 100); say(`Getting it from another device… ${Math.round((got / total) * 100)}%`); } });
if (res.ok) { file = await localFile(); if (file) return ready(file); }
say('That device could not send it — saving through the server instead…');
}
}
// Normal save (server copy or YouTube via the server), then export it.
say(serverUrl ? 'Copying it to this device…' : 'Saving the video first — long videos take a while…');
busy();
await preload(video, { quiet: true });
file = await localFile();
if (!file) { say('Could not get this video. Check your connection and try again.'); pct(0); return; }
ready(file);
} catch (e) {
say('Something went wrong: ' + (e && e.message ? e.message : e));
}
}
// ============================================================================
// "On other devices" — videos held by sharing devices (P2P), as a playlist
// ============================================================================
async function renderP2pList() {
els.listTitle.textContent = '📡 On other devices';
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
c.innerHTML = '<div class="status">Looking for videos on other devices…</div>';
let res = null;
try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ }
if (view.type !== 'p2p') return;
c.innerHTML = '';
if (!res || !res.ok) {
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Not available</h3><p class="empty-desc">Peer-to-peer sharing is off on this server, or you are offline.</p></div>';
return;
}
const items = res.items || [];
const list = items.map((it) => ({
id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '',
duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`,
}));
const refresh = document.createElement('button');
refresh.textContent = '↻ Refresh';
refresh.onclick = () => renderP2pList();
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.className = 'la-primary';
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
els.listActions.append(playAll, refresh);
if (!items.length) {
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Nothing shared yet</h3><p class="empty-desc">Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.</p></div>';
return;
}
const online = items.filter((i) => i.online).length;
const sum = document.createElement('div');
sum.className = 'saved-summary';
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now</span>`;
c.appendChild(sum);
items.forEach((it, idx) => {
const v = list[idx];
const row = document.createElement('div');
row.className = 'card saved-card p2p-card';
row.dataset.id = it.id;
const state = it.online ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
</div>
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
row.querySelector('.card-title').textContent = v.title;
row.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]');
if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; }
playFromList(list, idx, 'p2p');
});
c.appendChild(row);
});
markPlayingCard();
}
async function renderSaved() {
els.listTitle.textContent = 'Saved videos';
els.listActions.innerHTML = '';
@@ -7480,9 +7655,11 @@ async function renderSaved() {
<div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
</div>
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
<button class="card-del" title="Delete saved file">✕</button>`;
row.querySelector('.card-title').textContent = v.title || it.id;
row.addEventListener('click', (e) => {
if (e.target.closest('[data-act="export"]')) { e.stopPropagation(); exportToDevice(v); return; }
if (e.target.closest('.card-del')) return;
playFromList([v], 0, 'saved');
});
@@ -8400,6 +8577,12 @@ function openCardMenu(video) {
const isCustom = !!video.custom;
const exportBtn = document.createElement('button');
exportBtn.textContent = '⤓ Save to this device';
exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder';
exportBtn.onclick = () => { closeModal(); exportToDevice(video); };
body.appendChild(exportBtn);
// Quick: save / remove offline. For a custom (edited) video there's no
// source to re-download, so "remove" deletes the edit entirely.
const saveBtn = document.createElement('button');
@@ -9164,6 +9347,7 @@ document.querySelectorAll('.chip').forEach((c) => {
await preload(current.meta);
}
});
if ($('exportBtn')) $('exportBtn').addEventListener('click', () => { if (current && current.meta) exportToDevice(current.meta); });
els.addPlaylistBtn.addEventListener('click', () => {
if (current && current.meta) openCardMenu(current.meta);
});
@@ -9676,6 +9860,13 @@ function renderSmartSidebar() {
item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); });
list.appendChild(item);
});
if (WEB && data.settings.p2pReceive !== false) {
const item = document.createElement('div');
item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : '');
item.innerHTML = '<span class="pl-name">📡 On other devices</span>';
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
list.appendChild(item);
}
}
function getSmartList(smartType) {
switch (smartType) {