IDK man, changes I guess
This commit is contained in:
196
web/app.js
196
web/app.js
@@ -121,6 +121,7 @@ const DEFAULTS = {
|
||||
mode: 'copy', join: true, output: '', allAudio: true, noAudio: false,
|
||||
vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192,
|
||||
gifHeight: 480, gifFps: '15', gifDither: 'sierra2_4a', gifLoop: true,
|
||||
burnCaptions: true, captionSize: 'medium', captionStyle: 'outline',
|
||||
lastDir: '', volume: 1, muted: false,
|
||||
};
|
||||
const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b'];
|
||||
@@ -129,6 +130,7 @@ const S = {
|
||||
info: null,
|
||||
sources: [], // probed media (see media.go Media)
|
||||
clips: [], // {id, src, in, out}
|
||||
captions: {}, // source id -> {path, name, cues: [{start, end, text}]} (a loaded .srt file)
|
||||
cur: null, // source id shown in the viewer
|
||||
marks: {}, // source id -> {in, out} (scratch selection)
|
||||
sel: null, // selected clip id (being edited)
|
||||
@@ -185,7 +187,7 @@ const thumbURL = (id, t, hgt) => `/api/thumb?id=${id}&t=${Math.max(0, t).toFixed
|
||||
|
||||
// ---------------------------------------------------------------- undo
|
||||
function snapshot() {
|
||||
return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), sel: S.sel };
|
||||
return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), captions: { ...S.captions }, sel: S.sel };
|
||||
}
|
||||
function commit(fn) {
|
||||
S.undo.push(snapshot());
|
||||
@@ -197,6 +199,7 @@ function commit(fn) {
|
||||
function restoreSnap(s) {
|
||||
S.clips = s.clips;
|
||||
S.sources = s.sources;
|
||||
S.captions = s.captions;
|
||||
S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null;
|
||||
if (!srcById(S.cur)) {
|
||||
S.cur = null;
|
||||
@@ -236,26 +239,27 @@ function confirmDlg(text, okLabel = 'OK') {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sources
|
||||
async function addFiles(builtin = false) {
|
||||
if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error');
|
||||
// pickFiles asks for files to open, with the native dialog when there is one
|
||||
// and else with the built-in browser. kind is 'video' or 'captions'.
|
||||
async function pickFiles({ kind = 'video', dir = '', builtin = false } = {}) {
|
||||
if (!builtin && S.info.nativeDialogs) {
|
||||
try {
|
||||
const dir = S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : '');
|
||||
const r = await api('/api/dialog/open', { dir });
|
||||
if (r.supported) {
|
||||
if (r.paths && r.paths.length) {
|
||||
S.settings.lastDir = splitPath(r.paths[0])[0];
|
||||
await addPaths(r.paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const r = await api('/api/dialog/open', { dir, kind });
|
||||
if (r.supported) return r.paths || [];
|
||||
if (r.error) toast('The file dialog could not be opened, using the built-in browser instead.', 'error');
|
||||
} catch (e) {
|
||||
if (e.status === 409) return;
|
||||
if (e.status === 409) return [];
|
||||
}
|
||||
}
|
||||
const paths = await fileBrowser({ mode: 'open' });
|
||||
if (paths && paths.length) await addPaths(paths);
|
||||
return await fileBrowser({ mode: 'open', kind, dir }) || [];
|
||||
}
|
||||
|
||||
async function addFiles(builtin = false) {
|
||||
if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error');
|
||||
const paths = await pickFiles({ dir: S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''), builtin });
|
||||
if (!paths.length) return;
|
||||
S.settings.lastDir = splitPath(paths[0])[0];
|
||||
await addPaths(paths);
|
||||
}
|
||||
|
||||
async function addPaths(paths) {
|
||||
@@ -292,6 +296,26 @@ async function removeSource(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCaptions(id) {
|
||||
const m = srcById(id);
|
||||
if (!m) return;
|
||||
const old = S.captions[id];
|
||||
const [path] = await pickFiles({ kind: 'captions', dir: splitPath(old ? old.path : m.path)[0] });
|
||||
if (!path || !srcById(id)) return;
|
||||
try {
|
||||
const c = await api('/api/captions', { path });
|
||||
commit(() => { S.captions[id] = c; });
|
||||
if (S.cur !== id) loadSource(id);
|
||||
toast(`Loaded ${c.cues.length} caption${c.cues.length !== 1 ? 's' : ''} from "${c.name}"`, 'ok');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function removeCaptions(id) {
|
||||
commit(() => { delete S.captions[id]; });
|
||||
}
|
||||
|
||||
function addWhole(id) {
|
||||
const m = srcById(id);
|
||||
if (!m) return;
|
||||
@@ -303,7 +327,7 @@ function addWhole(id) {
|
||||
async function newProject() {
|
||||
if ((S.sources.length || S.clips.length) &&
|
||||
!await confirmDlg('Start a new, empty project?\nThis removes all sources and clips from the editor (the video files are not touched). You can undo this.', 'New project')) return;
|
||||
commit(() => { S.sources = []; S.clips = []; S.sel = null; });
|
||||
commit(() => { S.sources = []; S.clips = []; S.captions = {}; S.sel = null; });
|
||||
S.marks = {};
|
||||
S.settings.output = '';
|
||||
S.cur = null;
|
||||
@@ -1014,6 +1038,7 @@ function renderSources() {
|
||||
const meta = [fmtLen(m.duration), v ? `${v.displayWidth}×${v.displayHeight}` : 'audio only',
|
||||
v ? v.codec : (m.audio[0] && m.audio[0].codec), v ? fmtFps(v.fps) : ''].filter(Boolean).join(' · ');
|
||||
const k = S.kf[m.id];
|
||||
const cap = S.captions[m.id];
|
||||
ul.append(h('li', { class: 'src' + (m.id === S.cur ? ' cur' : ''), 'data-id': m.id, title: m.path, style: `--c:${m.color}` },
|
||||
h('div', { class: 'src-thumb' },
|
||||
v ? h('img', { src: thumbURL(m.id, Math.min(m.duration * 0.1, 30), 108), alt: '', loading: 'lazy', draggable: 'false' })
|
||||
@@ -1023,6 +1048,9 @@ function renderSources() {
|
||||
h('div', { class: 'src-meta' }, meta),
|
||||
h('div', { class: 'src-actions' },
|
||||
h('button', { class: 'chip-btn', 'data-sact': 'whole', title: 'Add the whole video as one clip' }, icon('plus'), 'Whole'),
|
||||
cap ? h('button', { class: 'chip-btn on', 'data-sact': 'captions', title: `Captions: ${cap.name} (${cap.cues.length})\nClick to load a different .srt file.` }, 'CC',
|
||||
h('span', { class: 'chip-x', 'data-sact': 'nocaptions', title: 'Remove the captions' }, icon('x')))
|
||||
: h('button', { class: 'chip-btn', 'data-sact': 'captions', title: 'Load captions (.srt) for this video' }, icon('plus'), 'CC'),
|
||||
n ? h('span', { class: 'src-status' }, `${n} clip${n > 1 ? 's' : ''}`) : null,
|
||||
k && k.status === 'pending' ? h('span', { class: 'src-status' }, 'reading keyframes…') : null)),
|
||||
h('button', { class: 'icon-btn src-x', 'data-sact': 'remove', title: 'Remove from project' }, icon('x'))));
|
||||
@@ -1097,6 +1125,7 @@ function renderMarks() {
|
||||
}
|
||||
|
||||
function renderTime() {
|
||||
renderCaptions();
|
||||
const m = cur();
|
||||
tcCurEl.textContent = fmtT(m ? S.time : 0);
|
||||
$('#tcDur').textContent = fmtT(m ? m.duration : 0);
|
||||
@@ -1132,6 +1161,7 @@ function renderScreen() {
|
||||
msg = j ? `This format can't be played by the browser directly.\nCreating a preview copy… ${Math.round((j.progress || 0) * 100)}% (you can already scrub and cut)`
|
||||
: 'Preview not available — showing still frames.';
|
||||
}
|
||||
layoutCaptions();
|
||||
const el = $('#screenMsg');
|
||||
el.textContent = msg;
|
||||
el.style.whiteSpace = 'pre-line';
|
||||
@@ -1154,6 +1184,64 @@ function renderTopbar() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- captions preview
|
||||
// The viewer shows the current source's captions the way an export burns them
|
||||
// in (captionFilter in captions.go). Sizes are in 1/288ths of the picture's
|
||||
// shorter side and match captionSizes there; libass makes each line as tall as
|
||||
// the font size, which for Arial is 1.117 em.
|
||||
const CAPTION_SIZES = { small: 12, medium: 16, large: 21 };
|
||||
const capEl = $('#captions');
|
||||
let capShown = null;
|
||||
|
||||
function renderCaptions() {
|
||||
const m = cur();
|
||||
const c = m && S.captions[m.id];
|
||||
const t = S.time + 1e-3;
|
||||
const cues = c ? c.cues.filter(q => q.start <= t && t < q.end) : [];
|
||||
const key = cues.map(q => q.text).join('\0');
|
||||
if (key === capShown) return;
|
||||
capShown = key;
|
||||
capEl.replaceChildren(...cues.map(q => h('div', {}, captionNodes(q.text))));
|
||||
capEl.classList.toggle('hidden', !cues.length);
|
||||
}
|
||||
|
||||
// layoutCaptions places the captions over the picture inside the viewer.
|
||||
function layoutCaptions() {
|
||||
const scr = $('#screen');
|
||||
const W = scr.clientWidth, H = scr.clientHeight;
|
||||
let w = W, hh = H;
|
||||
const v = cur() && cur().video;
|
||||
if (v && v.displayWidth && v.displayHeight) {
|
||||
const a = v.displayWidth / v.displayHeight;
|
||||
if (W / H > a) w = H * a; else hh = W / a;
|
||||
}
|
||||
const u = Math.min(w, hh) / 288;
|
||||
const size = (CAPTION_SIZES[S.settings.captionSize] || CAPTION_SIZES.medium) * u;
|
||||
const margin = 10 * u; // ffmpeg's default MarginL/R/V
|
||||
Object.assign(capEl.style, {
|
||||
left: (W - w) / 2 + margin + 'px', width: Math.max(0, w - 2 * margin) + 'px', bottom: (H - hh) / 2 + margin + 'px',
|
||||
fontSize: size / 1.117 + 'px', lineHeight: size + 'px',
|
||||
});
|
||||
capEl.style.setProperty('--u', u + 'px');
|
||||
capEl.classList.toggle('box', S.settings.captionStyle === 'box');
|
||||
}
|
||||
new ResizeObserver(layoutCaptions).observe($('#screen'));
|
||||
|
||||
// captionNodes keeps the <i>, <b> and <u> tags of SRT caption text and drops
|
||||
// other tags and {\...} override codes.
|
||||
function captionNodes(text) {
|
||||
const root = h('span');
|
||||
let el = root;
|
||||
for (const part of text.replace(/\{\\[^}]*\}/g, '').split(/(<\/?[a-z]+[^>]*>)/i)) {
|
||||
const tag = /^<(\/?)([a-z]+)[^>]*>$/i.exec(part);
|
||||
if (!tag) el.append(part);
|
||||
else if (!['i', 'b', 'u'].includes(tag[2].toLowerCase())) continue;
|
||||
else if (!tag[1]) el = el.appendChild(h(tag[2].toLowerCase()));
|
||||
else if (el !== root) el = el.parentNode;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- editable time fields
|
||||
function makeEditable(el, getValue, onCommit) {
|
||||
el.addEventListener('click', () => {
|
||||
@@ -1206,6 +1294,10 @@ function exportExts(mode) {
|
||||
|
||||
function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); }
|
||||
|
||||
// willBurnCaptions tells whether the export draws captions into the picture.
|
||||
const willBurnCaptions = () => S.settings.mode !== 'copy' && S.settings.burnCaptions && S.info.captionsOK &&
|
||||
S.clips.some(c => S.captions[c.src]);
|
||||
|
||||
function defaultOutput() {
|
||||
const first = firstClipSource();
|
||||
if (!first) return '';
|
||||
@@ -1282,6 +1374,8 @@ function fillExport() {
|
||||
$('#exGifFPS').value = st.gifFps;
|
||||
$('#exGifDither').value = st.gifDither;
|
||||
$('#exGifLoop').value = st.gifLoop ? '1' : '0';
|
||||
$('#exCapSize').value = st.captionSize;
|
||||
$('#exCapStyle').value = st.captionStyle;
|
||||
updateExportView();
|
||||
}
|
||||
|
||||
@@ -1317,7 +1411,23 @@ function updateExportView() {
|
||||
$('#exEncOpts').classList.toggle('hidden', st.mode !== 'encode');
|
||||
$('#exGifOpts').classList.toggle('hidden', !gif);
|
||||
const result = st.join || n === 1 ? (gif ? 'one animated GIF' : 'one video file') : n + (gif ? ' separate GIFs' : ' separate files');
|
||||
$('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${result}`;
|
||||
$('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${result}${willBurnCaptions() ? ' with captions' : ''}`;
|
||||
|
||||
const capFiles = [...new Set(S.clips.map(c => S.captions[c.src]).filter(Boolean))];
|
||||
const capOK = !copy && S.info.captionsOK;
|
||||
let capHint = '';
|
||||
if (copy) capHint = 'Lossless export copies the picture unchanged, so it can\'t add captions. Choose Re-encode or GIF to burn them in.';
|
||||
else if (!S.info.codecsReady) capHint = 'Checking whether ffmpeg can draw captions…';
|
||||
else if (!S.info.captionsOK) capHint = 'This ffmpeg can\'t draw captions: it was built without libass (the subtitles filter).';
|
||||
else if (st.burnCaptions) {
|
||||
const none = S.clips.filter(c => !S.captions[c.src]).length;
|
||||
capHint = 'From ' + capFiles.map(c => c.name).join(', ') +
|
||||
(none ? ` · ${none} clip${none > 1 ? 's come' : ' comes'} from a video without captions` : '');
|
||||
}
|
||||
$('#exCapOpts').classList.toggle('hidden', !capFiles.length);
|
||||
$('#exCaptions').checked = capOK && st.burnCaptions;
|
||||
$('#exCapHint').textContent = capHint;
|
||||
$('#exCapLook').classList.toggle('hidden', !capOK || !st.burnCaptions);
|
||||
|
||||
let outHint = '';
|
||||
if (!st.join && n > 1 && st.output) {
|
||||
@@ -1378,7 +1488,10 @@ function updateExportView() {
|
||||
$('#exGo').disabled = running || !n;
|
||||
$('#exCancel').classList.toggle('hidden', !running);
|
||||
$$('#exportForm input, #exportForm select, #exBrowse').forEach(el => { el.disabled = running; });
|
||||
if (!running) $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac';
|
||||
if (!running) {
|
||||
$('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac';
|
||||
$('#exCaptions').disabled = !capOK;
|
||||
}
|
||||
}
|
||||
|
||||
function renderExportProgress() {
|
||||
@@ -1422,7 +1535,11 @@ async function startExport(overwrite = false) {
|
||||
height: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps,
|
||||
acodec: st.acodec, abitrate: +st.abitrate,
|
||||
dither: st.gifDither, loop: st.gifLoop,
|
||||
captions: {}, captionSize: st.captionSize, captionStyle: st.captionStyle,
|
||||
};
|
||||
if (willBurnCaptions()) {
|
||||
for (const c of S.clips) if (S.captions[c.src]) body.captions[c.src] = S.captions[c.src].path;
|
||||
}
|
||||
saveSoon();
|
||||
try {
|
||||
const r = await api('/api/export', body);
|
||||
@@ -1495,6 +1612,9 @@ function bindExport() {
|
||||
$('#exGifFPS').addEventListener('change', e => { st().gifFps = e.target.value; refill(); });
|
||||
$('#exGifDither').addEventListener('change', e => { st().gifDither = e.target.value; refill(); });
|
||||
$('#exGifLoop').addEventListener('change', e => { st().gifLoop = e.target.value === '1'; refill(); });
|
||||
$('#exCaptions').addEventListener('change', e => { st().burnCaptions = e.target.checked; refill(); });
|
||||
$('#exCapSize').addEventListener('change', e => { st().captionSize = e.target.value; refill(); layoutCaptions(); });
|
||||
$('#exCapStyle').addEventListener('change', e => { st().captionStyle = e.target.value; refill(); layoutCaptions(); });
|
||||
$('#exGo').addEventListener('click', () => startExport(false));
|
||||
$('#exCancel').addEventListener('click', () => {
|
||||
if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {});
|
||||
@@ -1519,13 +1639,16 @@ function setMode(mode) {
|
||||
const dlgF = $('#dlgFiles');
|
||||
let fb = null;
|
||||
|
||||
function fileBrowser({ mode = 'open', path = '' } = {}) {
|
||||
// fileBrowser picks files to open (of a kind: 'video' or 'captions') or the
|
||||
// path to save an export to.
|
||||
function fileBrowser({ mode = 'open', kind = 'video', dir = '', path = '' } = {}) {
|
||||
return new Promise(resolve => {
|
||||
fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' };
|
||||
$('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as';
|
||||
$('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here';
|
||||
const caps = kind === 'captions';
|
||||
fb = { mode, kind, resolve, sel: new Set(), path: '', entries: [], parent: '' };
|
||||
$('#fbTitle').textContent = mode === 'save' ? 'Export as' : caps ? 'Load captions' : 'Add videos';
|
||||
$('#fbOk').textContent = mode === 'save' ? 'Save here' : caps ? 'Load' : 'Add';
|
||||
$('#fbSaveRow').classList.toggle('hidden', mode !== 'save');
|
||||
let dir = S.settings.lastDir || '';
|
||||
dir = dir || S.settings.lastDir || '';
|
||||
if (mode === 'save' && path) {
|
||||
const [d, n] = splitPath(path);
|
||||
dir = d;
|
||||
@@ -1543,7 +1666,7 @@ async function fbLoad(path) {
|
||||
if (!fb) return;
|
||||
const seq = ++fbSeq;
|
||||
try {
|
||||
const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + ($('#fbAll').checked ? '&all=1' : ''));
|
||||
const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + '&kind=' + fb.kind + ($('#fbAll').checked ? '&all=1' : ''));
|
||||
if (!fb || seq !== fbSeq) return; // closed, or a newer listing was requested
|
||||
Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent });
|
||||
fb.sel.clear();
|
||||
@@ -1563,7 +1686,8 @@ async function fbLoad(path) {
|
||||
function fbRender() {
|
||||
const ul = $('#fbList');
|
||||
ul.replaceChildren();
|
||||
if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, fb.mode === 'open' ? 'No videos in this folder' : 'This folder is empty'));
|
||||
const none = fb.mode !== 'open' ? 'This folder is empty' : fb.kind === 'captions' ? 'No .srt files in this folder' : 'No videos in this folder';
|
||||
if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, none));
|
||||
for (const en of fb.entries) {
|
||||
ul.append(h('li', { class: (en.dir ? 'dir' : 'file') + (fb.sel.has(en.path) ? ' sel' : ''), 'data-path': en.path, 'data-dir': en.dir ? '1' : null },
|
||||
icon(en.dir ? 'folder' : 'film'), h('span', { class: 'fb-name' }, en.name),
|
||||
@@ -1584,7 +1708,7 @@ function fbFinish() {
|
||||
if (!name) return toast('Type a file name.');
|
||||
result = joinPath(fb.path, name);
|
||||
}
|
||||
S.settings.lastDir = fb.path;
|
||||
if (fb.kind !== 'captions') S.settings.lastDir = fb.path;
|
||||
const f = fb;
|
||||
fb = null;
|
||||
dlgF.close();
|
||||
@@ -1598,7 +1722,9 @@ function bindFileBrowser() {
|
||||
const p = li.dataset.path;
|
||||
if (li.dataset.dir) return fbLoad(p);
|
||||
if (fb.mode === 'open') {
|
||||
if (fb.sel.has(p)) fb.sel.delete(p); else fb.sel.add(p);
|
||||
const had = fb.sel.has(p);
|
||||
if (fb.kind === 'captions') fb.sel.clear(); // one caption file at a time
|
||||
if (had) fb.sel.delete(p); else fb.sel.add(p);
|
||||
} else {
|
||||
$('#fbName').value = splitPath(p)[1];
|
||||
}
|
||||
@@ -1607,8 +1733,10 @@ function bindFileBrowser() {
|
||||
$('#fbList').addEventListener('dblclick', e => {
|
||||
const li = e.target.closest('li[data-path]');
|
||||
if (!li || li.dataset.dir || !fb) return;
|
||||
if (fb.mode === 'open') fb.sel.add(li.dataset.path);
|
||||
else $('#fbName').value = splitPath(li.dataset.path)[1];
|
||||
if (fb.mode === 'open') {
|
||||
if (fb.kind === 'captions') fb.sel.clear();
|
||||
fb.sel.add(li.dataset.path);
|
||||
} else $('#fbName').value = splitPath(li.dataset.path)[1];
|
||||
fbFinish();
|
||||
});
|
||||
$('#fbOk').addEventListener('click', fbFinish);
|
||||
@@ -1683,6 +1811,7 @@ function saveNow() {
|
||||
current: cur() ? cur().path : null,
|
||||
time: S.time,
|
||||
marks,
|
||||
captions: Object.fromEntries(S.sources.filter(m => S.captions[m.id]).map(m => [m.path, S.captions[m.id].path])),
|
||||
settings: S.settings,
|
||||
};
|
||||
fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(() => {});
|
||||
@@ -1706,10 +1835,17 @@ async function restoreSession() {
|
||||
for (const [p, mk] of Object.entries(s.marks || {})) {
|
||||
if (byPath[p]) S.marks[byPath[p]] = { in: mk.in ?? null, out: mk.out ?? null };
|
||||
}
|
||||
const caps = Object.entries(s.captions || {}).filter(([p]) => byPath[p]);
|
||||
const loaded = await Promise.all(caps.map(([, srt]) => api('/api/captions', { path: srt }).catch(() => null)));
|
||||
const lost = [];
|
||||
caps.forEach(([p, srt], i) => { if (loaded[i]) S.captions[byPath[p]] = loaded[i]; else lost.push(srt); });
|
||||
if (missing.length) {
|
||||
toast(`${missing.length} video${missing.length > 1 ? 's' : ''} from your last session could not be found:\n` +
|
||||
missing.map(p => splitPath(p)[1]).join('\n'), 'error');
|
||||
}
|
||||
if (lost.length) {
|
||||
toast('Captions from your last session could not be loaded:\n' + lost.map(p => splitPath(p)[1]).join('\n'), 'error');
|
||||
}
|
||||
S.sources.forEach(m => ensureKeyframes(m.id));
|
||||
const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id);
|
||||
if (curId) loadSource(curId, { time: s.time || 0 });
|
||||
@@ -1770,6 +1906,8 @@ function bindUI() {
|
||||
const act = e.target.closest('[data-sact]');
|
||||
if (act && act.dataset.sact === 'remove') return removeSource(li.dataset.id);
|
||||
if (act && act.dataset.sact === 'whole') return addWhole(li.dataset.id);
|
||||
if (act && act.dataset.sact === 'captions') return loadCaptions(li.dataset.id);
|
||||
if (act && act.dataset.sact === 'nocaptions') return removeCaptions(li.dataset.id);
|
||||
if (li.dataset.id !== S.cur) loadSource(li.dataset.id);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user