Files
Psychotoxical-psysonic/src/components/ContextMenu.tsx
T
cucadmuh 45e0e1206f fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server

When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.

* fix(playback): switch to queue server when opening Now Playing

Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.

* docs(credits): mention Now Playing in PR #717 contribution line

* fix(playback): route scrobble and queue sync to queue server

Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
2026-05-15 16:08:41 +03:00

241 lines
9.2 KiB
TypeScript

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, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({
contextMenu: s.contextMenu,
closeContextMenu: s.closeContextMenu,
playTrack: s.playTrack,
enqueue: s.enqueue,
playNext: s.playNext,
queue: s.queue,
currentTrack: s.currentTrack,
removeTrack: s.removeTrack,
lastfmLovedCache: s.lastfmLovedCache,
setLastfmLovedForSong: s.setLastfmLovedForSong,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
openSongInfo: s.openSongInfo,
userRatingOverrides: s.userRatingOverrides,
setUserRatingOverride: s.setUserRatingOverride,
}))
);
const auth = useAuthStore();
const entityRatingSupport =
auth.activeServerId ? auth.entityRatingSupportByServer[auth.activeServerId] ?? 'unknown' : 'unknown';
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
const menuRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(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<string[]>([]);
const [keyboardRating, setKeyboardRating] = useState<{ kind: 'song' | 'album' | 'artist'; id: string; value: number } | null>(null);
const [pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus] = useState(false);
const playlistSubmenuCloseTimerRef = useRef<number | null>(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<HTMLElement>) => {
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<HTMLElement>('.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<void>) => {
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 (
<>
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y }}
tabIndex={-1}
onKeyDown={onMenuKeyDown}
>
<ContextMenuItems
type={type}
item={item}
queueIndex={queueIndex}
playlistId={playlistId}
playlistSongIndex={playlistSongIndex}
shareKindOverride={shareKindOverride}
playTrack={playTrack}
playNext={playNext}
enqueue={enqueue}
removeTrack={removeTrack}
queue={queue}
currentTrack={currentTrack}
closeContextMenu={closeContextMenu}
starredOverrides={starredOverrides}
setStarredOverride={setStarredOverride}
lastfmLovedCache={lastfmLovedCache}
setLastfmLovedForSong={setLastfmLovedForSong}
openSongInfo={openSongInfo}
userRatingOverrides={userRatingOverrides}
setKeyboardRating={setKeyboardRating}
keyboardRating={keyboardRating}
playlistSubmenuOpen={playlistSubmenuOpen}
setPlaylistSubmenuOpen={setPlaylistSubmenuOpen}
cancelPlaylistSubmenuCloseTimer={cancelPlaylistSubmenuCloseTimer}
onPlaylistSubmenuTriggerMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
playlistSongIds={playlistSongIds}
setPlaylistSongIds={setPlaylistSongIds}
orbitRole={orbitRole}
entityRatingSupport={entityRatingSupport}
audiomuseNavidromeEnabled={audiomuseNavidromeEnabled}
applySongRating={applySongRating}
applyAlbumRating={applyAlbumRating}
applyArtistRating={applyArtistRating}
handleAction={handleAction}
startRadio={startRadio}
startInstantMix={startInstantMix}
downloadAlbum={downloadAlbum}
copyShareLink={copyShareLink}
isStarred={isStarred}
pinToPlaybackServer={pinToPlaybackServer}
navigateLibrary={navigateLibrary}
/>
</div>
</>
);
}