import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { Track } from '../store/playerStoreTypes'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import type { EntityShareKind } from '../utils/share/shareLink'; import { AddToPlaylistSubmenu } from './contextMenu/AddToPlaylistSubmenu'; import { copyShareLink as copyShareLinkAction, downloadAlbum as downloadAlbumAction, startInstantMix as startInstantMixAction, startRadio as startRadioAction, } from '../utils/componentHelpers/contextMenuActions'; import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav'; import { useContextMenuRating } from '../hooks/useContextMenuRating'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useNavigate } from 'react-router-dom'; import ContextMenuItems from './contextMenu/ContextMenuItems'; export { AddToPlaylistSubmenu }; export default function ContextMenu() { const { t } = useTranslation(); const navigate = useNavigate(); const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); const orbitRole = useOrbitStore(s => s.role); const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( useShallow(s => ({ contextMenu: s.contextMenu, closeContextMenu: s.closeContextMenu, playTrack: s.playTrack, enqueue: s.enqueue, playNext: s.playNext, queueItems: s.queueItems, currentTrack: s.currentTrack, removeTrack: s.removeTrack, lastfmLovedCache: s.lastfmLovedCache, setLastfmLovedForSong: s.setLastfmLovedForSong, starredOverrides: s.starredOverrides, setStarredOverride: s.setStarredOverride, openSongInfo: s.openSongInfo, userRatingOverrides: s.userRatingOverrides, setUserRatingOverride: s.setUserRatingOverride, })) ); const auth = useAuthStore(); const entityRatingSupport = auth.activeServerId ? auth.entityRatingSupportByServer[auth.activeServerId] ?? 'unknown' : 'unknown'; const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]); const menuRef = useRef(null); const previousFocusRef = useRef(null); // Adjusted coordinates to keep menu on screen const [coords, setCoords] = useState({ x: 0, y: 0 }); const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false); const [playlistSongIds, setPlaylistSongIds] = useState([]); const [keyboardRating, setKeyboardRating] = useState<{ kind: 'song' | 'album' | 'artist'; id: string; value: number } | null>(null); const [pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus] = useState(false); const playlistSubmenuCloseTimerRef = useRef(null); const cancelPlaylistSubmenuCloseTimer = useCallback(() => { if (playlistSubmenuCloseTimerRef.current != null) { window.clearTimeout(playlistSubmenuCloseTimerRef.current); playlistSubmenuCloseTimerRef.current = null; } }, []); /** Delay close so a slow move across subpixel / border seams still lands on `.context-submenu` (a child of the row). */ const onPlaylistSubmenuTriggerMouseLeave = useCallback( (e: React.MouseEvent) => { const cur = e.currentTarget; const next = e.relatedTarget; if (next instanceof Node && cur.contains(next)) return; cancelPlaylistSubmenuCloseTimer(); playlistSubmenuCloseTimerRef.current = window.setTimeout(() => { playlistSubmenuCloseTimerRef.current = null; if (!cur.isConnected) return; if (!cur.matches(':hover')) setPlaylistSubmenuOpen(false); }, 140); }, [cancelPlaylistSubmenuCloseTimer], ); useEffect(() => { if (contextMenu.isOpen) { cancelPlaylistSubmenuCloseTimer(); setCoords({ x: contextMenu.x, y: contextMenu.y }); setPlaylistSubmenuOpen(false); setPlaylistSongIds([]); setKeyboardRating(null); setPendingSubmenuKeyboardFocus(false); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y, cancelPlaylistSubmenuCloseTimer]); useEffect(() => { if (contextMenu.isOpen && menuRef.current) { const rect = menuRef.current.getBoundingClientRect(); const winW = window.innerWidth; const winH = window.innerHeight; let finalX = contextMenu.x; let finalY = contextMenu.y; if (finalX + rect.width > winW) finalX = winW - rect.width - 10; if (finalY + rect.height > winH) finalY = winH - rect.height - 10; setCoords({ x: finalX, y: finalY }); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); useEffect(() => { if (contextMenu.isOpen) { previousFocusRef.current = document.activeElement as HTMLElement | null; return; } cancelPlaylistSubmenuCloseTimer(); // Clean up any keyboard focus styling when menu closes menuRef.current ?.querySelectorAll('.context-menu-keyboard-active') .forEach(el => el.classList.remove('context-menu-keyboard-active')); const prev = previousFocusRef.current; previousFocusRef.current = null; if (prev?.isConnected) { requestAnimationFrame(() => { prev.focus({ preventScroll: true }); }); } }, [contextMenu.isOpen, closeContextMenu, cancelPlaylistSubmenuCloseTimer]); const { type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride, pinToPlaybackServer = false, } = contextMenu; const navigateLibrary = pinToPlaybackServer ? navigatePlaybackLibrary : (path: string) => { navigate(path); }; const isStarred = (id: string, itemStarred?: string) => id in starredOverrides ? starredOverrides[id] : !!itemStarred; const { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind } = useContextMenuRating({ type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t }); 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( (kind: EntityShareKind, id: string) => copyShareLinkAction(kind, id, t), [t], ); const startRadio = (artistId: string, artistName: string, seedTrack?: Track) => startRadioAction(artistId, artistName, playTrack, seedTrack); const startInstantMix = (song: Track) => startInstantMixAction(song, t); const downloadAlbum = downloadAlbumAction; if (!contextMenu.isOpen || !contextMenu.item) return null; return ( <>
); }