feat: add multi-selection and context menu for playlist management

This commit is contained in:
kveld9
2026-04-12 14:29:41 -03:00
parent 805b6bf163
commit bef6941a2b
12 changed files with 7955 additions and 99 deletions
+865
View File
@@ -198,6 +198,733 @@ function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: { artistId: st
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
}
// Resolves all songs from multiple albums and adds them to playlist with detailed toast notifications
function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds: string[]; onDone: () => void; triggerId?: string }) {
const { t } = useTranslation();
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
const [totalAlbums, setTotalAlbums] = useState(0);
useEffect(() => {
setTotalAlbums(albumIds.length);
(async () => {
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
const allSongs = albumSongs.flat();
setResolvedIds(allSongs.map(s => s.id));
})().catch(() => setResolvedIds([]));
}, [albumIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: existingSongs } = await getPlaylist(pl.id);
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
const duplicateIds: string[] = [];
for (const id of songIds) {
if (existingIds.has(id)) {
duplicateIds.push(id);
} else {
newIds.push(id);
}
}
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'
);
} 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'
);
}
} catch (err) {
showToast(t('playlists.addError'), 4000, 'error');
}
onDone();
};
const handleCreateWithToast = async (songIds: string[]) => {
const { createPlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const name = t('playlists.unnamed');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
touchPlaylist(pl.id);
showToast(
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
3000,
'info'
);
}
} catch {
showToast(t('playlists.createError'), 4000, 'error');
}
onDone();
};
// Custom AddToPlaylistSubmenu with toast notifications for multiple albums
function MultiAddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [flipLeft, setFlipLeft] = useState(false);
useEffect(() => {
getPlaylists().then((all) => {
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
}).catch(() => {});
}, []);
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
}
}, []);
useEffect(() => {
if (creating) newNameRef.current?.focus();
}, [creating]);
const handleAdd = async (pl: SubsonicPlaylist) => {
setAdding(pl.id);
await handleAddWithToast(pl, songIds);
setAdding(null);
};
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const { createPlaylist } = await import('../api/subsonic');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
const { usePlaylistStore } = await import('../store/playlistStore');
usePlaylistStore.getState().touchPlaylist(pl.id);
showToast(
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
3000,
'info'
);
}
} catch {
showToast(t('playlists.createError'), 4000, 'error');
}
onDone();
};
const subStyle: React.CSSProperties = flipLeft
? { right: 'calc(100% + 4px)', left: 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div className="context-submenu" ref={subRef} style={subStyle}>
{!creating ? (
<div
className="context-menu-item context-submenu-new"
onClick={e => { e.stopPropagation(); setCreating(true); }}
>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{playlists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.empty')}</div>
)}
{playlists.map((pl) => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl)}
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
if (resolvedIds === null) {
return (
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('playlists.loadingAlbums', { count: totalAlbums })}
</span>
</div>
);
}
if (resolvedIds.length === 0) return null;
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
// Resolves all songs from multiple artists and adds them to playlist with detailed toast notifications
function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artistIds: string[]; onDone: () => void; triggerId?: string }) {
const { t } = useTranslation();
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
const [totalArtists, setTotalArtists] = useState(0);
useEffect(() => {
setTotalArtists(artistIds.length);
(async () => {
const allSongs: string[] = [];
for (const artistId of artistIds) {
try {
const { albums } = await getArtist(artistId);
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
allSongs.push(...albumSongs.flat().map(s => s.id));
} catch {
// Skip failed artists
}
}
setResolvedIds(allSongs);
})().catch(() => setResolvedIds([]));
}, [artistIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: existingSongs } = await getPlaylist(pl.id);
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
const duplicateIds: string[] = [];
for (const id of songIds) {
if (existingIds.has(id)) {
duplicateIds.push(id);
} else {
newIds.push(id);
}
}
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'
);
} 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'
);
}
} catch (err) {
showToast(t('playlists.addError'), 4000, 'error');
}
onDone();
};
// Custom AddToPlaylistSubmenu with toast notifications for multiple artists
function MultiAddToPlaylistSubmenu({ songIds, onDone }: { songIds: string[]; onDone: () => void }) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [flipLeft, setFlipLeft] = useState(false);
useEffect(() => {
getPlaylists().then((all) => {
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
}).catch(() => {});
}, []);
useLayoutEffect(() => {
if (subRef.current) {
const rect = subRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth - 8) setFlipLeft(true);
}
}, []);
useEffect(() => {
if (creating) newNameRef.current?.focus();
}, [creating]);
const handleAdd = async (pl: SubsonicPlaylist) => {
setAdding(pl.id);
await handleAddWithToast(pl, songIds);
setAdding(null);
};
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const { createPlaylist } = await import('../api/subsonic');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
const { usePlaylistStore } = await import('../store/playlistStore');
usePlaylistStore.getState().touchPlaylist(pl.id);
showToast(
t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }),
3000,
'info'
);
}
} catch {
showToast(t('playlists.createError'), 4000, 'error');
}
onDone();
};
const subStyle: React.CSSProperties = flipLeft
? { right: 'calc(100% + 4px)', left: 'auto' }
: { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div className="context-submenu" ref={subRef} style={subStyle}>
{!creating ? (
<div
className="context-menu-item context-submenu-new"
onClick={e => { e.stopPropagation(); setCreating(true); }}
>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{playlists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.empty')}</div>
)}
{playlists.map((pl) => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl)}
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
if (resolvedIds === null) {
return (
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('playlists.loadingArtists', { count: totalArtists })}
</span>
</div>
);
}
if (resolvedIds.length === 0) return null;
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
// Submenu for adding a single playlist to another playlist
function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { playlist: { id: string; name: string }; onDone: () => void; triggerId?: string }) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [allPlaylists, setAllPlaylists] = useState<{ id: string; name: string }[]>([]);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const newNameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
(async () => {
const { getPlaylists } = await import('../api/subsonic');
const pls = await getPlaylists().catch(() => []);
setAllPlaylists(pls.filter((p: { id: string }) => p.id !== playlist.id));
setLoading(false);
})();
}, [playlist]);
useEffect(() => {
if (creating && newNameRef.current) {
newNameRef.current.focus();
}
}, [creating]);
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('../api/subsonic');
const { showToast } = await import('../utils/toast');
try {
const newPl = await createPlaylist(newName.trim(), []);
if (newPl?.id) {
await handleAddToNewPlaylist(newPl.id, newPl.name || newName.trim());
}
setCreating(false);
setNewName('');
} catch {
showToast(t('playlists.createError'), 3000, 'error');
}
};
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: sourceSongs } = await getPlaylist(playlist.id);
if (sourceSongs.length > 0) {
await updatePlaylist(targetId, sourceSongs.map((s: { id: string }) => s.id));
touchPlaylist(targetId);
showToast(t('playlists.createAndAddSuccess', { count: sourceSongs.length, playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
onDone();
}
};
const handleAdd = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: targetSongs } = await getPlaylist(targetId);
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
const { songs: sourceSongs } = await getPlaylist(playlist.id);
const newSongs = sourceSongs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.addToPlaylistSuccess', { count: newSongs.length, playlist: targetName }), 3000, 'info');
} else {
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.addToPlaylistError'), 4000, 'error');
onDone();
}
};
if (loading) {
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ minWidth: 190 }}>
<div style={{ padding: '0.75rem', display: 'flex', justifyContent: 'center' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
</div>
);
}
const subStyle: React.CSSProperties = { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
{/* New Playlist row */}
{!creating ? (
<div
className="context-menu-item context-submenu-new"
onClick={e => { e.stopPropagation(); setCreating(true); }}
>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{allPlaylists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
)}
{allPlaylists.map(pl => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleAdd(pl.id, pl.name)}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
// Submenu for merging multiple playlists into another playlist
function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { playlists: { id: string; name: string }[]; onDone: () => void; triggerId?: string }) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [allPlaylists, setAllPlaylists] = useState<{ id: string; name: string }[]>([]);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const newNameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
(async () => {
const { getPlaylists } = await import('../api/subsonic');
const pls = await getPlaylists().catch(() => []);
const selectedIds = new Set(playlists.map(p => p.id));
setAllPlaylists(pls.filter((p: { id: string }) => !selectedIds.has(p.id)));
setLoading(false);
})();
}, [playlists]);
useEffect(() => {
if (creating && newNameRef.current) {
newNameRef.current.focus();
}
}, [creating]);
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('../api/subsonic');
const { showToast } = await import('../utils/toast');
try {
const newPl = await createPlaylist(newName.trim(), []);
if (newPl?.id) {
await handleMergeToNewPlaylist(newPl.id, newPl.name || newName.trim());
}
setCreating(false);
setNewName('');
} catch {
showToast(t('playlists.createError'), 3000, 'error');
}
};
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const targetIds = new Set<string>();
let totalAdded = 0;
for (const pl of playlists) {
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.createAndAddSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
onDone();
}
};
const handleMerge = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: targetSongs } = await getPlaylist(targetId);
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
let totalAdded = 0;
for (const pl of playlists) {
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
onDone();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
onDone();
}
};
if (loading) {
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ minWidth: 190 }}>
<div style={{ padding: '0.75rem', display: 'flex', justifyContent: 'center' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
</div>
);
}
const subStyle: React.CSSProperties = { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
{/* New Playlist row */}
{!creating ? (
<div
className="context-menu-item context-submenu-new"
onClick={e => { e.stopPropagation(); setCreating(true); }}
>
<Plus size={13} /> {t('playlists.newPlaylist')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.createName')}
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="context-submenu-create-btn" onClick={handleCreate}>
<Plus size={13} />
</button>
</div>
)}
<div className="context-menu-divider" />
{allPlaylists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
)}
{allPlaylists.map(pl => (
<div
key={pl.id}
className="context-menu-item"
onClick={() => handleMerge(pl.id, pl.name)}
>
<ListMusic size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
</div>
))}
</div>
);
}
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
@@ -820,6 +1547,72 @@ export default function ContextMenu() {
);
})()}
{type === 'playlist' && (() => {
const playlist = item as SubsonicPlaylist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/playlists/${playlist.id}`))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`playlist:${playlist.id}`}
onMouseEnter={() => { setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../utils/toast');
const { deletePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const removeId = usePlaylistStore.getState().removeId;
try {
await deletePlaylist(playlist.id);
removeId(playlist.id);
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
window.location.reload();
} catch {
showToast(t('playlists.deleteFailed', { name: playlist.name }), 3000, 'error');
}
})}>
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
</div>
</>
);
})()}
{type === 'multi-album' && (() => {
const albums = item as SubsonicAlbum[];
const albumIds = albums.map(a => a.id);
return (
<>
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
{t('contextMenu.selectedAlbums', { count: albums.length })}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
onMouseEnter={() => { setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
</>
);
})()}
{type === 'artist' && (() => {
const artist = item as SubsonicArtist;
const artistRatingDisabled = entityRatingSupport === 'track_only';
@@ -870,6 +1663,78 @@ export default function ContextMenu() {
);
})()}
{type === 'multi-artist' && (() => {
const artists = item as SubsonicArtist[];
const artistIds = artists.map(a => a.id);
return (
<>
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
{t('contextMenu.selectedArtists', { count: artists.length })}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
onMouseEnter={() => { setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
</>
);
})()}
{type === 'multi-playlist' && (() => {
const selectedPlaylists = item as SubsonicPlaylist[];
const playlistIds = selectedPlaylists.map(p => p.id);
return (
<>
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
</div>
<div className="context-menu-divider" />
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
onMouseEnter={() => { setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../utils/toast');
const { usePlaylistStore } = await import('../store/playlistStore');
const { deletePlaylist } = await import('../api/subsonic');
const removeId = usePlaylistStore.getState().removeId;
let deleted = 0;
for (const pl of selectedPlaylists) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
}
window.location.reload();
})}>
<Trash2 size={14} /> {t('playlists.deleteSelected')}
</div>
</>
);
})()}
{type === 'queue-item' && (() => {
const song = item as Track;
return (