diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index c6d76bdf..bee8e19e 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,1027 +1,24 @@ -import { getPlaylists, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; -import { buildDownloadUrl } from '../api/subsonicStreamUrl'; -import { star, unstar, setRating } from '../api/subsonicStarRating'; -import { getAlbum } from '../api/subsonicLibrary'; -import { getSimilarSongs2, getSimilarSongs, getTopSongs, getArtist } from '../api/subsonicArtists'; -import type { SubsonicAlbum, SubsonicArtist, SubsonicPlaylist } from '../api/subsonicTypes'; -import { songToTrack } from '../utils/songToTrack'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { Track } from '../store/playerStoreTypes'; -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Play, ListPlus, Radio, Heart, Download, ChevronRight, ChevronsRight, 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 } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; -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'; -import { useConfirmModalStore } from '../store/confirmModalStore'; +import { AddToPlaylistSubmenu } from './contextMenu/AddToPlaylistSubmenu'; +import { + copyShareLink as copyShareLinkAction, + downloadAlbum as downloadAlbumAction, + startInstantMix as startInstantMixAction, + startRadio as startRadioAction, +} from '../utils/contextMenuActions'; +import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav'; +import { useContextMenuRating } from '../hooks/useContextMenuRating'; +import ContextMenuItems from './contextMenu/ContextMenuItems'; + +export { AddToPlaylistSubmenu }; -/** Ask user before re-adding songs to a playlist when *all* selected ids are - * already present. Returns true → caller should append them as duplicates, - * false → caller should keep today's silent-skip toast behavior. */ -async function confirmAddAllDuplicates( - playlistName: string, - count: number, - t: (key: string, opts?: Record) => string, -): Promise { - return useConfirmModalStore.getState().request({ - title: t('playlists.duplicateConfirmTitle'), - message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }), - confirmLabel: t('playlists.duplicateConfirmAction'), - cancelLabel: t('common.cancel'), - }); -} - -function sanitizeFilename(name: string): string { - return name - .replace(/[/\\?%*:|"<>]/g, '-') - .replace(/\.{2,}/g, '.') - .replace(/^[\s.]+|[\s.]+$/g, '') - .substring(0, 200) || 'download'; -} - -/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */ -const SMART_PLAYLIST_PREFIX = 'psy-smart-'; - -function isSmartPlaylistName(name: string | undefined | null): boolean { - return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX); -} - -/** 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 (exclude app smart playlists — not editable as normal lists) - const playlists = useMemo(() => { - return [...storePlaylists] - .filter(p => !isSmartPlaylistName(p.name)) - .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 })); - touchPlaylist(pl.id); - } else { - const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t); - if (accepted) { - await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]); - showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info'); - touchPlaylist(pl.id); - } else { - showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info'); - } - } - } 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/subsonicPlaylists'); - 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); - } - } - - 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 (err) { - showToast(t('playlists.addError'), 4000, 'error'); - } - onDone(); - }; - - const handleCreateWithToast = async (songIds: string[]) => { - const { createPlaylist } = await import('../api/subsonicPlaylists'); - 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] - .filter(p => !isSmartPlaylistName(p.name)) - .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/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'); - } - 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/subsonicPlaylists'); - 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); - } - } - - 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 (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.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('../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'); - } - 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 and smart playlists from the list - const allPlaylists = useMemo(() => { - return storePlaylists.filter( - (p) => p.id !== playlist.id && !isSmartPlaylistName(p.name), - ); - }, [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/subsonicPlaylists'); - 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/subsonicPlaylists'); - 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/subsonicPlaylists'); - 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 and smart playlists from the list - const allPlaylists = useMemo(() => { - const selectedIds = new Set(playlists.map(p => p.id)); - return storePlaylists.filter( - (p) => !selectedIds.has(p.id) && !isSmartPlaylistName(p.name), - ); - }, [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/subsonicPlaylists'); - 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/subsonicPlaylists'); - 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/subsonicPlaylists'); - 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(); @@ -1046,12 +43,9 @@ export default function ContextMenu() { })) ); 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); @@ -1103,421 +97,46 @@ export default function ContextMenu() { } }, [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, shareKindOverride } = 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 { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind } = + useContextMenuRating({ type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t }); - 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 === 'album' && type === 'multi-album') { - const albums = item as SubsonicAlbum[]; - const compositeId = [...albums.map(a => a.id)].sort().join('\x1e'); - if (id !== compositeId) return userRatingOverrides[id] ?? 0; - if (albums.length === 0) return 0; - const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); - const first = vals[0]; - return vals.every(v => v === first) ? first : 0; - } - if (kind === 'artist' && type === 'artist') { - const artist = item as SubsonicArtist; - if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0; - } - if (kind === 'artist' && type === 'multi-artist') { - const artists = item as SubsonicArtist[]; - const compositeId = [...artists.map(a => a.id)].sort().join('\x1e'); - if (id !== compositeId) return userRatingOverrides[id] ?? 0; - if (artists.length === 0) return 0; - const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); - const first = vals[0]; - return vals.every(v => v === first) ? first : 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 === 'album' && type === 'multi-album') { - const albums = item as SubsonicAlbum[]; - const compositeId = [...albums.map(a => a.id)].sort().join('\x1e'); - if (id !== compositeId) return; - for (const a of albums) applyAlbumRating(a, rating); - return; - } - if (kind === 'artist' && type === 'artist') { - applyArtistRating(item as SubsonicArtist, rating); - return; - } - if (kind === 'artist' && type === 'multi-artist') { - const artists = item as SubsonicArtist[]; - const compositeId = [...artists.map(a => a.id)].sort().join('\x1e'); - if (id !== compositeId) return; - for (const a of artists) applyArtistRating(a, 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 { onMenuKeyDown } = useContextMenuKeyboardNav({ + menuRef, + isOpen: contextMenu.isOpen, + closeContextMenu, + keyboardRating, + setKeyboardRating, + getRatingValueByKind, + commitRatingByKind, + playlistSubmenuOpen, + setPlaylistSubmenuOpen, + setPlaylistSongIds, + pendingSubmenuKeyboardFocus, + setPendingSubmenuKeyboardFocus, + }); 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 copyShareLink = useCallback( + (kind: EntityShareKind, id: string) => copyShareLinkAction(kind, id, t), + [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. - // Lead with similar songs (other artists) so the listener doesn't get a - // wall of the seed artist's own top tracks before anything else plays. - // Top tracks stay as a fallback for setups without Last.fm / small - // libraries where similar comes back empty (issue #500). - try { - const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); - const similarTracks = shuffleArray( - similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })) - ); - const radioTracks = similarTracks.length > 0 - ? similarTracks - : shuffleArray( - top.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); - // 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 startRadio = (artistId: string, artistName: string, seedTrack?: Track) => + startRadioAction(artistId, artistName, playTrack, seedTrack); - 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 startInstantMix = (song: Track) => startInstantMixAction(song, t); - 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); - } - }; + const downloadAlbum = downloadAlbumAction; if (!contextMenu.isOpen || !contextMenu.item) return null; @@ -1530,714 +149,45 @@ export default function ContextMenu() { tabIndex={-1} onKeyDown={onMenuKeyDown} > - {(type === 'song' || type === 'album-song') && (() => { - const song = item as Track; - return ( - <> -
handleAction(() => playTrack(song, [song]))}> - {t('contextMenu.playNow')} -
-
handleAction(() => playNext([song]))}> - {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/subsonicPlaylists'); - 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(() => playNext([song]))}> - {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); - const tracks = albumData.songs.map(songToTrack); - if (tracks.length === 0) return; - playNext(tracks); - })}> - {t('contextMenu.playNext')} -
-
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/subsonicPlaylists'); - 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); - const albumRatingDisabled = entityRatingSupport === 'track_only'; - const multiAlbumRatingId = [...albumIds].sort().join('\x1e'); - const unifiedAlbumRating = (() => { - if (albums.length === 0) return 0; - const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); - const first = vals[0]; - return vals.every(v => v === first) ? first : 0; - })(); - 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(); }} /> - )} -
-
e.stopPropagation()} - > - - { - setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r }); - for (const a of albums) applyAlbumRating(a, r); - }} - /> -
- - ); - })()} - - {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(shareKindOverride ?? '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); - const artistRatingDisabled = entityRatingSupport === 'track_only'; - const multiArtistRatingId = [...artistIds].sort().join('\x1e'); - const unifiedArtistRating = (() => { - if (artists.length === 0) return 0; - const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); - const first = vals[0]; - return vals.every(v => v === first) ? first : 0; - })(); - 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(); }} /> - )} -
-
e.stopPropagation()} - > - - { - setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r }); - for (const a of artists) applyArtistRating(a, r); - }} - /> -
- - ); - })()} - - {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 { deletePlaylist } = await import('../api/subsonicPlaylists'); - 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, undefined, undefined, contextMenu.queueIndex))}> - {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')} -
- - ); - })()} +
); diff --git a/src/components/contextMenu/AddToPlaylistSubmenu.tsx b/src/components/contextMenu/AddToPlaylistSubmenu.tsx new file mode 100644 index 00000000..df447538 --- /dev/null +++ b/src/components/contextMenu/AddToPlaylistSubmenu.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListMusic, Plus } from 'lucide-react'; +import { getPlaylist, updatePlaylist } from '../../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../../api/subsonicTypes'; +import { usePlaylistStore } from '../../store/playlistStore'; +import { showToast } from '../../utils/toast'; +import { + confirmAddAllDuplicates, + isSmartPlaylistName, +} from '../../utils/contextMenuHelpers'; + +interface Props { + songIds: string[]; + onDone: () => void; + dropDown?: boolean; + triggerId?: string; +} + +export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: Props) { + 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); + + useEffect(() => { + if (storePlaylists.length === 0) fetchPlaylists(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const playlists = useMemo(() => { + return [...storePlaylists] + .filter(p => !isSmartPlaylistName(p.name)) + .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]); + + 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 })); + touchPlaylist(pl.id); + } else { + const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t); + if (accepted) { + await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]); + showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + touchPlaylist(pl.id); + } else { + showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + } + } + } 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 ( +
+ {!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} +
+ ))} +
+ ); +} diff --git a/src/components/contextMenu/AlbumArtistToPlaylistSubmenu.tsx b/src/components/contextMenu/AlbumArtistToPlaylistSubmenu.tsx new file mode 100644 index 00000000..54da3b77 --- /dev/null +++ b/src/components/contextMenu/AlbumArtistToPlaylistSubmenu.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from 'react'; +import { getAlbum } from '../../api/subsonicLibrary'; +import { getArtist } from '../../api/subsonicArtists'; +import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu'; + +interface AlbumProps { + albumId: string; + onDone: () => void; + triggerId?: string; +} + +export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProps) { + 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 ; +} + +interface ArtistProps { + artistId: string; + onDone: () => void; + triggerId?: string; +} + +export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistProps) { + 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 ; +} diff --git a/src/components/contextMenu/AlbumContextItems.tsx b/src/components/contextMenu/AlbumContextItems.tsx new file mode 100644 index 00000000..49e9db4d --- /dev/null +++ b/src/components/contextMenu/AlbumContextItems.tsx @@ -0,0 +1,170 @@ +import { useTranslation } from 'react-i18next'; +import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { getAlbum } from '../../api/subsonicLibrary'; +import { star, unstar } from '../../api/subsonicStarRating'; +import type { SubsonicAlbum } from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; +import { songToTrack } from '../../utils/songToTrack'; +import StarRating from '../StarRating'; +import { AlbumToPlaylistSubmenu } from './AlbumArtistToPlaylistSubmenu'; +import { MultiAlbumToPlaylistSubmenu } from './MultiAlbumToPlaylistSubmenu'; +import type { ContextMenuItemsProps } from './contextMenuItemTypes'; + +export default function AlbumContextItems(props: ContextMenuItemsProps) { + const { + type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, + playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, + starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, + playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, + applySongRating, applyAlbumRating, applyArtistRating, + handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + } = props; + const { t } = useTranslation(); + const auth = useAuthStore(); + const navigate = useNavigate(); + + return ( + <> + {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); + const tracks = albumData.songs.map(songToTrack); + if (tracks.length === 0) return; + playNext(tracks); + })}> + {t('contextMenu.playNext')} +
+
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 === 'multi-album' && (() => { + const albums = item as SubsonicAlbum[]; + const albumIds = albums.map(a => a.id); + const albumRatingDisabled = entityRatingSupport === 'track_only'; + const multiAlbumRatingId = [...albumIds].sort().join('\x1e'); + const unifiedAlbumRating = (() => { + if (albums.length === 0) return 0; + const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); + const first = vals[0]; + return vals.every(v => v === first) ? first : 0; + })(); + 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(); }} /> + )} +
+
e.stopPropagation()} + > + + { + setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r }); + for (const a of albums) applyAlbumRating(a, r); + }} + /> +
+ + ); + })()} + + + ); +} diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx new file mode 100644 index 00000000..290acc87 --- /dev/null +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from 'react-i18next'; +import { Radio, Heart, ChevronRight, ListMusic, Star, Share2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { star, unstar } from '../../api/subsonicStarRating'; +import type { SubsonicArtist } from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; +import StarRating from '../StarRating'; +import { ArtistToPlaylistSubmenu } from './AlbumArtistToPlaylistSubmenu'; +import { MultiArtistToPlaylistSubmenu } from './MultiArtistToPlaylistSubmenu'; +import type { ContextMenuItemsProps } from './contextMenuItemTypes'; + +export default function ArtistContextItems(props: ContextMenuItemsProps) { + const { + type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, + playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, + starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, + playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, + applySongRating, applyAlbumRating, applyArtistRating, + handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + } = props; + const { t } = useTranslation(); + const auth = useAuthStore(); + const navigate = useNavigate(); + + return ( + <> + {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(shareKindOverride ?? '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); + const artistRatingDisabled = entityRatingSupport === 'track_only'; + const multiArtistRatingId = [...artistIds].sort().join('\x1e'); + const unifiedArtistRating = (() => { + if (artists.length === 0) return 0; + const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); + const first = vals[0]; + return vals.every(v => v === first) ? first : 0; + })(); + 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(); }} /> + )} +
+
e.stopPropagation()} + > + + { + setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r }); + for (const a of artists) applyArtistRating(a, r); + }} + /> +
+ + ); + })()} + + + ); +} diff --git a/src/components/contextMenu/ContextMenuItems.tsx b/src/components/contextMenu/ContextMenuItems.tsx new file mode 100644 index 00000000..524b5456 --- /dev/null +++ b/src/components/contextMenu/ContextMenuItems.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type { ContextMenuItemsProps } from './contextMenuItemTypes'; +import SongContextItems from './SongContextItems'; +import QueueItemContextItems from './QueueItemContextItems'; +import AlbumContextItems from './AlbumContextItems'; +import ArtistContextItems from './ArtistContextItems'; +import PlaylistContextItems from './PlaylistContextItems'; + +export default function ContextMenuItems(props: ContextMenuItemsProps) { + const { type } = props; + switch (type) { + case 'song': + case 'album-song': + case 'favorite-song': + return ; + case 'queue-item': + return ; + case 'album': + case 'multi-album': + return ; + case 'artist': + case 'multi-artist': + return ; + case 'playlist': + case 'multi-playlist': + return ; + default: + return null; + } +} diff --git a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx new file mode 100644 index 00000000..095d5b72 --- /dev/null +++ b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListMusic, Plus } from 'lucide-react'; +import { getAlbum } from '../../api/subsonicLibrary'; +import type { SubsonicPlaylist } from '../../api/subsonicTypes'; +import { usePlaylistStore } from '../../store/playlistStore'; +import { showToast } from '../../utils/toast'; +import { + confirmAddAllDuplicates, + isSmartPlaylistName, +} from '../../utils/contextMenuHelpers'; + +interface Props { + albumIds: string[]; + onDone: () => void; + triggerId?: string; +} + +export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _triggerId }: Props) { + const { t } = useTranslation(); + const [resolvedIds, setResolvedIds] = useState(null); + const [totalAlbums, setTotalAlbums] = useState(0); + const [showLoading, setShowLoading] = useState(false); + + useEffect(() => { + setTotalAlbums(albumIds.length); + 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/subsonicPlaylists'); + 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); + } + + 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 albums + function MultiAddToPlaylistSubmenu({ songIds, onDone: innerOnDone }: { songIds: string[]; onDone: () => void }) { + 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); + + const playlists = useMemo(() => { + return [...storePlaylists] + .filter(p => !isSmartPlaylistName(p.name)) + .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); + 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/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: '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) { + if (!showLoading) { + return
; + } + return ( +
+
+ + {t('playlists.loadingAlbums', { count: totalAlbums })} + +
+ ); + } + if (resolvedIds.length === 0) return null; + return ; +} diff --git a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx new file mode 100644 index 00000000..e70060d0 --- /dev/null +++ b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListMusic, Plus } from 'lucide-react'; +import { getAlbum } from '../../api/subsonicLibrary'; +import { getArtist } from '../../api/subsonicArtists'; +import { getPlaylists } from '../../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../../api/subsonicTypes'; +import { usePlaylistStore } from '../../store/playlistStore'; +import { showToast } from '../../utils/toast'; +import { + confirmAddAllDuplicates, + isSmartPlaylistName, +} from '../../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(() => { + setTotalArtists(artistIds.length); + 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/subsonicPlaylists'); + 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); + } + + 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('../../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: '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) { + if (!showLoading) { + return
; + } + return ( +
+
+ + {t('playlists.loadingArtists', { count: totalArtists })} + +
+ ); + } + if (resolvedIds.length === 0) return null; + return ; +} diff --git a/src/components/contextMenu/PlaylistContextItems.tsx b/src/components/contextMenu/PlaylistContextItems.tsx new file mode 100644 index 00000000..6ce7f7b3 --- /dev/null +++ b/src/components/contextMenu/PlaylistContextItems.tsx @@ -0,0 +1,121 @@ +import { useTranslation } from 'react-i18next'; +import { Play, ChevronRight, ListMusic, Trash2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import type { SubsonicPlaylist } from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; +import { usePlaylistStore } from '../../store/playlistStore'; +import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from './PlaylistToPlaylistSubmenus'; +import type { ContextMenuItemsProps } from './contextMenuItemTypes'; + +export default function PlaylistContextItems(props: ContextMenuItemsProps) { + const { + type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, + playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, + starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, + playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, + applySongRating, applyAlbumRating, applyArtistRating, + handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + } = props; + const { t } = useTranslation(); + const auth = useAuthStore(); + const navigate = useNavigate(); + + return ( + <> + {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/subsonicPlaylists'); + 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-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 { deletePlaylist } = await import('../../api/subsonicPlaylists'); + 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')} +
+ + ); + })()} + + + ); +} diff --git a/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx b/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx new file mode 100644 index 00000000..5d84f230 --- /dev/null +++ b/src/components/contextMenu/PlaylistToPlaylistSubmenus.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListMusic, Plus } from 'lucide-react'; +import { usePlaylistStore } from '../../store/playlistStore'; +import { showToast } from '../../utils/toast'; +import { isSmartPlaylistName } from '../../utils/contextMenuHelpers'; + +interface SingleProps { + playlist: { id: string; name: string }; + onDone: () => void; + triggerId?: string; +} + +export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: SingleProps) { + 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); + + const allPlaylists = useMemo(() => { + return storePlaylists.filter( + (p) => p.id !== playlist.id && !isSmartPlaylistName(p.name), + ); + }, [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/subsonicPlaylists'); + 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/subsonicPlaylists'); + 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/subsonicPlaylists'); + 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 ( +
+ {!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} +
+ ))} +
+ ); +} + +interface MultiProps { + playlists: { id: string; name: string }[]; + onDone: () => void; + triggerId?: string; +} + +export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: MultiProps) { + 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); + + const allPlaylists = useMemo(() => { + const selectedIds = new Set(playlists.map(p => p.id)); + return storePlaylists.filter( + (p) => !selectedIds.has(p.id) && !isSmartPlaylistName(p.name), + ); + }, [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/subsonicPlaylists'); + 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/subsonicPlaylists'); + 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/subsonicPlaylists'); + 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 ( +
+ {!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} +
+ ))} +
+ ); +} diff --git a/src/components/contextMenu/QueueItemContextItems.tsx b/src/components/contextMenu/QueueItemContextItems.tsx new file mode 100644 index 00000000..e3138844 --- /dev/null +++ b/src/components/contextMenu/QueueItemContextItems.tsx @@ -0,0 +1,124 @@ +import { useTranslation } from 'react-i18next'; +import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { star, unstar } from '../../api/subsonicStarRating'; +import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; +import type { Track } from '../../store/playerStoreTypes'; +import { useAuthStore } from '../../store/authStore'; +import LastfmIcon from '../LastfmIcon'; +import StarRating from '../StarRating'; +import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu'; +import type { ContextMenuItemsProps } from './contextMenuItemTypes'; + +export default function QueueItemContextItems(props: ContextMenuItemsProps) { + const { + type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, + playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, + starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, + playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, + applySongRating, applyAlbumRating, applyArtistRating, + handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + } = props; + const { t } = useTranslation(); + const auth = useAuthStore(); + const navigate = useNavigate(); + + return ( + <> + {type === 'queue-item' && (() => { + const song = item as Track; + return ( + <> +
handleAction(() => playTrack(song, queue, undefined, undefined, queueIndex))}> + {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')} +
+ + ); + })()} + + ); +} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx new file mode 100644 index 00000000..c8bb0937 --- /dev/null +++ b/src/components/contextMenu/SongContextItems.tsx @@ -0,0 +1,316 @@ +import { useTranslation } from 'react-i18next'; +import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { getAlbum } from '../../api/subsonicLibrary'; +import { star, unstar } from '../../api/subsonicStarRating'; +import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; +import type { Track } from '../../store/playerStoreTypes'; +import { useAuthStore } from '../../store/authStore'; +import { usePlaylistStore } from '../../store/playlistStore'; +import { songToTrack } from '../../utils/songToTrack'; +import { showToast } from '../../utils/toast'; +import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '../../utils/orbit'; +import LastfmIcon from '../LastfmIcon'; +import StarRating from '../StarRating'; +import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu'; +import type { ContextMenuItemsProps } from './contextMenuItemTypes'; + +export default function SongContextItems(props: ContextMenuItemsProps) { + const { + type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, + playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu, + starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong, + openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating, + playlistSubmenuOpen, setPlaylistSubmenuOpen, playlistSongIds, setPlaylistSongIds, + orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, + applySongRating, applyAlbumRating, applyArtistRating, + handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + } = props; + const { t } = useTranslation(); + const auth = useAuthStore(); + const navigate = useNavigate(); + + return ( + <> + {(type === 'song' || type === 'album-song') && (() => { + const song = item as Track; + return ( + <> +
handleAction(() => playTrack(song, [song]))}> + {t('contextMenu.playNow')} +
+
handleAction(() => playNext([song]))}> + {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/subsonicPlaylists'); + 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(() => playNext([song]))}> + {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')} +
+ + ); + })()} + + + ); +} diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts new file mode 100644 index 00000000..60442aca --- /dev/null +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -0,0 +1,52 @@ +import type React from 'react'; +import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; +import type { EntityShareKind } from '../../utils/shareLink'; + +export type RatingKind = 'song' | 'album' | 'artist'; + +export interface KeyboardRating { + kind: RatingKind; + id: string; + value: number; +} + +export interface ContextMenuItemsProps { + type: string | null; + item: unknown; + queueIndex?: number; + playlistId?: string; + playlistSongIndex?: number; + shareKindOverride?: EntityShareKind; + playTrack: (track: Track, queue?: Track[], manual?: boolean, orbitConfirmed?: boolean, targetQueueIndex?: number) => void; + playNext: (tracks: Track[]) => void; + enqueue: (tracks: Track[]) => void; + removeTrack: (idx: number) => void; + queue: Track[]; + currentTrack: Track | null; + closeContextMenu: () => void; + starredOverrides: Record; + setStarredOverride: (id: string, starred: boolean) => void; + lastfmLovedCache: Record; + setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void; + openSongInfo: (id: string) => void; + userRatingOverrides: Record; + setKeyboardRating: React.Dispatch>; + keyboardRating: KeyboardRating | null; + playlistSubmenuOpen: boolean; + setPlaylistSubmenuOpen: React.Dispatch>; + playlistSongIds: string[]; + setPlaylistSongIds: React.Dispatch>; + orbitRole: 'host' | 'guest' | null; + entityRatingSupport: 'full' | 'track_only' | 'unknown'; + audiomuseNavidromeEnabled: boolean; + applySongRating: (id: string, rating: number) => void; + applyAlbumRating: (album: SubsonicAlbum, rating: number) => void; + applyArtistRating: (artist: SubsonicArtist, rating: number) => void; + handleAction: (action: () => void | Promise) => Promise; + startRadio: (artistId: string, artistName: string, seedTrack?: Track) => void; + startInstantMix: (song: Track) => void; + downloadAlbum: (albumName: string, albumId: string) => Promise; + copyShareLink: (kind: EntityShareKind, id: string) => void; + isStarred: (id: string, itemStarred?: string) => boolean; +} diff --git a/src/hooks/useContextMenuKeyboardNav.ts b/src/hooks/useContextMenuKeyboardNav.ts new file mode 100644 index 00000000..5fa9f960 --- /dev/null +++ b/src/hooks/useContextMenuKeyboardNav.ts @@ -0,0 +1,213 @@ +import React, { useCallback, useEffect } from 'react'; + +type RatingKind = 'song' | 'album' | 'artist'; + +interface KeyboardRating { + kind: RatingKind; + id: string; + value: number; +} + +interface Args { + menuRef: React.RefObject; + isOpen: boolean; + closeContextMenu: () => void; + keyboardRating: KeyboardRating | null; + setKeyboardRating: React.Dispatch>; + getRatingValueByKind: (kind: RatingKind, id: string) => number; + commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void; + playlistSubmenuOpen: boolean; + setPlaylistSubmenuOpen: React.Dispatch>; + setPlaylistSongIds: React.Dispatch>; + pendingSubmenuKeyboardFocus: boolean; + setPendingSubmenuKeyboardFocus: React.Dispatch>; +} + +interface Result { + onMenuKeyDown: (e: React.KeyboardEvent) => void; + getMenuNavItems: (scope?: 'main' | 'submenu') => HTMLElement[]; + focusMenuItemAt: (scope: 'main' | 'submenu', index: number) => void; +} + +export function useContextMenuKeyboardNav({ + menuRef, isOpen, closeContextMenu, + keyboardRating, setKeyboardRating, getRatingValueByKind, commitRatingByKind, + playlistSubmenuOpen, setPlaylistSubmenuOpen, setPlaylistSongIds, + pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus, +}: Args): Result { + 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, + ); + }, + [menuRef], + ); + + 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, menuRef]); + + // Focus the menu container when it opens (no row pre-highlight; keyboard outline + // only appears after explicit arrow navigation). + useEffect(() => { + if (!isOpen) return; + requestAnimationFrame(() => { + menuRef.current?.focus({ preventScroll: true }); + }); + }, [isOpen, menuRef]); + + // Outside-click closes the menu without occluding the underlying UI. + useEffect(() => { + if (!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); + }, [isOpen, closeContextMenu, menuRef]); + + // After opening a submenu via keyboard, wait for it to render then focus first item. + 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, setPendingSubmenuKeyboardFocus]); + + 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 RatingKind | 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 RatingKind | 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, menuRef, + setKeyboardRating, setPlaylistSubmenuOpen, setPlaylistSongIds, setPendingSubmenuKeyboardFocus, + ]); + + return { onMenuKeyDown, getMenuNavItems, focusMenuItemAt }; +} diff --git a/src/hooks/useContextMenuRating.ts b/src/hooks/useContextMenuRating.ts new file mode 100644 index 00000000..a688da75 --- /dev/null +++ b/src/hooks/useContextMenuRating.ts @@ -0,0 +1,127 @@ +import { useCallback } from 'react'; +import { setRating } from '../api/subsonicStarRating'; +import type { SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes'; +import type { Track } from '../store/playerStoreTypes'; +import { useAuthStore } from '../store/authStore'; +import { showToast } from '../utils/toast'; + +type RatingKind = 'song' | 'album' | 'artist'; + +interface Args { + type: string | null; + item: unknown; + userRatingOverrides: Record; + setUserRatingOverride: (id: string, rating: number) => void; + entityRatingSupport: 'full' | 'track_only' | 'unknown'; + t: (key: string) => string; +} + +interface Result { + applySongRating: (songId: string, rating: number) => void; + applyAlbumRating: (album: SubsonicAlbum, rating: number) => void; + applyArtistRating: (artist: SubsonicArtist, rating: number) => void; + getRatingValueByKind: (kind: RatingKind, id: string) => number; + commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void; +} + +export function useContextMenuRating({ + type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t, +}: Args): Result { + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); + const activeServerId = useAuthStore(s => s.activeServerId); + + 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 (activeServerId) setEntityRatingSupport(activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + }); + }, [setUserRatingOverride, entityRatingSupport, 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 (activeServerId) setEntityRatingSupport(activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + }); + }, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]); + + const getRatingValueByKind = useCallback((kind: RatingKind, 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 === 'album' && type === 'multi-album') { + const albums = item as SubsonicAlbum[]; + const compositeId = [...albums.map(a => a.id)].sort().join('\x1e'); + if (id !== compositeId) return userRatingOverrides[id] ?? 0; + if (albums.length === 0) return 0; + const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); + const first = vals[0]; + return vals.every(v => v === first) ? first : 0; + } + if (kind === 'artist' && type === 'artist') { + const artist = item as SubsonicArtist; + if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0; + } + if (kind === 'artist' && type === 'multi-artist') { + const artists = item as SubsonicArtist[]; + const compositeId = [...artists.map(a => a.id)].sort().join('\x1e'); + if (id !== compositeId) return userRatingOverrides[id] ?? 0; + if (artists.length === 0) return 0; + const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0); + const first = vals[0]; + return vals.every(v => v === first) ? first : 0; + } + return userRatingOverrides[id] ?? 0; + }, [type, item, userRatingOverrides]); + + const commitRatingByKind = useCallback((kind: RatingKind, id: string, rating: number) => { + if (kind === 'song') { + applySongRating(id, rating); + return; + } + if (kind === 'album' && type === 'album') { + applyAlbumRating(item as SubsonicAlbum, rating); + return; + } + if (kind === 'album' && type === 'multi-album') { + const albums = item as SubsonicAlbum[]; + const compositeId = [...albums.map(a => a.id)].sort().join('\x1e'); + if (id !== compositeId) return; + for (const a of albums) applyAlbumRating(a, rating); + return; + } + if (kind === 'artist' && type === 'artist') { + applyArtistRating(item as SubsonicArtist, rating); + return; + } + if (kind === 'artist' && type === 'multi-artist') { + const artists = item as SubsonicArtist[]; + const compositeId = [...artists.map(a => a.id)].sort().join('\x1e'); + if (id !== compositeId) return; + for (const a of artists) applyArtistRating(a, rating); + } + }, [applySongRating, applyAlbumRating, applyArtistRating, type, item]); + + return { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind }; +} diff --git a/src/utils/contextMenuActions.ts b/src/utils/contextMenuActions.ts new file mode 100644 index 00000000..23698d44 --- /dev/null +++ b/src/utils/contextMenuActions.ts @@ -0,0 +1,146 @@ +import { join } from '@tauri-apps/api/path'; +import { invoke } from '@tauri-apps/api/core'; +import { getSimilarSongs2, getSimilarSongs, getTopSongs } from '../api/subsonicArtists'; +import { buildDownloadUrl } from '../api/subsonicStreamUrl'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import type { Track } from '../store/playerStoreTypes'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; +import { useDownloadModalStore } from '../store/downloadModalStore'; +import type { EntityShareKind } from './shareLink'; +import { copyEntityShareLink } from './copyEntityShareLink'; +import { sanitizeFilename, shuffleArray } from './contextMenuHelpers'; +import { songToTrack } from './songToTrack'; +import { showToast } from './toast'; + +export async function copyShareLink( + kind: EntityShareKind, + id: string, + t: (key: string) => string, +) { + const ok = await copyEntityShareLink(kind, id); + if (ok) showToast(t('contextMenu.shareCopied')); + else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error'); +} + +export async function startRadio( + artistId: string, + artistName: string, + playTrack: (track: Track, queue: Track[]) => void, + seedTrack?: Track, +) { + if (seedTrack) { + const state = usePlayerStore.getState(); + if (state.currentTrack?.id === seedTrack.id) { + if (!state.isPlaying) state.resume(); + } else { + playTrack(seedTrack, [seedTrack]); + } + try { + const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); + const similarTracks = shuffleArray( + similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })), + ); + const radioTracks = similarTracks.length > 0 + ? similarTracks + : shuffleArray( + top.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); + } + return; + } + + // Artist radio without seed + const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited>); + try { + const top = await getTopSongs(artistName); + const topTracks = shuffleArray( + top.map(t => ({ ...songToTrack(t), radioAdded: true as const })), + ); + if (topTracks.length === 0) { + 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; + } + const state = usePlayerStore.getState(); + if (state.currentTrack) { + state.enqueueRadio([topTracks[0]], artistId); + } else { + state.setRadioArtistId(artistId); + playTrack(topTracks[0], [topTracks[0]]); + } + 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); + } +} + +export async function startInstantMix( + song: Track, + t: (key: string) => string, +) { + 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'); + } +} + +export async function downloadAlbum(albumName: string, albumId: string) { + const auth = useAuthStore.getState(); + const requestDownloadFolder = useDownloadModalStore.getState().requestFolder; + 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); + } +} diff --git a/src/utils/contextMenuHelpers.ts b/src/utils/contextMenuHelpers.ts new file mode 100644 index 00000000..ce6c3591 --- /dev/null +++ b/src/utils/contextMenuHelpers.ts @@ -0,0 +1,42 @@ +import { useConfirmModalStore } from '../store/confirmModalStore'; + +/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */ +export const SMART_PLAYLIST_PREFIX = 'psy-smart-'; + +export function isSmartPlaylistName(name: string | undefined | null): boolean { + return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX); +} + +export 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. */ +export 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; +} + +/** Ask user before re-adding songs to a playlist when *all* selected ids are + * already present. Returns true → caller should append them as duplicates, + * false → caller should keep today's silent-skip toast behavior. */ +export async function confirmAddAllDuplicates( + playlistName: string, + count: number, + t: (key: string, opts?: Record) => string, +): Promise { + return useConfirmModalStore.getState().request({ + title: t('playlists.duplicateConfirmTitle'), + message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }), + confirmLabel: t('playlists.duplicateConfirmAction'), + cancelLabel: t('common.cancel'), + }); +}