feat: Add support for editing video
Add an Edit
This commit is contained in:
350
frontend/app.js
350
frontend/app.js
@@ -113,6 +113,43 @@ async function opfsDownload(videoId, { mux = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// "Edit & download": ask the server to trim `sourceId` to the given keep
|
||||
// segments and store the resulting custom cut in OPFS under `customId` (which
|
||||
// is NOT a real YouTube id — it's edit_<src>_<uid>). Same worker-first /
|
||||
// main-thread-fallback strategy as opfsDownload, but the URL targets the
|
||||
// source video with ?edit=1&keep=… while the file is written under customId.
|
||||
async function opfsDownloadEdited(customId, sourceId, keepParam) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||||
return { ok: false, error: 'OPFS not supported in this browser' };
|
||||
}
|
||||
const fp = window.getFingerprint ? window.getFingerprint() : '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('edit', '1');
|
||||
params.set('keep', keepParam);
|
||||
if (fp) params.set('fp', fp);
|
||||
const url = `/api/download/${encodeURIComponent(sourceId)}?${params.toString()}`;
|
||||
|
||||
let workerError = null;
|
||||
if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') {
|
||||
const w = await window.OPFS.downloadVideo(customId, url);
|
||||
if (w.ok) return { ok: true, cached: true };
|
||||
workerError = w.error || null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: j.error || `HTTP ${res.status}` };
|
||||
}
|
||||
const ct = res.headers.get('content-type') || 'video/mp4';
|
||||
const ext = ct.includes('webm') ? 'webm' : ct.includes('ogg') ? 'ogg' : 'mp4';
|
||||
await window.OPFS.writeFromResponse(customId, ext, res);
|
||||
return { ok: true, cached: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: workerError || err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function opfsStatus(videoId) {
|
||||
if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true, cached: false };
|
||||
try {
|
||||
@@ -170,6 +207,11 @@ const API = {
|
||||
cacheDownload: (videoId, opts) => WEB
|
||||
? opfsDownload(sanitizeId(videoId), opts)
|
||||
: call('cache.download', 'cache_download', { videoId: sanitizeId(videoId) }),
|
||||
// Download a server-edited cut of `sourceId` into the cache under a custom
|
||||
// id. WEB (OPFS) only — the native shells have no ffmpeg edit pipeline.
|
||||
cacheDownloadEdited: (customId, sourceId, keepParam) => WEB
|
||||
? opfsDownloadEdited(sanitizeId(customId), sanitizeId(sourceId), keepParam)
|
||||
: Promise.resolve({ ok: false, error: 'Editing is only available in the web app' }),
|
||||
cacheStatus: (videoId) => WEB
|
||||
? opfsStatus(sanitizeId(videoId))
|
||||
: call('cache.status', 'cache_status', { videoId: sanitizeId(videoId) }),
|
||||
@@ -206,7 +248,7 @@ const DEFAULT_SETTINGS = {
|
||||
autoBackupEnabled: false,
|
||||
autoBackupIntervalDays: 7,
|
||||
};
|
||||
let data = { playlists: [], history: [], queue: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
|
||||
let data = { playlists: [], history: [], queue: [], customVideos: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
|
||||
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
|
||||
let searchResults = [];
|
||||
let channelData = { name: '', url: '', key: '', results: [], loading: false };
|
||||
@@ -282,6 +324,7 @@ const els = {
|
||||
newPlaylistBtn: $('newPlaylistBtn'),
|
||||
audioOnlyToggle: $('audioOnlyToggle'),
|
||||
saveBtn: $('saveBtn'),
|
||||
editBtn: $('editBtn'),
|
||||
addPlaylistBtn: $('addPlaylistBtn'),
|
||||
};
|
||||
|
||||
@@ -302,6 +345,7 @@ function profilePayload() {
|
||||
return {
|
||||
playlists: data.playlists,
|
||||
history: data.history,
|
||||
customVideos: data.customVideos,
|
||||
settings: data.settings,
|
||||
resumePositions: data.resumePositions,
|
||||
rememberPos: data.rememberPos,
|
||||
@@ -340,6 +384,7 @@ function applyProfileData(name, payload, updatedAt) {
|
||||
payload = payload || {};
|
||||
if (Array.isArray(payload.playlists)) data.playlists = payload.playlists;
|
||||
if (Array.isArray(payload.history)) data.history = payload.history;
|
||||
if (Array.isArray(payload.customVideos)) data.customVideos = payload.customVideos;
|
||||
if (payload.resumePositions && typeof payload.resumePositions === 'object') data.resumePositions = payload.resumePositions;
|
||||
if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos;
|
||||
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
|
||||
@@ -534,6 +579,10 @@ async function refreshCachedIds() {
|
||||
// Download a video into the permanent offline cache. Safe to call repeatedly.
|
||||
async function preload(video, { quiet = false, mux = 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;
|
||||
downloading.add(id);
|
||||
downloadMeta.set(id, slim(video));
|
||||
@@ -599,6 +648,203 @@ function markCardCacheState(id, state) {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Video editor — cut parts out of a video and save a custom offline copy
|
||||
//
|
||||
// The editor works on a SOURCE video (any card / the now-playing video). The
|
||||
// user marks one or more CUT ranges; everything outside those ranges survives.
|
||||
// On confirm we compute the keep segments (VideoEdit.invertCuts), ask the
|
||||
// server to trim+concat the source into one continuous mp4 (?edit=1&keep=…),
|
||||
// store it in OPFS under a fresh custom id, and register a custom video object
|
||||
// in data.customVideos so it plays offline and can be added to playlists just
|
||||
// like a normal video.
|
||||
// ============================================================================
|
||||
|
||||
// Make a stable-ish unique id for a custom cut. Not a YouTube id — the
|
||||
// `edit_` prefix is how the rest of the app recognises an offline-only video.
|
||||
function customVideoId(sourceId) {
|
||||
return 'edit_' + sanitizeId(sourceId) + '_' + uid();
|
||||
}
|
||||
|
||||
// Kick off the server-side edit + OPFS save for a custom video object, driving
|
||||
// the same download/cache UI state (badges, toasts) as a normal save.
|
||||
async function downloadEdited(customVideo) {
|
||||
const id = customVideo.id;
|
||||
if (!id || cachedIds.has(id) || downloading.has(id)) return;
|
||||
downloading.add(id);
|
||||
downloadMeta.set(id, slim(customVideo));
|
||||
markCardCacheState(id, 'downloading');
|
||||
if (view.type === 'downloads') renderList();
|
||||
updateDownloadBadge();
|
||||
toast(`Rendering “${customVideo.title}”…`);
|
||||
try {
|
||||
const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep);
|
||||
if (res && res.ok && res.cached) {
|
||||
cachedIds.add(id);
|
||||
// Only persist the custom video once its media is actually stored, so a
|
||||
// failed render never leaves a dangling entry the user can't play.
|
||||
if (!(data.customVideos || []).some((v) => v.id === id)) {
|
||||
data.customVideos = data.customVideos || [];
|
||||
data.customVideos.push(customVideo);
|
||||
persist();
|
||||
}
|
||||
toast(`Saved edited “${customVideo.title}” ✓`);
|
||||
if (view.type === 'downloads' || view.type === 'saved') renderList();
|
||||
} else {
|
||||
toast('⚠ ' + ((res && res.error) || 'Could not render edited video'));
|
||||
}
|
||||
} catch (e) {
|
||||
toast('⚠ ' + (e && e.message ? e.message : 'Editing failed'));
|
||||
} finally {
|
||||
downloading.delete(id);
|
||||
downloadMeta.delete(id);
|
||||
markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none');
|
||||
updateDownloadBadge();
|
||||
if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') renderList();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a custom (edited) video entirely: its cached media file, its cache
|
||||
// membership, its registry entry, and any playlist references. Unlike a normal
|
||||
// "remove from cache", the media can't be re-fetched, so this is a true delete.
|
||||
async function deleteCustomVideo(id) {
|
||||
try { await API.cacheDelete(id); } catch { /* best-effort */ }
|
||||
cachedIds.delete(id);
|
||||
data.customVideos = (data.customVideos || []).filter((v) => v.id !== id);
|
||||
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => x.id !== id); });
|
||||
data.queue = (data.queue || []).filter((x) => x.id !== id);
|
||||
persist();
|
||||
markCardCacheState(id, 'none');
|
||||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||||
toast('Deleted edited video');
|
||||
if (view.type === 'saved' || view.type === 'downloads' || view.type === 'playlist') renderList();
|
||||
}
|
||||
|
||||
// Open the editor modal for a source video. `duration` seconds is needed to
|
||||
// compute keep segments; we take it from the live player when the video is
|
||||
// currently playing, else from the card metadata.
|
||||
function openVideoEditor(source) {
|
||||
if (!(WEB && window.OPFS && window.OPFS.isSupported())) {
|
||||
toast('⚠ Editing needs offline storage, which this browser doesn’t support');
|
||||
return;
|
||||
}
|
||||
// Prefer the precise live duration when editing the now-playing video.
|
||||
let duration = 0;
|
||||
if (current && current.meta && current.meta.id === source.id && Player.master && Player.master.duration) {
|
||||
duration = Player.master.duration;
|
||||
}
|
||||
if (!duration) duration = Number(source.duration) || 0;
|
||||
if (!duration || !isFinite(duration)) {
|
||||
toast('⚠ Play the video first so its length is known, then edit');
|
||||
return;
|
||||
}
|
||||
|
||||
const cuts = []; // [{start,end}] the user is removing
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'video-editor';
|
||||
body.innerHTML = `
|
||||
<p class="ve-intro">Mark the parts to <strong>remove</strong>. Everything else is kept and saved as a new offline video.</p>
|
||||
<div class="ve-add-row">
|
||||
<label>From <input type="text" class="ve-from" placeholder="0:30" inputmode="numeric" /></label>
|
||||
<label>To <input type="text" class="ve-to" placeholder="1:15" inputmode="numeric" /></label>
|
||||
<button type="button" class="btn ve-add">Add cut</button>
|
||||
</div>
|
||||
<div class="ve-error" hidden></div>
|
||||
<div class="ve-cuts"></div>
|
||||
<label class="ve-title-row">Name <input type="text" class="ve-title" maxlength="120" /></label>
|
||||
<div class="ve-summary"></div>`;
|
||||
|
||||
const fromEl = body.querySelector('.ve-from');
|
||||
const toEl = body.querySelector('.ve-to');
|
||||
const addBtn = body.querySelector('.ve-add');
|
||||
const errEl = body.querySelector('.ve-error');
|
||||
const cutsEl = body.querySelector('.ve-cuts');
|
||||
const titleEl = body.querySelector('.ve-title');
|
||||
const sumEl = body.querySelector('.ve-summary');
|
||||
titleEl.value = (source.title || 'Video') + ' (edit)';
|
||||
|
||||
function showErr(msg) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = !msg;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
cutsEl.innerHTML = '';
|
||||
const sorted = cuts.slice().sort((a, b) => a.start - b.start);
|
||||
sorted.forEach((cut) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 've-cut';
|
||||
row.innerHTML = `<span>✂ ${VideoEdit.fmtTime(cut.start)} – ${VideoEdit.fmtTime(cut.end)}</span>`;
|
||||
const del = document.createElement('button');
|
||||
del.className = 've-cut-del';
|
||||
del.type = 'button';
|
||||
del.textContent = '✕';
|
||||
del.title = 'Remove this cut';
|
||||
del.onclick = () => {
|
||||
const i = cuts.indexOf(cut);
|
||||
if (i > -1) cuts.splice(i, 1);
|
||||
refresh();
|
||||
};
|
||||
row.appendChild(del);
|
||||
cutsEl.appendChild(row);
|
||||
});
|
||||
const keep = VideoEdit.invertCuts(cuts, duration);
|
||||
const finalLen = VideoEdit.keepDuration(keep);
|
||||
sumEl.innerHTML = cuts.length
|
||||
? `Final length: <strong>${VideoEdit.fmtTime(finalLen)}</strong> of ${VideoEdit.fmtTime(duration)}`
|
||||
: `No cuts yet — the whole ${VideoEdit.fmtTime(duration)} video would be saved.`;
|
||||
}
|
||||
|
||||
addBtn.onclick = () => {
|
||||
showErr('');
|
||||
const a = VideoEdit.parseTime(fromEl.value);
|
||||
const b = VideoEdit.parseTime(toEl.value);
|
||||
if (a === null || b === null) { showErr('Enter valid times, e.g. 0:30 and 1:15.'); return; }
|
||||
if (b <= a) { showErr('“To” must be after “From”.'); return; }
|
||||
if (a >= duration) { showErr(`Times must be within the video (0 – ${VideoEdit.fmtTime(duration)}).`); return; }
|
||||
cuts.push({ start: a, end: Math.min(b, duration) });
|
||||
fromEl.value = '';
|
||||
toEl.value = '';
|
||||
fromEl.focus();
|
||||
refresh();
|
||||
};
|
||||
|
||||
refresh();
|
||||
|
||||
showModal('Edit & download', body, [
|
||||
{ label: 'Cancel', onClick: closeModal },
|
||||
{
|
||||
label: 'Save edited copy', primary: true, onClick: () => {
|
||||
if (!VideoEdit.hasEdits(cuts, duration)) {
|
||||
showErr('Add at least one cut, or use ⬇ Save for the full video.');
|
||||
return;
|
||||
}
|
||||
const keep = VideoEdit.invertCuts(cuts, duration);
|
||||
if (!keep.length) {
|
||||
showErr('That would remove the entire video — leave something to keep.');
|
||||
return;
|
||||
}
|
||||
const custom = {
|
||||
id: customVideoId(source.id),
|
||||
title: (titleEl.value || '').trim() || ((source.title || 'Video') + ' (edit)'),
|
||||
channel: source.channel || '',
|
||||
channelId: source.channelId || '',
|
||||
channelUrl: source.channelUrl || '',
|
||||
thumbnail: source.thumbnail || '',
|
||||
duration: Math.round(VideoEdit.keepDuration(keep)),
|
||||
custom: true,
|
||||
sourceId: source.id,
|
||||
keep: VideoEdit.keepToParam(keep),
|
||||
};
|
||||
closeModal();
|
||||
downloadEdited(custom);
|
||||
},
|
||||
},
|
||||
]);
|
||||
setTimeout(() => fromEl.focus(), 50);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Player engine — single video, or video+audio synced (adaptive), or audio-only
|
||||
// ============================================================================
|
||||
@@ -663,6 +909,14 @@ const Player = {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom (edited) videos exist ONLY in the offline cache — there is no
|
||||
// YouTube stream to fall back to. If the cached file is missing (e.g.
|
||||
// cleared, or synced from another device that never had the media),
|
||||
// surface a clear error instead of trying to stream a fake video id.
|
||||
if (videoObj.custom) {
|
||||
throw new Error('This edited video isn’t available offline on this device.');
|
||||
}
|
||||
|
||||
// Opt-in "Save before playing" (Settings → Playback, off by default):
|
||||
// download the server-compiled single file into the offline cache
|
||||
// first, then play the local copy — one data stream instead of the
|
||||
@@ -1186,6 +1440,11 @@ function updatePlayBtn() {
|
||||
function updateNowPlayingActions() {
|
||||
if (!current || !current.meta) return;
|
||||
const id = current.meta.id;
|
||||
// Custom (edited) videos are already a saved-offline cut of a source video;
|
||||
// re-saving or re-editing them makes no sense, so hide those actions.
|
||||
const isCustom = !!current.meta.custom;
|
||||
if (els.editBtn) els.editBtn.hidden = isCustom;
|
||||
if (els.saveBtn) els.saveBtn.hidden = isCustom;
|
||||
const btn = els.saveBtn;
|
||||
if (!btn) return;
|
||||
if (removing.has(id)) {
|
||||
@@ -1754,11 +2013,20 @@ async function renderSaved() {
|
||||
const clearAll = document.createElement('button');
|
||||
clearAll.textContent = 'Clear all';
|
||||
clearAll.onclick = () => {
|
||||
showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Playlist entries stay and re-download on demand.'), [
|
||||
showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [
|
||||
{ label: 'Cancel', onClick: closeModal },
|
||||
{ label: 'Clear all', danger: true, onClick: async () => {
|
||||
try { await API.cacheClear(); } catch {}
|
||||
cachedIds.clear();
|
||||
// Custom cuts live only in the cache — clearing it destroys their media,
|
||||
// so drop their registry entries and playlist references too.
|
||||
const customIds = new Set((data.customVideos || []).map((v) => v.id));
|
||||
if (customIds.size) {
|
||||
data.customVideos = [];
|
||||
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); });
|
||||
data.queue = (data.queue || []).filter((x) => !customIds.has(x.id));
|
||||
persist();
|
||||
}
|
||||
closeModal();
|
||||
toast('Cache cleared');
|
||||
if (current) updateNowPlayingActions();
|
||||
@@ -1775,15 +2043,20 @@ async function renderSaved() {
|
||||
c.appendChild(summary);
|
||||
|
||||
items.forEach((it) => {
|
||||
const v = videoById(it.id) || { id: it.id, title: videoTitleById(it.id), thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
|
||||
const known = videoById(it.id);
|
||||
// A custom (edited) id has no YouTube thumbnail; fall back to its source's
|
||||
// thumbnail when we know it, else a neutral placeholder.
|
||||
const isCustom = (known && known.custom) || String(it.id).startsWith('edit_');
|
||||
const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`;
|
||||
const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb };
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card saved-card';
|
||||
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
|
||||
row.dataset.id = it.id;
|
||||
row.innerHTML = `
|
||||
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" /></div>
|
||||
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title"></div>
|
||||
<div class="card-channel saved-size">${fmtBytes(it.size)}</div>
|
||||
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
|
||||
</div>
|
||||
<button class="card-del" title="Delete saved file">✕</button>`;
|
||||
row.querySelector('.card-title').textContent = v.title || it.id;
|
||||
@@ -1793,6 +2066,7 @@ async function renderSaved() {
|
||||
});
|
||||
row.querySelector('.card-del').addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (isCustom) { await deleteCustomVideo(it.id); renderSaved(); return; }
|
||||
try { await API.cacheDelete(it.id); } catch {}
|
||||
cachedIds.delete(it.id);
|
||||
if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
|
||||
@@ -1867,6 +2141,10 @@ function videoById(id) {
|
||||
if (q) return q;
|
||||
const h = data.history.find((x) => x.id === id);
|
||||
if (h) return h;
|
||||
// Custom (edited) videos live only in data.customVideos — their media is in
|
||||
// the offline cache, never on YouTube, so nothing else references them.
|
||||
const cv = (data.customVideos || []).find((x) => x.id === id);
|
||||
if (cv) return cv;
|
||||
return null;
|
||||
}
|
||||
function videoTitleById(id) {
|
||||
@@ -2442,24 +2720,42 @@ function openCardMenu(video) {
|
||||
queueBtn.onclick = () => { addToQueue(video); closeModal(); };
|
||||
body.appendChild(queueBtn);
|
||||
|
||||
// Quick: save / remove offline
|
||||
const isCustom = !!video.custom;
|
||||
|
||||
// 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');
|
||||
const isSaved = cachedIds.has(video.id);
|
||||
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
|
||||
saveBtn.onclick = async () => {
|
||||
closeModal();
|
||||
if (cachedIds.has(video.id)) {
|
||||
try { await API.cacheDelete(video.id); } catch {}
|
||||
cachedIds.delete(video.id);
|
||||
markCardCacheState(video.id, 'none');
|
||||
if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions();
|
||||
toast('Removed from offline cache');
|
||||
} else {
|
||||
preload(video);
|
||||
}
|
||||
};
|
||||
if (isCustom) {
|
||||
saveBtn.textContent = '🗑 Delete edited video';
|
||||
saveBtn.className = 'danger';
|
||||
saveBtn.onclick = () => { closeModal(); deleteCustomVideo(video.id); };
|
||||
} else {
|
||||
saveBtn.textContent = isSaved ? '✓ Saved offline — remove' : '⬇ Save for offline';
|
||||
saveBtn.onclick = async () => {
|
||||
closeModal();
|
||||
if (cachedIds.has(video.id)) {
|
||||
try { await API.cacheDelete(video.id); } catch {}
|
||||
cachedIds.delete(video.id);
|
||||
markCardCacheState(video.id, 'none');
|
||||
if (current && current.meta && current.meta.id === video.id) updateNowPlayingActions();
|
||||
toast('Removed from offline cache');
|
||||
} else {
|
||||
preload(video);
|
||||
}
|
||||
};
|
||||
}
|
||||
body.appendChild(saveBtn);
|
||||
|
||||
// Quick: edit & download (only for real source videos — a custom cut can't
|
||||
// be re-cut server-side because its media lives only in the browser cache).
|
||||
if (!isCustom) {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.textContent = '✂ Edit & download';
|
||||
editBtn.onclick = () => { closeModal(); openVideoEditor(video); };
|
||||
body.appendChild(editBtn);
|
||||
}
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'modal-divider';
|
||||
divider.textContent = 'Playlists';
|
||||
@@ -2519,11 +2815,19 @@ function openCardMenu(video) {
|
||||
}
|
||||
|
||||
function slim(v) {
|
||||
return {
|
||||
const s = {
|
||||
id: v.id, title: v.title, channel: v.channel,
|
||||
channelId: v.channelId || '', channelUrl: v.channelUrl || '',
|
||||
duration: v.duration, thumbnail: v.thumbnail,
|
||||
};
|
||||
// Preserve the markers that make a custom (edited) video self-contained, so
|
||||
// a slimmed copy sitting in a playlist still knows it's an offline-only cut.
|
||||
if (v.custom) {
|
||||
s.custom = true;
|
||||
s.sourceId = v.sourceId || '';
|
||||
if (v.keep) s.keep = v.keep;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function newPlaylist(addVideo) {
|
||||
@@ -2942,6 +3246,9 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
els.addPlaylistBtn.addEventListener('click', () => {
|
||||
if (current && current.meta) openCardMenu(current.meta);
|
||||
});
|
||||
if (els.editBtn) els.editBtn.addEventListener('click', () => {
|
||||
if (current && current.meta) openVideoEditor(current.meta);
|
||||
});
|
||||
els.queueBtn.addEventListener('click', () => {
|
||||
if (current && current.meta) addToQueue(current.meta);
|
||||
});
|
||||
@@ -3582,6 +3889,7 @@ async function boot() {
|
||||
playlists: loaded.playlists || [],
|
||||
history: loaded.history || [],
|
||||
queue: loaded.queue || [],
|
||||
customVideos: loaded.customVideos || [],
|
||||
resumePositions: loaded.resumePositions || {},
|
||||
rememberPos: loaded.rememberPos || {},
|
||||
playCount: loaded.playCount || {},
|
||||
|
||||
Reference in New Issue
Block a user