Add save-to-device export, server export route and an On other devices P2P list
This commit is contained in:
191
frontend/app.js
191
frontend/app.js
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user