import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { useOrbitStore } from '../store/orbitStore'; import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError, } from '../utils/orbit'; import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { usePlaylistStore } from '../store/playlistStore'; import { open } from '@tauri-apps/plugin-shell'; import { join } from '@tauri-apps/api/path'; import { invoke } from '@tauri-apps/api/core'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; import type { EntityShareKind } from '../utils/shareLink'; import { copyEntityShareLink } from '../utils/copyEntityShareLink'; function sanitizeFilename(name: string): string { return name .replace(/[/\\?%*:|"<>]/g, '-') .replace(/\.{2,}/g, '.') .replace(/^[\s.]+|[\s.]+$/g, '') .substring(0, 200) || 'download'; } /** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */ function shuffleArray(arr: T[]): T[] { const result = [...arr]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } // ── Add-to-Playlist submenu ─────────────────────────────────────── export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: { songIds: string[]; onDone: () => void; dropDown?: boolean; triggerId?: string }) { const { t } = useTranslation(); const subRef = useRef(null); const newNameRef = useRef(null); const [adding, setAdding] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const [flipLeft, setFlipLeft] = useState(false); const [flipUp, setFlipUp] = useState(false); const storePlaylists = usePlaylistStore((s) => s.playlists); const recentIds = usePlaylistStore((s) => s.recentIds); const createPlaylist = usePlaylistStore((s) => s.createPlaylist); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists); // Fetch playlists on first open if the store hasn't been populated yet useEffect(() => { if (storePlaylists.length === 0) fetchPlaylists(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sort playlists by recent usage const playlists = useMemo(() => { return [...storePlaylists].sort((a, b) => { const ai = recentIds.indexOf(a.id); const bi = recentIds.indexOf(b.id); if (ai === -1 && bi === -1) return a.name.localeCompare(b.name); if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }); }, [storePlaylists, recentIds]); // Flip submenu left if it would overflow the right edge of the viewport // Flip submenu up if it would overflow the bottom of the viewport 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); try { const { songs } = await getPlaylist(pl.id); const existingIds = new Set(songs.map((s) => s.id)); const newIds = songIds.filter((id) => !existingIds.has(id)); if (newIds.length > 0) { await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]); showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name })); } else { showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info'); } touchPlaylist(pl.id); } catch { showToast(t('playlists.addError'), 3000, 'error'); } setAdding(null); onDone(); }; const handleCreate = async () => { const name = newName.trim() || t('playlists.unnamed'); try { const pl = await createPlaylist(name, songIds); if (pl?.id) { showToast(t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name })); } } catch { showToast(t('playlists.createError'), 3000, 'error'); } onDone(); }; const subStyle: React.CSSProperties = dropDown ? { top: 'calc(100% + 4px)', left: 0, right: 'auto' } : flipLeft ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
{/* New Playlist row */} {!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: SubsonicPlaylist) => (
handleAdd(pl)} style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }} > {pl.name}
))}
); } // Same as AddToPlaylistSubmenu but resolves album songs first function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: { albumId: string; onDone: () => void; triggerId?: string }) { const [resolvedIds, setResolvedIds] = useState(null); useEffect(() => { getAlbum(albumId).then((data) => { setResolvedIds(data.songs.map((s) => s.id)); }).catch(() => setResolvedIds([])); }, [albumId]); if (resolvedIds === null) { return (
); } if (resolvedIds.length === 0) return null; return ; } // Resolves all songs from all of an artist's albums, then hands off to AddToPlaylistSubmenu. function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: { artistId: string; onDone: () => void; triggerId?: string }) { const [resolvedIds, setResolvedIds] = useState(null); useEffect(() => { (async () => { const { albums } = await getArtist(artistId); const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs))); setResolvedIds(albumSongs.flat().map(s => s.id)); })().catch(() => setResolvedIds([])); }, [artistId]); if (resolvedIds === null) { return (
); } if (resolvedIds.length === 0) return null; return ; } // 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(null); const [totalAlbums, setTotalAlbums] = useState(0); const [showLoading, setShowLoading] = useState(false); useEffect(() => { setTotalAlbums(albumIds.length); // Delay showing loading state to avoid flash for fast loads const loadingTimeout = setTimeout(() => setShowLoading(true), 300); (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([])); return () => clearTimeout(loadingTimeout); }, [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(null); const newNameRef = useRef(null); const [adding, setAdding] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const [flipLeft, setFlipLeft] = useState(false); const [flipUp, setFlipUp] = useState(false); const [visible, setVisible] = useState(false); const storePlaylists = usePlaylistStore((s) => s.playlists); // Sort playlists from store (no fetch needed, prevents flash) const playlists = useMemo(() => { return [...storePlaylists].sort((a, b) => a.name.localeCompare(b.name)); }, [storePlaylists]); 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); // Show after position is calculated to prevent flash setVisible(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', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } : { left: 'calc(100% + 4px)', 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) { // Only show loading UI if it takes more than 300ms (avoid flash) if (!showLoading) { return
; } return (
{t('playlists.loadingAlbums', { count: totalAlbums })}
); } if (resolvedIds.length === 0) return null; return ; } // 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(null); const [totalArtists, setTotalArtists] = useState(0); const [showLoading, setShowLoading] = useState(false); useEffect(() => { setTotalArtists(artistIds.length); // Delay showing loading state to avoid flash for fast loads const loadingTimeout = setTimeout(() => setShowLoading(true), 300); (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([])); return () => clearTimeout(loadingTimeout); }, [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(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.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('../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', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } : { left: 'calc(100% + 4px)', 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) { // Only show loading UI if it takes more than 300ms (avoid flash) if (!showLoading) { return
; } return (
{t('playlists.loadingArtists', { count: totalArtists })}
); } if (resolvedIds.length === 0) return null; return ; } // 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(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const newNameRef = useRef(null); const [flipLeft, setFlipLeft] = useState(false); const [flipUp, setFlipUp] = useState(false); const storePlaylists = usePlaylistStore((s) => s.playlists); // Filter out the current playlist from the list const allPlaylists = useMemo(() => { return storePlaylists.filter((p) => p.id !== playlist.id); }, [storePlaylists, playlist.id]); 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) { 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(); } }; const subStyle: React.CSSProperties = flipLeft ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
{/* New Playlist row */} {!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(''); } }} />
)}
{allPlaylists.length === 0 && (
{t('playlists.noOtherPlaylists')}
)} {allPlaylists.map(pl => (
handleAdd(pl.id, pl.name)} > {pl.name}
))}
); } // 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(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const newNameRef = useRef(null); const [flipLeft, setFlipLeft] = useState(false); const [flipUp, setFlipUp] = useState(false); const storePlaylists = usePlaylistStore((s) => s.playlists); // Filter out the selected playlists from the list const allPlaylists = useMemo(() => { const selectedIds = new Set(playlists.map(p => p.id)); return storePlaylists.filter((p) => !selectedIds.has(p.id)); }, [storePlaylists, playlists]); 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) { 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(); 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(); } }; const subStyle: React.CSSProperties = flipLeft ? { right: 'calc(100% + 4px)', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' } : { left: 'calc(100% + 4px)', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return (
{/* New Playlist row */} {!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(''); } }} />
)}
{allPlaylists.length === 0 && (
{t('playlists.noOtherPlaylists')}
)} {allPlaylists.map(pl => (
handleMerge(pl.id, pl.name)} > {pl.name}
))}
); } export default function ContextMenu() { const { t } = useTranslation(); const orbitRole = useOrbitStore(s => s.role); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( useShallow(s => ({ contextMenu: s.contextMenu, closeContextMenu: s.closeContextMenu, playTrack: s.playTrack, enqueue: s.enqueue, queue: s.queue, currentTrack: s.currentTrack, removeTrack: s.removeTrack, lastfmLovedCache: s.lastfmLovedCache, setLastfmLovedForSong: s.setLastfmLovedForSong, starredOverrides: s.starredOverrides, setStarredOverride: s.setStarredOverride, openSongInfo: s.openSongInfo, userRatingOverrides: s.userRatingOverrides, setUserRatingOverride: s.setUserRatingOverride, })) ); const auth = useAuthStore(); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const entityRatingSupport = auth.activeServerId ? auth.entityRatingSupportByServer[auth.activeServerId] ?? 'unknown' : 'unknown'; const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); const menuRef = useRef(null); const previousFocusRef = useRef(null); // Adjusted coordinates to keep menu on screen const [coords, setCoords] = useState({ x: 0, y: 0 }); const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false); const [playlistSongIds, setPlaylistSongIds] = useState([]); const [keyboardRating, setKeyboardRating] = useState<{ kind: 'song' | 'album' | 'artist'; id: string; value: number } | null>(null); const [pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus] = useState(false); useEffect(() => { if (contextMenu.isOpen) { setCoords({ x: contextMenu.x, y: contextMenu.y }); setPlaylistSubmenuOpen(false); setPlaylistSongIds([]); setKeyboardRating(null); setPendingSubmenuKeyboardFocus(false); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); useEffect(() => { if (contextMenu.isOpen && menuRef.current) { const rect = menuRef.current.getBoundingClientRect(); const winW = window.innerWidth; const winH = window.innerHeight; let finalX = contextMenu.x; let finalY = contextMenu.y; if (finalX + rect.width > winW) finalX = winW - rect.width - 10; if (finalY + rect.height > winH) finalY = winH - rect.height - 10; setCoords({ x: finalX, y: finalY }); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); useEffect(() => { if (contextMenu.isOpen) { previousFocusRef.current = document.activeElement as HTMLElement | null; return; } // Clean up any keyboard focus styling when menu closes menuRef.current ?.querySelectorAll('.context-menu-keyboard-active') .forEach(el => el.classList.remove('context-menu-keyboard-active')); const prev = previousFocusRef.current; previousFocusRef.current = null; if (prev?.isConnected) { requestAnimationFrame(() => { prev.focus({ preventScroll: true }); }); } }, [contextMenu.isOpen, closeContextMenu]); const getMenuNavItems = useCallback( (scope: 'main' | 'submenu' = 'main') => { if (!menuRef.current) return []; if (scope === 'submenu') { const sub = menuRef.current.querySelector('.context-submenu'); if (!sub || sub.offsetParent === null) return []; return Array.from( sub.querySelectorAll('.context-menu-item, .context-submenu-create-btn'), ).filter(el => el.offsetParent !== null); } return Array.from(menuRef.current.children) .filter((el): el is HTMLElement => el instanceof HTMLElement && (el.classList.contains('context-menu-item') || el.classList.contains('context-menu-rating-row')) && el.offsetParent !== null, ); }, [], ); const focusMenuItemAt = useCallback((scope: 'main' | 'submenu', index: number) => { const items = getMenuNavItems(scope); if (items.length === 0) return; menuRef.current ?.querySelectorAll('.context-menu-keyboard-active') .forEach(el => el.classList.remove('context-menu-keyboard-active')); const safeIdx = ((index % items.length) + items.length) % items.length; const target = items[safeIdx]; target.classList.add('context-menu-keyboard-active'); target.tabIndex = -1; target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'nearest' }); }, [getMenuNavItems]); useEffect(() => { if (!contextMenu.isOpen) return; requestAnimationFrame(() => { menuRef.current?.focus({ preventScroll: true }); // Do not pre-highlight any menu row; keyboard outline appears only // after explicit arrow navigation. }); }, [contextMenu.isOpen]); // Outside-click closes the menu without occluding the underlying UI. The // previous implementation rendered a transparent fullscreen backdrop, which // also blocked right-clicks from reaching elements *under* it — so users // couldn't reposition the menu by right-clicking another row. useEffect(() => { if (!contextMenu.isOpen) return; const handler = (e: MouseEvent) => { const target = e.target as Node | null; if (!target) return; if (menuRef.current?.contains(target)) return; closeContextMenu(); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [contextMenu.isOpen, closeContextMenu]); useEffect(() => { if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return; let cancelled = false; const tryFocus = (attemptsLeft: number) => { if (cancelled) return; const items = getMenuNavItems('submenu'); if (items.length > 0) { focusMenuItemAt('submenu', 0); setPendingSubmenuKeyboardFocus(false); return; } if (attemptsLeft <= 0) { setPendingSubmenuKeyboardFocus(false); return; } requestAnimationFrame(() => tryFocus(attemptsLeft - 1)); }; requestAnimationFrame(() => tryFocus(8)); return () => { cancelled = true; }; }, [pendingSubmenuKeyboardFocus, playlistSubmenuOpen, getMenuNavItems, focusMenuItemAt]); const { type, item, queueIndex, playlistId, playlistSongIndex } = contextMenu; const isStarred = (id: string, itemStarred?: string) => id in starredOverrides ? starredOverrides[id] : !!itemStarred; const applySongRating = useCallback((songId: string, rating: number) => { setUserRatingOverride(songId, rating); setRating(songId, rating).catch(() => {}); }, [setUserRatingOverride]); const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => { setUserRatingOverride(album.id, rating); if (entityRatingSupport !== 'full') return; setRating(album.id, rating).catch(err => { if (auth.activeServerId) setEntityRatingSupport(auth.activeServerId, 'track_only'); showToast( typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), 4500, 'error', ); }); }, [setUserRatingOverride, entityRatingSupport, auth.activeServerId, setEntityRatingSupport, t]); const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => { setUserRatingOverride(artist.id, rating); if (entityRatingSupport !== 'full') return; setRating(artist.id, rating).catch(err => { if (auth.activeServerId) setEntityRatingSupport(auth.activeServerId, 'track_only'); showToast( typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), 4500, 'error', ); }); }, [setUserRatingOverride, entityRatingSupport, auth.activeServerId, setEntityRatingSupport, t]); const getRatingValueByKind = useCallback((kind: 'song' | 'album' | 'artist', id: string): number => { if (kind === 'song' && (type === 'song' || type === 'album-song' || type === 'queue-item')) { const song = item as Track; if (song.id === id) return userRatingOverrides[id] ?? song.userRating ?? 0; } if (kind === 'album' && type === 'album') { const album = item as SubsonicAlbum; if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0; } if (kind === 'artist' && type === 'artist') { const artist = item as SubsonicArtist; if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0; } return userRatingOverrides[id] ?? 0; }, [type, item, userRatingOverrides]); const commitRatingByKind = useCallback((kind: 'song' | 'album' | 'artist', id: string, rating: number) => { if (kind === 'song') { applySongRating(id, rating); return; } if (kind === 'album' && type === 'album') { applyAlbumRating(item as SubsonicAlbum, rating); return; } if (kind === 'artist' && type === 'artist') { applyArtistRating(item as SubsonicArtist, rating); } }, [applySongRating, applyAlbumRating, applyArtistRating, type, item]); const onMenuKeyDown = useCallback((e: React.KeyboardEvent) => { const active = document.activeElement as HTMLElement | null; const ratingRow = active?.closest('.context-menu-rating-row') as HTMLElement | null; if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeContextMenu(); return; } if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (ratingRow) { const kind = ratingRow.dataset.ratingKind as ('song' | 'album' | 'artist' | undefined); const id = ratingRow.dataset.ratingId; if (!kind || !id) return; if (ratingRow.dataset.ratingDisabled === 'true') return; const value = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id ? keyboardRating.value : getRatingValueByKind(kind, id); commitRatingByKind(kind, id, value); setKeyboardRating({ kind, id, value }); return; } active?.click(); return; } if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { if (ratingRow) { const kind = ratingRow.dataset.ratingKind as ('song' | 'album' | 'artist' | undefined); const id = ratingRow.dataset.ratingId; if (!kind || !id) return; if (ratingRow.dataset.ratingDisabled === 'true') return; e.preventDefault(); e.stopPropagation(); const currentValue = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id ? keyboardRating.value : getRatingValueByKind(kind, id); const delta = e.key === 'ArrowRight' ? 1 : -1; const nextValue = Math.max(0, Math.min(5, currentValue + delta)); setKeyboardRating({ kind, id, value: nextValue }); return; } } if (e.key === 'ArrowRight') { const trigger = active?.closest('.context-menu-item--submenu') as HTMLElement | null; const triggerId = trigger?.dataset.playlistTriggerId; if (!trigger || !triggerId) return; e.preventDefault(); e.stopPropagation(); setPlaylistSongIds([triggerId]); setPlaylistSubmenuOpen(true); setPendingSubmenuKeyboardFocus(true); return; } if (e.key === 'ArrowLeft') { const sub = active?.closest('.context-submenu') as HTMLElement | null; if (!sub) return; e.preventDefault(); e.stopPropagation(); const triggerId = sub.dataset.parentTriggerId; setPlaylistSubmenuOpen(false); requestAnimationFrame(() => { const trigger = triggerId ? Array.from(menuRef.current?.querySelectorAll('.context-menu-item--submenu') ?? []) .find(el => el.dataset.playlistTriggerId === triggerId) ?? null : null; if (trigger) { menuRef.current ?.querySelectorAll('.context-menu-keyboard-active') .forEach(el => el.classList.remove('context-menu-keyboard-active')); trigger.classList.add('context-menu-keyboard-active'); trigger.focus({ preventScroll: true }); } }); return; } if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; e.preventDefault(); e.stopPropagation(); const scope: 'main' | 'submenu' = active?.closest('.context-submenu') ? 'submenu' : 'main'; const items = getMenuNavItems(scope); if (items.length === 0) return; const activeIdx = items.findIndex(el => el === document.activeElement); const nextIdx = activeIdx >= 0 ? (e.key === 'ArrowDown' ? activeIdx + 1 : activeIdx - 1) : (e.key === 'ArrowDown' ? 0 : items.length - 1); focusMenuItemAt(scope, nextIdx); }, [closeContextMenu, keyboardRating, getRatingValueByKind, commitRatingByKind, getMenuNavItems, focusMenuItemAt]); const handleAction = async (action: () => void | Promise) => { closeContextMenu(); await action(); }; const copyShareLink = useCallback(async (kind: EntityShareKind, id: string) => { const ok = await copyEntityShareLink(kind, id); if (ok) showToast(t('contextMenu.shareCopied')); else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); }, [t]); const startRadio = async (artistId: string, artistName: string, seedTrack?: Track) => { if (seedTrack) { // Start playback immediately based on current state const state = usePlayerStore.getState(); if (state.currentTrack?.id === seedTrack.id) { if (!state.isPlaying) state.resume(); // Already playing this track — don't restart } else { playTrack(seedTrack, [seedTrack]); } // Load radio queue in background — enqueueRadio replaces any pending radio // tracks so clicking "Start Radio" again never stacks duplicate batches. // Shuffle so the follow-up tracks feel fresh instead of always being the // same "Top 5" in the same order every time. try { const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); // Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile — // otherwise this feels the same as Instant Mix (track-based similar only). const topTracks = shuffleArray( top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })) ); const similarTracks = shuffleArray( similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })) ); const radioTracks = [...topTracks, ...similarTracks]; if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId); } catch (e) { console.error('Failed to load radio queue', e); } } else { // Artist radio: fire both calls immediately but don't wait for the slow one. // getTopSongs is fast (local library) — start playback as soon as it resolves. // getSimilarSongs2 is slow (Last.fm) — enrich the queue in the background. const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited>); try { const top = await getTopSongs(artistName); // Shuffle so each Radio session starts from a different track rather // than always kicking off with the #1 most-played song. const topTracks = shuffleArray( top.map(t => ({ ...songToTrack(t), radioAdded: true as const })) ); if (topTracks.length === 0) { // No local top songs — fall back to waiting for similar tracks const similar = await similarPromise; const fallback = shuffleArray( similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })) ); if (fallback.length === 0) return; const state = usePlayerStore.getState(); if (state.currentTrack) { state.enqueueRadio(fallback, artistId); } else { state.setRadioArtistId(artistId); playTrack(fallback[0], fallback); } return; } // Start playback from the first shuffled top track only. // No other tracks are queued yet — positions 2+ will be filled // exclusively by the similar-songs result below. const state = usePlayerStore.getState(); if (state.currentTrack) { state.enqueueRadio([topTracks[0]], artistId); } else { state.setRadioArtistId(artistId); playTrack(topTracks[0], [topTracks[0]]); } // Populate positions 2+ from similar songs only — never from the // remaining top tracks. Mixing in topTracks.slice(1) meant that when // getSimilarSongs2 returned nothing (no Last.fm, small library, etc.) // the queue fell back to the same top-4 the user just heard. // If similarTracks is also empty, the proactive top-up in next() // will refill the queue when the first track nears its end. similarPromise.then(similar => { const similarTracks = shuffleArray( similar .map(t => ({ ...songToTrack(t), radioAdded: true as const })) .filter(t => t.id !== topTracks[0].id) ); if (similarTracks.length === 0) return; const { queue, queueIndex } = usePlayerStore.getState(); const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded); usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId); }); } catch (e) { console.error('Failed to start radio', e); } } }; const startInstantMix = async (song: Track) => { usePlayerStore.getState().reseedQueueForInstantMix(song); const serverId = useAuthStore.getState().activeServerId; try { const similar = await getSimilarSongs(song.id, 50); if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false); const shuffled = shuffleArray( similar .filter(s => s.id !== song.id) .map(s => ({ ...songToTrack(s), radioAdded: true as const })) ); if (shuffled.length > 0) { const aid = song.artistId?.trim() || undefined; usePlayerStore.getState().enqueueRadio(shuffled, aid); } } catch (e) { console.error('Instant mix failed', e); if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true); showToast(t('contextMenu.instantMixFailed'), 5000, 'error'); } }; const downloadAlbum = async (albumName: string, albumId: string) => { const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; const filename = `${sanitizeFilename(albumName)}.zip`; const destPath = await join(folder, filename); const url = buildDownloadUrl(albumId); const id = crypto.randomUUID(); const { start, complete, fail } = useZipDownloadStore.getState(); start(id, filename); try { await invoke('download_zip', { id, url, destPath }); complete(id); } catch (e) { fail(id); console.error('ZIP download failed:', e); } }; if (!contextMenu.isOpen || !contextMenu.item) return null; return ( <>
{(type === 'song' || type === 'album-song') && (() => { const song = item as Track; return ( <>
handleAction(() => playTrack(song, [song]))}> {t('contextMenu.playNow')}
handleAction(() => { if (!currentTrack) { playTrack(song, [song]); return; } const currentIdx = usePlayerStore.getState().queueIndex; const newQueue = [...queue]; newQueue.splice(currentIdx + 1, 0, song); usePlayerStore.setState({ queue: newQueue }); })}> {t('contextMenu.playNext')}
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
{orbitRole === 'guest' && (() => { const muted = evaluateOrbitSuggestGate().reason === 'muted'; return (
handleAction(() => { if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } suggestOrbitTrack(song.id) .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) .catch(err => { if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); } else { showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); } }); })} > {t('orbit.ctxAddToSession')}
); })()} {orbitRole === 'host' && (
handleAction(() => { hostEnqueueToOrbit(song.id) .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); })}> {t('orbit.ctxAddToSessionHost')}
)}
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
{type === 'album-song' && (
handleAction(async () => { const albumData = await getAlbum(song.albumId); const tracks = albumData.songs.map(songToTrack); enqueue(tracks); })}> {t('contextMenu.enqueueAlbum')}
)}
{song.albumId && (
handleAction(() => navigate(`/album/${song.albumId}`))}> {t('contextMenu.openAlbum')}
)} {song.artistId && (
handleAction(() => navigate(`/artist/${song.artistId}`))}> {t('contextMenu.goToArtist')}
)}
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> {t('contextMenu.startRadio')}
{audiomuseNavidromeEnabled && (
handleAction(() => startInstantMix(song))}> {t('contextMenu.instantMix')}
)}
handleAction(() => { const starred = isStarred(song.id, song.starred); setStarredOverride(song.id, !starred); return starred ? unstar(song.id, 'song') : star(song.id, 'song'); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
{auth.lastfmSessionKey && (() => { const loveKey = `${song.title}::${song.artist}`; const loved = lastfmLovedCache[loveKey] ?? false; return (
handleAction(() => { const newLoved = !loved; setLastfmLovedForSong(song.title, song.artist, newLoved); if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); else lastfmUnloveTrack(song, auth.lastfmSessionKey); })}> {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
); })()}
e.stopPropagation()} > { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} ariaLabel={t('albumDetail.ratingLabel')} />
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
{playlistId && playlistSongIndex !== undefined && (
handleAction(async () => { 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 } = await getPlaylist(playlistId); const prevCount = songs.length; const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id); await updatePlaylist(playlistId, updatedIds, prevCount); touchPlaylist(playlistId); showToast(t('playlists.removeSuccess'), 3000, 'info'); } catch { showToast(t('playlists.removeError'), 4000, 'error'); } })}> {t('contextMenu.removeFromPlaylist')}
)} ); })()} {type === 'favorite-song' && (() => { const song = item as Track; return ( <>
handleAction(() => playTrack(song, [song]))}> {t('contextMenu.playNow')}
handleAction(() => { if (!currentTrack) { playTrack(song, [song]); return; } const currentIdx = usePlayerStore.getState().queueIndex; const newQueue = [...queue]; newQueue.splice(currentIdx + 1, 0, song); usePlayerStore.setState({ queue: newQueue }); })}> {t('contextMenu.playNext')}
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
{orbitRole === 'guest' && (() => { const muted = evaluateOrbitSuggestGate().reason === 'muted'; return (
handleAction(() => { if (muted) { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); return; } suggestOrbitTrack(song.id) .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) .catch(err => { if (err instanceof OrbitSuggestBlockedError && err.reason === 'muted') { showToast(t('orbit.suggestBlockedMuted'), 3500, 'error'); } else { showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'); } }); })} > {t('orbit.ctxAddToSession')}
); })()} {orbitRole === 'host' && (
handleAction(() => { hostEnqueueToOrbit(song.id) .then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info')) .catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error')); })}> {t('orbit.ctxAddToSessionHost')}
)}
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
{song.albumId && (
handleAction(() => navigate(`/album/${song.albumId}`))}> {t('contextMenu.openAlbum')}
)} {song.artistId && (
handleAction(() => navigate(`/artist/${song.artistId}`))}> {t('contextMenu.goToArtist')}
)}
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> {t('contextMenu.startRadio')}
{audiomuseNavidromeEnabled && (
handleAction(() => startInstantMix(song))}> {t('contextMenu.instantMix')}
)} {auth.lastfmSessionKey && (() => { const loveKey = `${song.title}::${song.artist}`; const loved = lastfmLovedCache[loveKey] ?? false; return (
handleAction(() => { const newLoved = !loved; setLastfmLovedForSong(song.title, song.artist, newLoved); if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); else lastfmUnloveTrack(song, auth.lastfmSessionKey); })}> {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
); })()}
e.stopPropagation()} > { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} ariaLabel={t('albumDetail.ratingLabel')} />
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
handleAction(() => { setStarredOverride(song.id, false); return unstar(song.id, 'song'); })}> {t('contextMenu.unfavorite')}
); })()} {type === 'album' && (() => { const album = item as SubsonicAlbum; const albumRatingDisabled = entityRatingSupport === 'track_only'; return ( <>
handleAction(() => navigate(`/album/${album.id}`))}> {t('contextMenu.openAlbum')}
handleAction(async () => { const albumData = await getAlbum(album.id); enqueue(albumData.songs.map(songToTrack)); })}> {t('contextMenu.enqueueAlbum')}
handleAction(() => navigate(`/artist/${album.artistId}`))}> {t('contextMenu.goToArtist')}
handleAction(() => { const starred = isStarred(album.id, album.starred); setStarredOverride(album.id, !starred); return starred ? unstar(album.id, 'album') : star(album.id, 'album'); })}> {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
e.stopPropagation()} > { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }} />
handleAction(() => copyShareLink('album', album.id))}> {t('contextMenu.shareLink')}
handleAction(() => downloadAlbum(album.name, album.id))}> {t('contextMenu.download')}
{ setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
); })()} {type === 'playlist' && (() => { const playlist = item as SubsonicPlaylist; return ( <>
handleAction(() => navigate(`/playlists/${playlist.id}`))}> {t('contextMenu.playNow')}
{ setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
handleAction(async () => { const { showToast } = await import('../utils/toast'); const { deletePlaylist } = await import('../api/subsonic'); const { usePlaylistStore } = await import('../store/playlistStore'); const { removeId } = usePlaylistStore.getState(); try { await deletePlaylist(playlist.id); removeId(playlist.id); // Update local playlist state without page reload to preserve audio playback state usePlaylistStore.setState((s) => ({ playlists: s.playlists.filter((p) => p.id !== playlist.id), })); showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info'); } catch { showToast(t('playlists.deleteFailed', { name: playlist.name }), 3000, 'error'); } })}> {t('playlists.deletePlaylist')}
); })()} {type === 'multi-album' && (() => { const albums = item as SubsonicAlbum[]; const albumIds = albums.map(a => a.id); return ( <>
{t('contextMenu.selectedAlbums', { count: albums.length })}
handleAction(async () => { // Parallel — Navidrome handles concurrent getAlbum requests fine. const results = await Promise.all(albums.map(a => getAlbum(a.id))); const allTracks = results.flatMap(r => r.songs.map(songToTrack)); enqueue(allTracks); })}> {t('contextMenu.enqueueAlbums', { count: albums.length })}
{ setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
); })()} {type === 'artist' && (() => { const artist = item as SubsonicArtist; const artistRatingDisabled = entityRatingSupport === 'track_only'; return ( <>
handleAction(() => startRadio(artist.id, artist.name))}> {t('contextMenu.startRadio')}
{ setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
handleAction(() => copyShareLink('artist', artist.id))}> {t('contextMenu.shareLink')}
handleAction(() => { const starred = isStarred(artist.id, artist.starred); setStarredOverride(artist.id, !starred); return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist'); })}> {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
e.stopPropagation()} > { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }} />
); })()} {type === 'multi-artist' && (() => { const artists = item as SubsonicArtist[]; const artistIds = artists.map(a => a.id); return ( <>
{t('contextMenu.selectedArtists', { count: artists.length })}
{ setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
); })()} {type === 'multi-playlist' && (() => { const selectedPlaylists = item as SubsonicPlaylist[]; const playlistIds = selectedPlaylists.map(p => p.id); return ( <>
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
{ setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
handleAction(async () => { const { showToast } = await import('../utils/toast'); const { usePlaylistStore } = await import('../store/playlistStore'); const { deletePlaylist } = await import('../api/subsonic'); const { removeId } = usePlaylistStore.getState(); const deletedIds: string[] = []; for (const pl of selectedPlaylists) { try { await deletePlaylist(pl.id); removeId(pl.id); deletedIds.push(pl.id); } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } } if (deletedIds.length > 0) { // Update local playlist state without page reload to preserve audio playback state usePlaylistStore.setState((s) => ({ playlists: s.playlists.filter((p) => !deletedIds.includes(p.id)), })); showToast(t('playlists.deleteSuccess', { count: deletedIds.length }), 3000, 'info'); } })}> {t('playlists.deleteSelected')}
); })()} {type === 'queue-item' && (() => { const song = item as Track; return ( <>
handleAction(() => playTrack(song, queue))}> {t('contextMenu.playNow')}
handleAction(() => { if (queueIndex !== undefined) removeTrack(queueIndex); })}> {t('contextMenu.removeFromQueue')}
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
{song.albumId && (
handleAction(() => navigate(`/album/${song.albumId}`))}> {t('contextMenu.openAlbum')}
)} {song.artistId && (
handleAction(() => navigate(`/artist/${song.artistId}`))}> {t('contextMenu.goToArtist')}
)}
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> {t('contextMenu.startRadio')}
{audiomuseNavidromeEnabled && (
handleAction(() => startInstantMix(song))}> {t('contextMenu.instantMix')}
)}
handleAction(() => { const starred = isStarred(song.id, song.starred); setStarredOverride(song.id, !starred); return starred ? unstar(song.id, 'song') : star(song.id, 'song'); })}> {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
{auth.lastfmSessionKey && (() => { const loveKey = `${song.title}::${song.artist}`; const loved = lastfmLovedCache[loveKey] ?? false; return (
handleAction(() => { const newLoved = !loved; setLastfmLovedForSong(song.title, song.artist, newLoved); if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey); else lastfmUnloveTrack(song, auth.lastfmSessionKey); })}> {loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
); })()}
e.stopPropagation()} > { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} ariaLabel={t('albumDetail.ratingLabel')} />
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')}
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
); })()}
); }