import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react'; import LastfmIcon from './LastfmIcon'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist } 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 { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; import { useTranslation } from 'react-i18next'; function sanitizeFilename(name: string): string { return name .replace(/[/\\?%*:|"<>]/g, '-') .replace(/\.{2,}/g, '.') .replace(/^[\s.]+|[\s.]+$/g, '') .substring(0, 200) || 'download'; } // ── 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 } = usePlayerStore(); const auth = useAuthStore(); 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. try { const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); const radioTracks = [...top, ...similar] .map(songToTrack) .filter(t => t.id !== seedTrack.id) .map(t => ({ ...t, radioAdded: true as const })); 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); const topTracks = 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 = 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 immediately from top songs const state = usePlayerStore.getState(); if (state.currentTrack) { state.enqueueRadio(topTracks, artistId); } else { state.setRadioArtistId(artistId); playTrack(topTracks[0], topTracks); } // Enrich with similar tracks in the background similarPromise.then(similar => { const similarTracks = similar .map(t => ({ ...songToTrack(t), radioAdded: true as const })) .filter(t => !topTracks.some(top => top.id === t.id)); if (similarTracks.length === 0) return; // Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them // together with the new similar tracks rather than losing them. 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 downloadAlbum = async (albumName: string, albumId: string) => { try { const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; const url = buildDownloadUrl(albumId); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(); const buffer = await blob.arrayBuffer(); const path = await join(folder, `${sanitizeFilename(albumName)}.zip`); await writeFile(path, new Uint8Array(buffer)); } catch (e) { console.error('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')}
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(() => 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')}
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
); })()}
); }