diff --git a/frontend/app.js b/frontend/app.js
index 1b220b9..aac684c 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -74,6 +74,17 @@ 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 downloadMeta = new Map(); // id -> video object, for the Downloads page
+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);
@@ -197,6 +208,10 @@ async function preload(video, { quiet = false } = {}) {
markCardCacheState(id, 'downloading');
if (view.type === 'downloads') renderList();
updateDownloadBadge();
+ // 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);
@@ -594,15 +609,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');
@@ -1549,20 +1568,36 @@ function openCardMenu(video) {
body.appendChild(divider);
data.playlists.forEach((pl) => {
- const has = pl.videos.some((x) => x.id === video.id);
const btn = document.createElement('button');
- btn.textContent = (has ? '✓ ' : '+ ') + pl.name;
+ btn.textContent = (pl.videos.some((x) => x.id === video.id) ? '✓ ' : '+ ') + pl.name;
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 {
+ toast('⚠ Could not update playlist');
+ }
+ closeModal();
+ render();
+ });
};
body.appendChild(btn);
});
@@ -1747,13 +1782,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 {
+ // 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