From e0c53da94d37dc785479312afd1ff5adf1425ca9 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:13:13 +0200 Subject: [PATCH] feat(playlists): confirm before adding songs that are already in the playlist (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #327. When every selected song is already in the target playlist, the "Add to Playlist" flow used to silently show an "all skipped" toast. Users could not tell whether they had hit the wrong target or whether the action was deliberately ignored, and they had no way to add duplicates intentionally (e.g. for a song they actually want twice). New behavior: if every id is a duplicate, ask via the existing GlobalConfirmModal — "Add anyway" appends them as duplicates, "Cancel" keeps the previous silent-skip toast. Mixed cases (some new, some duplicate) and the all-new path are unchanged. Applied at all three add-to-playlist call sites in the context menu (single/multi-track, album selection, artist selection). Strings added to all 8 locales (en, de, es, fr, nl, nb, ru, zh). Co-authored-by: Claude Opus 4.7 (1M context) --- src/components/ContextMenu.tsx | 139 +++++++++++++++++++++------------ src/locales/de.ts | 4 + src/locales/en.ts | 4 + src/locales/es.ts | 4 + src/locales/fr.ts | 4 + src/locales/nb.ts | 4 + src/locales/nl.ts | 4 + src/locales/ru.ts | 4 + src/locales/zh.ts | 4 + 9 files changed, 122 insertions(+), 49 deletions(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 2e28fed1..94061497 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -25,6 +25,23 @@ import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; import type { EntityShareKind } from '../utils/shareLink'; import { copyEntityShareLink } from '../utils/copyEntityShareLink'; +import { useConfirmModalStore } from '../store/confirmModalStore'; + +/** Ask user before re-adding songs to a playlist when *all* selected ids are + * already present. Returns true → caller should append them as duplicates, + * false → caller should keep today's silent-skip toast behavior. */ +async function confirmAddAllDuplicates( + playlistName: string, + count: number, + t: (key: string, opts?: Record) => string, +): Promise { + return useConfirmModalStore.getState().request({ + title: t('playlists.duplicateConfirmTitle'), + message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }), + confirmLabel: t('playlists.duplicateConfirmAction'), + cancelLabel: t('common.cancel'), + }); +} function sanitizeFilename(name: string): string { return name @@ -110,10 +127,17 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: { if (newIds.length > 0) { await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]); showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name })); + touchPlaylist(pl.id); } else { - showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t); + if (accepted) { + await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]); + showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + touchPlaylist(pl.id); + } else { + showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + } } - touchPlaylist(pl.id); } catch { showToast(t('playlists.addError'), 3000, 'error'); } @@ -273,34 +297,42 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds } } - if (newIds.length > 0) { - await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]); - touchPlaylist(pl.id); - } - - // Show detailed toast notification - const totalSongs = songIds.length; const addedCount = newIds.length; const duplicateCount = duplicateIds.length; - if (addedCount === 0 && duplicateCount > 0) { - showToast( - t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), - 4000, - 'info' - ); + if (addedCount > 0) { + await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]); + touchPlaylist(pl.id); + if (duplicateCount > 0) { + showToast( + t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), + 4000, + 'info' + ); + } else { + showToast( + t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), + 3000, + 'info' + ); + } } else if (duplicateCount > 0) { - showToast( - t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), - 4000, - 'info' - ); - } else { - showToast( - t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), - 3000, - 'info' - ); + const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t); + if (accepted) { + await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]); + touchPlaylist(pl.id); + showToast( + t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }), + 3000, + 'info' + ); + } else { + showToast( + t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), + 4000, + 'info' + ); + } } } catch (err) { showToast(t('playlists.addError'), 4000, 'error'); @@ -509,33 +541,42 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist } } - if (newIds.length > 0) { - await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]); - touchPlaylist(pl.id); - } - - // Show detailed toast notification const addedCount = newIds.length; const duplicateCount = duplicateIds.length; - if (addedCount === 0 && duplicateCount > 0) { - showToast( - t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), - 4000, - 'info' - ); + if (addedCount > 0) { + await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]); + touchPlaylist(pl.id); + if (duplicateCount > 0) { + showToast( + t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), + 4000, + 'info' + ); + } else { + showToast( + t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), + 3000, + 'info' + ); + } } else if (duplicateCount > 0) { - showToast( - t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), - 4000, - 'info' - ); - } else { - showToast( - t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), - 3000, - 'info' - ); + const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t); + if (accepted) { + await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]); + touchPlaylist(pl.id); + showToast( + t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }), + 3000, + 'info' + ); + } else { + showToast( + t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), + 4000, + 'info' + ); + } } } catch (err) { showToast(t('playlists.addError'), 4000, 'error'); diff --git a/src/locales/de.ts b/src/locales/de.ts index 2a8c8f71..2a1a8d2c 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1309,6 +1309,10 @@ export const deTranslation = { addSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt', addPartial: '{{added}} hinzugefügt, {{skipped}} übersprungen (Duplikate)', addAllSkipped: 'Alle übersprungen ({{count}} Duplikate)', + addedAsDuplicates: '{{count}} Songs zu {{playlist}} hinzugefügt (als Duplikate)', + duplicateConfirmTitle: 'Bereits in Playlist', + duplicateConfirmMessage: 'Alle {{count}} Songs sind bereits in "{{playlist}}". Trotzdem als Duplikate hinzufügen?', + duplicateConfirmAction: 'Trotzdem hinzufügen', addError: 'Fehler beim Hinzufügen von Songs', createAndAddSuccess: 'Playlist "{{playlist}}" mit {{count}} Songs erstellt', createError: 'Fehler beim Erstellen der Playlist', diff --git a/src/locales/en.ts b/src/locales/en.ts index a3d8cd22..81e046af 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1315,6 +1315,10 @@ export const enTranslation = { addSuccess: '{{count}} songs added to {{playlist}}', addPartial: '{{added}} added, {{skipped}} skipped (duplicates)', addAllSkipped: 'All skipped ({{count}} duplicates)', + addedAsDuplicates: '{{count}} songs added to {{playlist}} (as duplicates)', + duplicateConfirmTitle: 'Already in playlist', + duplicateConfirmMessage: 'All {{count}} songs are already in "{{playlist}}". Add them again as duplicates?', + duplicateConfirmAction: 'Add anyway', addError: 'Error adding songs', createAndAddSuccess: 'Playlist "{{playlist}}" created with {{count}} songs', createError: 'Error creating playlist', diff --git a/src/locales/es.ts b/src/locales/es.ts index 5de501a7..8e3524ad 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1302,6 +1302,10 @@ export const esTranslation = { addSuccess: '{{count}} canciones agregadas a {{playlist}}', addPartial: '{{added}} agregadas, {{skipped}} skippeadas por duplicadas', addAllSkipped: 'Todas skippeadas ({{count}} duplicadas)', + addedAsDuplicates: '{{count}} canciones añadidas a {{playlist}} (como duplicados)', + duplicateConfirmTitle: 'Ya en la lista', + duplicateConfirmMessage: 'Las {{count}} canciones ya están en "{{playlist}}". ¿Añadirlas igualmente como duplicados?', + duplicateConfirmAction: 'Añadir igualmente', addError: 'Error al agregar canciones', createAndAddSuccess: 'Lista "{{playlist}}" creada con {{count}} canciones', createError: 'Error al crear lista', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 1730b4d4..33e0926a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1297,6 +1297,10 @@ export const frTranslation = { addSuccess: '{{count}} morceaux ajoutés à {{playlist}}', addPartial: '{{added}} ajoutés, {{skipped}} ignorés (doublons)', addAllSkipped: 'Tous ignorés ({{count}} doublons)', + addedAsDuplicates: '{{count}} morceaux ajoutés à {{playlist}} (en doublons)', + duplicateConfirmTitle: 'Déjà dans la playlist', + duplicateConfirmMessage: 'Les {{count}} morceaux sont déjà dans « {{playlist}} ». Les ajouter quand même en doublons ?', + duplicateConfirmAction: 'Ajouter quand même', addError: 'Erreur lors de l\'ajout des morceaux', createAndAddSuccess: 'Playlist "{{playlist}}" créée avec {{count}} morceaux', createError: 'Erreur lors de la création de la playlist', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 053bb9a2..c16f41de 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1296,6 +1296,10 @@ export const nbTranslation = { addSuccess: '{{count}} sanger lagt til i {{playlist}}', addPartial: '{{added}} lagt til, {{skipped}} hoppet over (duplikater)', addAllSkipped: 'Alle hoppet over ({{count}} duplikater)', + addedAsDuplicates: '{{count}} sanger lagt til i {{playlist}} (som duplikater)', + duplicateConfirmTitle: 'Allerede i spillelisten', + duplicateConfirmMessage: 'Alle {{count}} sanger finnes allerede i "{{playlist}}". Legg dem til likevel som duplikater?', + duplicateConfirmAction: 'Legg til likevel', addError: 'Feil ved å legge til sanger', createAndAddSuccess: 'Spilleliste "{{playlist}}" opprettet med {{count}} sanger', createError: 'Feil ved oppretting av spilleliste', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 6d814e9c..c2038da1 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1296,6 +1296,10 @@ export const nlTranslation = { addSuccess: '{{count}} nummers toegevoegd aan {{playlist}}', addPartial: '{{added}} toegevoegd, {{skipped}} overgeslagen (duplicaten)', addAllSkipped: 'Alles overgeslagen ({{count}} duplicaten)', + addedAsDuplicates: '{{count}} nummers toegevoegd aan {{playlist}} (als duplicaten)', + duplicateConfirmTitle: 'Al in afspeellijst', + duplicateConfirmMessage: 'Alle {{count}} nummers staan al in "{{playlist}}". Toch als duplicaten toevoegen?', + duplicateConfirmAction: 'Toch toevoegen', addError: 'Fout bij toevoegen van nummers', createAndAddSuccess: 'Playlist "{{playlist}}" aangemaakt met {{count}} nummers', createError: 'Fout bij aanmaken van playlist', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index dfbd274f..08bce342 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1381,6 +1381,10 @@ export const ruTranslation = { addSuccess: '{{count}} треков добавлено в {{playlist}}', addPartial: '{{added}} добавлено, {{skipped}} пропущено (дубликаты)', addAllSkipped: 'Все пропущены ({{count}} дубликатов)', + addedAsDuplicates: '{{count}} треков добавлено в {{playlist}} (как дубликаты)', + duplicateConfirmTitle: 'Уже в плейлисте', + duplicateConfirmMessage: 'Все {{count}} треков уже есть в «{{playlist}}». Всё равно добавить их как дубликаты?', + duplicateConfirmAction: 'Всё равно добавить', addError: 'Ошибка при добавлении треков', createAndAddSuccess: 'Плейлист "{{playlist}}" создан с {{count}} треками', createError: 'Ошибка при создании плейлиста', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c1b551d8..4bcfd670 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1291,6 +1291,10 @@ export const zhTranslation = { addSuccess: '{{count}} 首歌曲已添加到 {{playlist}}', addPartial: '{{added}} 首已添加,{{skipped}} 首跳过(重复)', addAllSkipped: '全部跳过({{count}} 首重复)', + addedAsDuplicates: '{{count}} 首歌曲已添加到 {{playlist}}(作为重复项)', + duplicateConfirmTitle: '已在播放列表中', + duplicateConfirmMessage: '所有 {{count}} 首歌曲已在 "{{playlist}}" 中。仍要作为重复项添加吗?', + duplicateConfirmAction: '仍要添加', addError: '添加歌曲时出错', createAndAddSuccess: '播放列表 "{{playlist}}" 已创建,包含 {{count}} 首歌曲', createError: '创建播放列表时出错',