mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(context-menu): H.1–H.12 — extract submenus + 5 type-branch components + hooks (Phase H start) (#662)
* refactor(context-menu): H.1 — extract helpers + constants * refactor(context-menu): H.2 — extract AddToPlaylistSubmenu component * refactor(context-menu): H.3 — extract AlbumToPlaylistSubmenu + ArtistToPlaylistSubmenu * refactor(context-menu): H.4 — extract MultiAlbumToPlaylistSubmenu * refactor(context-menu): H.5 — extract MultiArtistToPlaylistSubmenu * refactor(context-menu): H.6 — extract SinglePlaylist + MultiPlaylist submenus * refactor(context-menu): H.7 — extract startRadio/startInstantMix/downloadAlbum/copyShareLink actions * refactor(context-menu): H.8 — extract useContextMenuKeyboardNav hook * refactor(context-menu): H.9 — extract useContextMenuRating hook * refactor(context-menu): H.10 — extract ContextMenuItems (all 9 type branches) * refactor(context-menu): H.11 — split ContextMenuItems into 5 type-branch files ContextMenuItems.tsx (800 LOC) was just a moved 400-LOC-cap violation. Now ContextMenuItems is a 30-LOC switch that dispatches to: - SongContextItems (song + album-song + favorite-song) - QueueItemContextItems (queue-item) - AlbumContextItems (album + multi-album) - ArtistContextItems (artist + multi-artist) - PlaylistContextItems (playlist + multi-playlist) All five branch files are now under 330 LOC; ContextMenu.tsx itself stays at 194 LOC. Shared Props interface lives in contextMenuItemTypes.ts. * refactor(context-menu): H.12 — strip unused imports from branch components
This commit is contained in:
committed by
GitHub
parent
c2b75817c4
commit
ef5eda263d
+76
-2126
File diff suppressed because it is too large
Load Diff
@@ -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<HTMLDivElement>(null);
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [adding, setAdding] = useState<string | null>(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 (
|
||||||
|
<div className="context-submenu" data-parent-trigger-id={triggerId ?? ''} ref={subRef} style={subStyle}>
|
||||||
|
{!creating ? (
|
||||||
|
<div
|
||||||
|
className="context-menu-item context-submenu-new"
|
||||||
|
onClick={e => { e.stopPropagation(); setCreating(true); }}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
placeholder={t('playlists.createName')}
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
|
||||||
|
{playlists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||||
|
)}
|
||||||
|
{playlists.map((pl: SubsonicPlaylist) => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl)}
|
||||||
|
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAlbum(albumId).then((data) => {
|
||||||
|
setResolvedIds(data.songs.map((s) => s.id));
|
||||||
|
}).catch(() => setResolvedIds([]));
|
||||||
|
}, [albumId]);
|
||||||
|
|
||||||
|
if (resolvedIds === null) {
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedIds.length === 0) return null;
|
||||||
|
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistProps {
|
||||||
|
artistId: string;
|
||||||
|
onDone: () => void;
|
||||||
|
triggerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistProps) {
|
||||||
|
const [resolvedIds, setResolvedIds] = useState<string[] | null>(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 (
|
||||||
|
<div className="context-submenu" style={{ display: 'flex', justifyContent: 'center', padding: '0.75rem' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedIds.length === 0) return null;
|
||||||
|
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||||
|
const albumData = await getAlbum(album.id);
|
||||||
|
const tracks = albumData.songs.map(songToTrack);
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
playNext(tracks);
|
||||||
|
})}>
|
||||||
|
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||||
|
const albumData = await getAlbum(album.id);
|
||||||
|
enqueue(albumData.songs.map(songToTrack));
|
||||||
|
})}>
|
||||||
|
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
|
||||||
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const starred = isStarred(album.id, album.starred);
|
||||||
|
setStarredOverride(album.id, !starred);
|
||||||
|
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
|
||||||
|
})}>
|
||||||
|
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="album"
|
||||||
|
data-rating-id={album.id}
|
||||||
|
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||||
|
? keyboardRating.value
|
||||||
|
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||||
|
disabled={albumRatingDisabled}
|
||||||
|
labelKey="entityRating.albumAriaLabel"
|
||||||
|
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||||
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||||
|
<Download size={14} /> {t('contextMenu.download')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`album:${album.id}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||||
|
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
{t('contextMenu.selectedAlbums', { count: albums.length })}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => 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);
|
||||||
|
})}>
|
||||||
|
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||||
|
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="album"
|
||||||
|
data-rating-id={multiAlbumRatingId}
|
||||||
|
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={
|
||||||
|
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||||
|
? keyboardRating.value
|
||||||
|
: unifiedAlbumRating
|
||||||
|
}
|
||||||
|
disabled={albumRatingDisabled}
|
||||||
|
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||||
|
onChange={r => {
|
||||||
|
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||||
|
for (const a of albums) applyAlbumRating(a, r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||||
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||||
|
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
|
||||||
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const starred = isStarred(artist.id, artist.starred);
|
||||||
|
setStarredOverride(artist.id, !starred);
|
||||||
|
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
|
||||||
|
})}>
|
||||||
|
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="artist"
|
||||||
|
data-rating-id={artist.id}
|
||||||
|
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||||
|
? keyboardRating.value
|
||||||
|
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||||
|
disabled={artistRatingDisabled}
|
||||||
|
labelKey="entityRating.artistAriaLabel"
|
||||||
|
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||||
|
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="artist"
|
||||||
|
data-rating-id={multiArtistRatingId}
|
||||||
|
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={
|
||||||
|
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||||
|
? keyboardRating.value
|
||||||
|
: unifiedArtistRating
|
||||||
|
}
|
||||||
|
disabled={artistRatingDisabled}
|
||||||
|
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||||
|
onChange={r => {
|
||||||
|
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||||
|
for (const a of artists) applyArtistRating(a, r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <SongContextItems {...props} />;
|
||||||
|
case 'queue-item':
|
||||||
|
return <QueueItemContextItems {...props} />;
|
||||||
|
case 'album':
|
||||||
|
case 'multi-album':
|
||||||
|
return <AlbumContextItems {...props} />;
|
||||||
|
case 'artist':
|
||||||
|
case 'multi-artist':
|
||||||
|
return <ArtistContextItems {...props} />;
|
||||||
|
case 'playlist':
|
||||||
|
case 'multi-playlist':
|
||||||
|
return <PlaylistContextItems {...props} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string[] | null>(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<HTMLDivElement>(null);
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [adding, setAdding] = useState<string | null>(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 (
|
||||||
|
<div className="context-submenu" ref={subRef} style={{ ...subStyle, visibility: visible ? 'visible' : 'hidden' }}>
|
||||||
|
{!creating ? (
|
||||||
|
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
placeholder={t('playlists.createName')}
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{playlists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||||
|
)}
|
||||||
|
{playlists.map((pl) => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl)}
|
||||||
|
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedIds === null) {
|
||||||
|
if (!showLoading) {
|
||||||
|
return <div className="context-submenu" style={{ minWidth: 190 }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem', minWidth: 190 }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('playlists.loadingAlbums', { count: totalAlbums })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedIds.length === 0) return null;
|
||||||
|
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||||
|
}
|
||||||
@@ -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<string[] | null>(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<HTMLDivElement>(null);
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||||
|
const [adding, setAdding] = useState<string | null>(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 (
|
||||||
|
<div className="context-submenu" ref={subRef} style={subStyle}>
|
||||||
|
{!creating ? (
|
||||||
|
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
placeholder={t('playlists.createName')}
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{playlists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.empty')}</div>
|
||||||
|
)}
|
||||||
|
{playlists.map((pl) => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl)}
|
||||||
|
style={{ opacity: adding === pl.id ? 0.5 : 1, pointerEvents: adding ? 'none' : undefined }}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedIds === null) {
|
||||||
|
if (!showLoading) {
|
||||||
|
return <div className="context-submenu" style={{ minWidth: 190 }} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="context-submenu" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.75rem', gap: '0.5rem', minWidth: 190 }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('playlists.loadingArtists', { count: totalArtists })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resolvedIds.length === 0) return null;
|
||||||
|
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/playlists/${playlist.id}`))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||||
|
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => 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');
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{type === 'multi-playlist' && (() => {
|
||||||
|
const selectedPlaylists = item as SubsonicPlaylist[];
|
||||||
|
const playlistIds = selectedPlaylists.map(p => p.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-header" style={{ padding: '8px 12px', fontSize: 13, color: 'var(--text-muted)', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
|
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||||
|
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => 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');
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||||
|
{!creating ? (
|
||||||
|
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
placeholder={t('playlists.createName')}
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{allPlaylists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
|
||||||
|
)}
|
||||||
|
{allPlaylists.map(pl => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleAdd(pl.id, pl.name)}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiProps {
|
||||||
|
playlists: { id: string; name: string }[];
|
||||||
|
onDone: () => void;
|
||||||
|
triggerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: MultiProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const subRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const newNameRef = useRef<HTMLInputElement>(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<string>();
|
||||||
|
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 (
|
||||||
|
<div ref={subRef} className="context-submenu" data-submenu-for={triggerId} style={{ ...subStyle, minWidth: 190 }}>
|
||||||
|
{!creating ? (
|
||||||
|
<div className="context-menu-item context-submenu-new" onClick={e => { e.stopPropagation(); setCreating(true); }}>
|
||||||
|
<Plus size={13} /> {t('playlists.newPlaylist')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
ref={newNameRef}
|
||||||
|
className="context-submenu-input"
|
||||||
|
placeholder={t('playlists.createName')}
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="context-submenu-create-btn" onClick={handleCreate}>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{allPlaylists.length === 0 && (
|
||||||
|
<div className="context-submenu-empty">{t('playlists.noOtherPlaylists')}</div>
|
||||||
|
)}
|
||||||
|
{allPlaylists.map(pl => (
|
||||||
|
<div
|
||||||
|
key={pl.id}
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleMerge(pl.id, pl.name)}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pl.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue, undefined, undefined, queueIndex))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||||
|
if (queueIndex !== undefined) removeTrack(queueIndex);
|
||||||
|
})}>
|
||||||
|
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={song.id}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||||
|
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{song.albumId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||||
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{song.artistId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||||
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
|
</div>
|
||||||
|
{audiomuseNavidromeEnabled && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||||
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const starred = isStarred(song.id, song.starred);
|
||||||
|
setStarredOverride(song.id, !starred);
|
||||||
|
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||||
|
})}>
|
||||||
|
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
|
</div>
|
||||||
|
{auth.lastfmSessionKey && (() => {
|
||||||
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
|
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||||
|
return (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const newLoved = !loved;
|
||||||
|
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||||
|
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||||
|
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||||
|
})}>
|
||||||
|
<LastfmIcon size={14} />
|
||||||
|
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="song"
|
||||||
|
data-rating-id={song.id}
|
||||||
|
data-rating-disabled="false"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||||
|
? keyboardRating.value
|
||||||
|
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
|
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => playNext([song]))}>
|
||||||
|
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||||
|
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||||
|
</div>
|
||||||
|
{orbitRole === 'guest' && (() => {
|
||||||
|
const muted = evaluateOrbitSuggestGate().reason === 'muted';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`context-menu-item${muted ? ' is-disabled' : ''}`}
|
||||||
|
{...(muted ? { 'data-tooltip': t('orbit.suggestBlockedMuted') } : {})}
|
||||||
|
onClick={() => 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{orbitRole === 'host' && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
hostEnqueueToOrbit(song.id)
|
||||||
|
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
|
||||||
|
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
|
||||||
|
})}>
|
||||||
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={song.id}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||||
|
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{type === 'album-song' && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||||
|
const albumData = await getAlbum(song.albumId);
|
||||||
|
const tracks = albumData.songs.map(songToTrack);
|
||||||
|
enqueue(tracks);
|
||||||
|
})}>
|
||||||
|
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{song.albumId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||||
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{song.artistId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||||
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
|
</div>
|
||||||
|
{audiomuseNavidromeEnabled && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||||
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const starred = isStarred(song.id, song.starred);
|
||||||
|
setStarredOverride(song.id, !starred);
|
||||||
|
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
|
||||||
|
})}>
|
||||||
|
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
|
</div>
|
||||||
|
{auth.lastfmSessionKey && (() => {
|
||||||
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
|
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||||
|
return (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const newLoved = !loved;
|
||||||
|
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||||
|
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||||
|
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||||
|
})}>
|
||||||
|
<LastfmIcon size={14} />
|
||||||
|
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="song"
|
||||||
|
data-rating-id={song.id}
|
||||||
|
data-rating-disabled="false"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||||
|
? keyboardRating.value
|
||||||
|
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
|
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
|
</div>
|
||||||
|
{playlistId && playlistSongIndex !== undefined && (
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => 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');
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
<Trash2 size={14} /> {t('contextMenu.removeFromPlaylist')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{type === 'favorite-song' && (() => {
|
||||||
|
const song = item as Track;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, [song]))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => playNext([song]))}>
|
||||||
|
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
|
||||||
|
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
|
||||||
|
</div>
|
||||||
|
{orbitRole === 'guest' && (() => {
|
||||||
|
const muted = evaluateOrbitSuggestGate().reason === 'muted';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`context-menu-item${muted ? ' is-disabled' : ''}`}
|
||||||
|
{...(muted ? { 'data-tooltip': t('orbit.suggestBlockedMuted') } : {})}
|
||||||
|
onClick={() => 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{orbitRole === 'host' && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
hostEnqueueToOrbit(song.id)
|
||||||
|
.then(() => showToast(t('orbit.ctxAddedHostToast'), 2200, 'info'))
|
||||||
|
.catch(() => showToast(t('orbit.ctxAddHostFailed'), 3000, 'error'));
|
||||||
|
})}>
|
||||||
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||||
|
data-playlist-trigger-id={song.id}
|
||||||
|
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||||
|
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
|
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||||
|
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{song.albumId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
|
||||||
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{song.artistId && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${song.artistId}`))}>
|
||||||
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
|
||||||
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
|
</div>
|
||||||
|
{audiomuseNavidromeEnabled && (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
|
||||||
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{auth.lastfmSessionKey && (() => {
|
||||||
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
|
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||||
|
return (
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
|
const newLoved = !loved;
|
||||||
|
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||||
|
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||||
|
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||||
|
})}>
|
||||||
|
<LastfmIcon size={14} />
|
||||||
|
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div
|
||||||
|
className="context-menu-rating-row"
|
||||||
|
data-rating-kind="song"
|
||||||
|
data-rating-id={song.id}
|
||||||
|
data-rating-disabled="false"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
|
<StarRating
|
||||||
|
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||||
|
? keyboardRating.value
|
||||||
|
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
|
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||||
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||||
|
setStarredOverride(song.id, false);
|
||||||
|
return unstar(song.id, 'song');
|
||||||
|
})}>
|
||||||
|
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, boolean>;
|
||||||
|
setStarredOverride: (id: string, starred: boolean) => void;
|
||||||
|
lastfmLovedCache: Record<string, boolean>;
|
||||||
|
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
||||||
|
openSongInfo: (id: string) => void;
|
||||||
|
userRatingOverrides: Record<string, number>;
|
||||||
|
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||||
|
keyboardRating: KeyboardRating | null;
|
||||||
|
playlistSubmenuOpen: boolean;
|
||||||
|
setPlaylistSubmenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
playlistSongIds: string[];
|
||||||
|
setPlaylistSongIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
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<void>) => Promise<void>;
|
||||||
|
startRadio: (artistId: string, artistName: string, seedTrack?: Track) => void;
|
||||||
|
startInstantMix: (song: Track) => void;
|
||||||
|
downloadAlbum: (albumName: string, albumId: string) => Promise<void>;
|
||||||
|
copyShareLink: (kind: EntityShareKind, id: string) => void;
|
||||||
|
isStarred: (id: string, itemStarred?: string) => boolean;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement | null>;
|
||||||
|
isOpen: boolean;
|
||||||
|
closeContextMenu: () => void;
|
||||||
|
keyboardRating: KeyboardRating | null;
|
||||||
|
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||||
|
getRatingValueByKind: (kind: RatingKind, id: string) => number;
|
||||||
|
commitRatingByKind: (kind: RatingKind, id: string, rating: number) => void;
|
||||||
|
playlistSubmenuOpen: boolean;
|
||||||
|
setPlaylistSubmenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setPlaylistSongIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
pendingSubmenuKeyboardFocus: boolean;
|
||||||
|
setPendingSubmenuKeyboardFocus: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
onMenuKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => 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<HTMLElement>('.context-submenu');
|
||||||
|
if (!sub || sub.offsetParent === null) return [];
|
||||||
|
return Array.from(
|
||||||
|
sub.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLDivElement>) => {
|
||||||
|
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<HTMLElement>('.context-menu-item--submenu') ?? [])
|
||||||
|
.find(el => el.dataset.playlistTriggerId === triggerId) ?? null
|
||||||
|
: null;
|
||||||
|
if (trigger) {
|
||||||
|
menuRef.current
|
||||||
|
?.querySelectorAll<HTMLElement>('.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 };
|
||||||
|
}
|
||||||
@@ -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<string, number>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<ReturnType<typeof getSimilarSongs2>>);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(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, unknown>) => string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return useConfirmModalStore.getState().request({
|
||||||
|
title: t('playlists.duplicateConfirmTitle'),
|
||||||
|
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
|
||||||
|
confirmLabel: t('playlists.duplicateConfirmAction'),
|
||||||
|
cancelLabel: t('common.cancel'),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user