feat: Prevent UI hanging during video save or playlist add
Task #1 completed by ClaudeQueue ClaudeQueue
This commit is contained in:
@@ -60,6 +60,17 @@ let dragSource = -1; // index of card being dragged
|
||||
let saveTimer = null;
|
||||
const cachedIds = new Set(); // video ids that exist in the offline cache
|
||||
const downloading = new Set(); // video ids with an in-flight download
|
||||
const removing = new Set(); // video ids with an in-flight cache removal
|
||||
const playlistOps = new Set(); // `${playlistId}:${videoId}` add/remove in flight
|
||||
|
||||
// Concurrency helper (frontend/async-guard.js) — keeps the save / playlist
|
||||
// operations from being triggered twice at once for the same target.
|
||||
const runExclusive = (window.AsyncGuard && window.AsyncGuard.runExclusive)
|
||||
|| (async (set, key, fn) => {
|
||||
if (set.has(key)) return undefined;
|
||||
set.add(key);
|
||||
try { return await fn(); } finally { set.delete(key); }
|
||||
});
|
||||
|
||||
// ---------- DOM ----------
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -165,6 +176,10 @@ async function preload(video, { quiet = false } = {}) {
|
||||
if (!id || cachedIds.has(id) || downloading.has(id)) return;
|
||||
downloading.add(id);
|
||||
markCardCacheState(id, 'downloading');
|
||||
// Reflect the busy state on the now-playing Save button immediately, so the
|
||||
// UI responds the moment the (long) download begins rather than only when it
|
||||
// finishes in the `finally` below.
|
||||
if (current && current.meta && current.meta.id === id) updateNowPlayingActions();
|
||||
if (!quiet) toast(`Saving “${video.title}” for offline…`);
|
||||
try {
|
||||
const res = await API.cacheDownload(id);
|
||||
@@ -551,15 +566,19 @@ function updateNowPlayingActions() {
|
||||
const id = current.meta.id;
|
||||
const btn = els.saveBtn;
|
||||
if (!btn) return;
|
||||
if (cachedIds.has(id)) {
|
||||
btn.textContent = '✓ Saved';
|
||||
btn.classList.add('done');
|
||||
btn.disabled = false;
|
||||
btn.title = 'Saved for offline — click to remove from cache';
|
||||
if (removing.has(id)) {
|
||||
btn.textContent = '⏳ Removing…';
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = true;
|
||||
} else if (downloading.has(id)) {
|
||||
btn.textContent = '⏳ Saving…';
|
||||
btn.classList.remove('done');
|
||||
btn.disabled = true;
|
||||
} else if (cachedIds.has(id)) {
|
||||
btn.textContent = '✓ Saved';
|
||||
btn.classList.add('done');
|
||||
btn.disabled = false;
|
||||
btn.title = 'Saved for offline — click to remove from cache';
|
||||
} else {
|
||||
btn.textContent = '⬇ Save';
|
||||
btn.classList.remove('done');
|
||||
@@ -1088,20 +1107,37 @@ function openCardMenu(video) {
|
||||
body.className = 'modal-list';
|
||||
|
||||
data.playlists.forEach((pl) => {
|
||||
const has = pl.videos.some((x) => x.id === video.id);
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = (has ? '✓ ' : '+ ') + pl.name;
|
||||
const label = () => ((pl.videos.some((x) => x.id === video.id)) ? '✓ ' : '+ ') + pl.name;
|
||||
btn.textContent = label();
|
||||
btn.onclick = () => {
|
||||
if (has) {
|
||||
pl.videos = pl.videos.filter((x) => x.id !== video.id);
|
||||
} else {
|
||||
pl.videos.push(slim(video));
|
||||
preload(video); // auto-cache for offline playback
|
||||
}
|
||||
persist();
|
||||
closeModal();
|
||||
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
|
||||
render();
|
||||
const key = pl.id + ':' + video.id;
|
||||
// Guard against rapid double-clicks: the op is keyed by playlist+video,
|
||||
// so a second click while the first is in flight is ignored (prevents the
|
||||
// same video being pushed twice / state corruption).
|
||||
runExclusive(playlistOps, key, async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ ' + pl.name;
|
||||
// Recompute membership now (not from a flag captured at menu-open),
|
||||
// so the decision reflects current state.
|
||||
const has = pl.videos.some((x) => x.id === video.id);
|
||||
try {
|
||||
if (has) {
|
||||
pl.videos = pl.videos.filter((x) => x.id !== video.id);
|
||||
} else {
|
||||
pl.videos.push(slim(video));
|
||||
// Auto-cache for offline playback (fire-and-forget; its own guard
|
||||
// prevents duplicate downloads).
|
||||
preload(video);
|
||||
}
|
||||
persist();
|
||||
toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`);
|
||||
} catch (e) {
|
||||
toast('⚠ Could not update playlist');
|
||||
}
|
||||
closeModal();
|
||||
render();
|
||||
});
|
||||
};
|
||||
body.appendChild(btn);
|
||||
});
|
||||
@@ -1282,13 +1318,25 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
els.saveBtn.addEventListener('click', async () => {
|
||||
if (!current || !current.meta) return;
|
||||
const id = current.meta.id;
|
||||
// Ignore clicks while either direction is already in flight for this video.
|
||||
if (downloading.has(id) || removing.has(id)) return;
|
||||
if (cachedIds.has(id)) {
|
||||
// Already saved → remove from cache.
|
||||
try { await API.cacheDelete(id); } catch {}
|
||||
cachedIds.delete(id);
|
||||
toast('Removed from offline cache');
|
||||
// Already saved → remove from cache. Guarded so a rapid double-click
|
||||
// can't fire two deletes; the button shows a busy state meanwhile.
|
||||
await runExclusive(removing, id, async () => {
|
||||
updateNowPlayingActions();
|
||||
try {
|
||||
const res = await API.cacheDelete(id);
|
||||
if (res && res.ok === false) throw new Error(res.error || 'delete failed');
|
||||
cachedIds.delete(id);
|
||||
markCardCacheState(id, 'none');
|
||||
toast('Removed from offline cache');
|
||||
} catch (e) {
|
||||
// Non-blocking error; leave it marked cached and re-enable the button.
|
||||
toast('⚠ Could not remove from cache');
|
||||
}
|
||||
});
|
||||
updateNowPlayingActions();
|
||||
markCardCacheState(id, 'none');
|
||||
} else {
|
||||
await preload(current.meta);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user