From 95dc9e3f98cb181532eaeb5e776a4d5d0cc38159 Mon Sep 17 00:00:00 2001 From: Claude Worker Date: Thu, 18 Jun 2026 18:43:48 +0000 Subject: [PATCH 1/2] feat: Prevent UI hanging during video save or playlist add Task #1 completed by ClaudeQueue ClaudeQueue --- frontend/app.js | 92 +++++++++++++++++++++++++++--------- frontend/async-guard.js | 45 ++++++++++++++++++ frontend/async-guard.test.js | 68 ++++++++++++++++++++++++++ frontend/index.html | 1 + package.json | 3 +- 5 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 frontend/async-guard.js create mode 100644 frontend/async-guard.test.js diff --git a/frontend/app.js b/frontend/app.js index f86f495..96af174 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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); } diff --git a/frontend/async-guard.js b/frontend/async-guard.js new file mode 100644 index 0000000..333b226 --- /dev/null +++ b/frontend/async-guard.js @@ -0,0 +1,45 @@ +/* ============================================================================ + * async-guard — tiny concurrency helper shared by the frontend. + * + * Keeps long-running native operations (offline-cache download/delete, playlist + * mutations) from being triggered twice at once for the same target, so the UI + * stays responsive and on-device state can't be corrupted by rapid clicks. + * + * Framework-free and dependency-free on purpose: + * • Loads as a plain diff --git a/package.json b/package.json index 0bc3b18..143602e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "make-icon": "node scripts/make-icon.js", "tauri": "tauri", "tauri:dev": "tauri dev", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "test": "node --test frontend/" }, "author": "", "license": "MIT", From 44fa24ca526bdc691de32bae0977a8526f876016 Mon Sep 17 00:00:00 2001 From: Claude Worker Date: Thu, 18 Jun 2026 18:48:07 +0000 Subject: [PATCH 2/2] refactor: tidy playlist-menu label and catch blocks Pre-push code analysis pass for task #1 ClaudeQueue --- frontend/app.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 96af174..02c4fe6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1108,8 +1108,7 @@ function openCardMenu(video) { data.playlists.forEach((pl) => { const btn = document.createElement('button'); - const label = () => ((pl.videos.some((x) => x.id === video.id)) ? '✓ ' : '+ ') + pl.name; - btn.textContent = label(); + btn.textContent = (pl.videos.some((x) => x.id === video.id) ? '✓ ' : '+ ') + pl.name; btn.onclick = () => { const key = pl.id + ':' + video.id; // Guard against rapid double-clicks: the op is keyed by playlist+video, @@ -1132,7 +1131,7 @@ function openCardMenu(video) { } persist(); toast(has ? `Removed from ${pl.name}` : `Added to ${pl.name}`); - } catch (e) { + } catch { toast('⚠ Could not update playlist'); } closeModal(); @@ -1331,7 +1330,7 @@ document.querySelectorAll('.chip').forEach((c) => { cachedIds.delete(id); markCardCacheState(id, 'none'); toast('Removed from offline cache'); - } catch (e) { + } catch { // Non-blocking error; leave it marked cached and re-enable the button. toast('⚠ Could not remove from cache'); }