feat(playlists): confirm before adding songs that are already in the playlist (#329)

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) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-26 22:13:13 +02:00
committed by GitHub
parent 9fe81ee6f6
commit e0c53da94d
9 changed files with 122 additions and 49 deletions
+90 -49
View File
@@ -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, unknown>) => string,
): Promise<boolean> {
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');