feat: Add support for editing video

Add an Edit
This commit is contained in:
Claude Worker
2026-07-18 16:43:15 +00:00
parent 94dba16ff6
commit 8c0776f97d
7 changed files with 834 additions and 21 deletions

View File

@@ -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 doesnt 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 isnt 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 || {},