import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles } from 'lucide-react'; 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, getPlaylists, getPlaylist, createPlaylist, 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'; 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 }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) { 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 touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const recentIds = usePlaylistStore((s) => s.recentIds); useEffect(() => { getPlaylists().then((all) => { const sorted = [...all].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; }); setPlaylists(sorted); }).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Flip submenu left if it would overflow the right edge of the viewport 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); 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]); } touchPlaylist(pl.id); } catch {} setAdding(null); onDone(); }; const handleCreate = async () => { const name = newName.trim() || t('playlists.unnamed'); try { const pl = await createPlaylist(name, songIds); if (pl?.id) touchPlaylist(pl.id); } catch {} onDone(); }; const subStyle: React.CSSProperties = dropDown ? { top: 'calc(100% + 4px)', left: 0, right: 'auto' } : flipLeft ? { right: 'calc(100% + 4px)', left: 'auto' } : { left: 'calc(100% + 4px)', right: '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) => (
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 }: { albumId: string; onDone: () => void }) { 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 ; } export default function ContextMenu() { const { t } = useTranslation(); 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 audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); const menuRef = 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([]); useEffect(() => { if (contextMenu.isOpen) { setCoords({ x: contextMenu.x, y: contextMenu.y }); setPlaylistSubmenuOpen(false); setPlaylistSongIds([]); } }, [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]); if (!contextMenu.isOpen || !contextMenu.item) return null; const { type, item, queueIndex } = contextMenu; const isStarred = (id: string, itemStarred?: string) => id in starredOverrides ? starredOverrides[id] : !!itemStarred; const handleAction = async (action: () => void | Promise) => { closeContextMenu(); await action(); }; 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) => { const state = usePlayerStore.getState(); if (state.currentTrack?.id === song.id) { if (!state.isPlaying) state.resume(); } else { playTrack(song, [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); } }; return ( <> {/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
closeContextMenu()} />
{(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')}
{ 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')}
)}
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()}> { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }} ariaLabel={t('albumDetail.ratingLabel')} />
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
); })()} {type === 'album' && (() => { const album = item as SubsonicAlbum; return ( <>
handleAction(() => navigate(`/album/${album.id}`))}> {t('contextMenu.openAlbum')}
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')}
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 === 'artist' && (() => { const artist = item as SubsonicArtist; return ( <>
handleAction(() => startRadio(artist.id, artist.name))}> {t('contextMenu.startRadio')}
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')}
); })()} {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')}
)}
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')}
); })()}
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> {t('contextMenu.startRadio')}
{audiomuseNavidromeEnabled && (
handleAction(() => startInstantMix(song))}> {t('contextMenu.instantMix')}
)}
e.stopPropagation()}> { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }} ariaLabel={t('albumDetail.ratingLabel')} />
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
); })()}
); }