import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ListMusic, Plus } from 'lucide-react'; import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '@/features/offline'; import { getPlaylists } from '@/lib/api/subsonicPlaylists'; import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes'; import { usePlaylistStore } from '@/features/playlist'; import { showToast } from '@/lib/dom/toast'; import { confirmAddAllDuplicates, isSmartPlaylistName, } from '@/features/contextMenu/utils/contextMenuHelpers'; interface Props { artistIds: string[]; onDone: () => void; triggerId?: string; } export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _triggerId }: Props) { const { t } = useTranslation(); const [resolvedIds, setResolvedIds] = useState(null); const [totalArtists, setTotalArtists] = useState(0); const [showLoading, setShowLoading] = useState(false); useEffect(() => { // React Compiler set-state-in-effect rule: state set from a timer/animation callback. // eslint-disable-next-line react-hooks/set-state-in-effect setTotalArtists(artistIds.length); const loadingTimeout = setTimeout(() => setShowLoading(true), 300); (async () => { const allSongs: string[] = []; const serverId = resolveMediaServerId(); if (!serverId) { setResolvedIds([]); return; } for (const artistId of artistIds) { try { const artistData = await resolveArtist(serverId, artistId); if (!artistData) continue; const albumSongs = await Promise.all( artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])), ); allSongs.push(...albumSongs.flat().map(s => s.id)); } catch { // Skip failed artists } } setResolvedIds(allSongs); })().catch(() => setResolvedIds([])); return () => clearTimeout(loadingTimeout); }, [artistIds]); const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => { const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists'); const touchPlaylist = usePlaylistStore.getState().touchPlaylist; try { const serverId = resolveMediaServerId(); if (!serverId) return; const resolved = await resolvePlaylist(serverId, pl.id); if (!resolved) return; const { songs: existingSongs } = resolved; 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); } const addedCount = newIds.length; const duplicateCount = duplicateIds.length; 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) { 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 { showToast(t('playlists.addError'), 4000, 'error'); } onDone(); }; // Custom AddToPlaylistSubmenu with toast notifications for multiple artists function MultiAddToPlaylistSubmenu({ songIds, onDone: innerOnDone }: { songIds: string[]; onDone: () => void }) { const subRef = useRef(null); const newNameRef = useRef(null); const [playlists, setPlaylists] = useState([]); const [adding, setAdding] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const [flipLeft, setFlipLeft] = useState(false); const [flipUp, setFlipUp] = useState(false); useEffect(() => { getPlaylists().then((all) => { setPlaylists( all.filter(p => !isSmartPlaylistName(p.name)).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); if (rect.bottom > window.innerHeight - 8) setFlipUp(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('@/lib/api/subsonicPlaylists'); const pl = await createPlaylist(name, songIds); if (pl?.id) { 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'); } innerOnDone(); }; const subStyle: React.CSSProperties = flipLeft ? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
{!creating ? (
{ e.stopPropagation(); setCreating(true); }}> {t('playlists.newPlaylist')}
) : (
e.stopPropagation()}> setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setCreating(false); setNewName(''); } }} />
)}
{playlists.length === 0 && (
{t('playlists.empty')}
)} {playlists.map((pl) => (
handleAdd(pl)} style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }} > {pl.name}
))}
); } if (resolvedIds === null) { if (!showLoading) { return
; } return (
{t('playlists.loadingArtists', { count: totalArtists })}
); } if (resolvedIds.length === 0) return null; // React Compiler rule: component intentionally defined inline for closure access. // eslint-disable-next-line react-hooks/static-components return ; }