mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(ui): queue full album after current; filter smart playlists in add targets
Album context menu loads tracks and inserts them immediately after the current queue position, or starts album playback when nothing is playing. Add-to-playlist and merge-target submenus omit psy-smart-* playlist names so dynamic Navidrome lists are not offered as manual append destinations.
This commit is contained in:
@@ -34,6 +34,13 @@ function sanitizeFilename(name: string): string {
|
|||||||
.substring(0, 200) || 'download';
|
.substring(0, 200) || 'download';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
|
||||||
|
const SMART_PLAYLIST_PREFIX = 'psy-smart-';
|
||||||
|
|
||||||
|
function isSmartPlaylistName(name: string | undefined | null): boolean {
|
||||||
|
return (name ?? '').toLowerCase().startsWith(SMART_PLAYLIST_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
|
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
|
||||||
function shuffleArray<T>(arr: T[]): T[] {
|
function shuffleArray<T>(arr: T[]): T[] {
|
||||||
const result = [...arr];
|
const result = [...arr];
|
||||||
@@ -66,16 +73,18 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sort playlists by recent usage
|
// Sort playlists by recent usage (exclude app smart playlists — not editable as normal lists)
|
||||||
const playlists = useMemo(() => {
|
const playlists = useMemo(() => {
|
||||||
return [...storePlaylists].sort((a, b) => {
|
return [...storePlaylists]
|
||||||
const ai = recentIds.indexOf(a.id);
|
.filter(p => !isSmartPlaylistName(p.name))
|
||||||
const bi = recentIds.indexOf(b.id);
|
.sort((a, b) => {
|
||||||
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
|
const ai = recentIds.indexOf(a.id);
|
||||||
if (ai === -1) return 1;
|
const bi = recentIds.indexOf(b.id);
|
||||||
if (bi === -1) return -1;
|
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
|
||||||
return ai - bi;
|
if (ai === -1) return 1;
|
||||||
});
|
if (bi === -1) return -1;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
}, [storePlaylists, recentIds]);
|
}, [storePlaylists, recentIds]);
|
||||||
|
|
||||||
// Flip submenu left if it would overflow the right edge of the viewport
|
// Flip submenu left if it would overflow the right edge of the viewport
|
||||||
@@ -337,7 +346,9 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
|
|||||||
|
|
||||||
// Sort playlists from store (no fetch needed, prevents flash)
|
// Sort playlists from store (no fetch needed, prevents flash)
|
||||||
const playlists = useMemo(() => {
|
const playlists = useMemo(() => {
|
||||||
return [...storePlaylists].sort((a, b) => a.name.localeCompare(b.name));
|
return [...storePlaylists]
|
||||||
|
.filter(p => !isSmartPlaylistName(p.name))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}, [storePlaylists]);
|
}, [storePlaylists]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -546,7 +557,9 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPlaylists().then((all) => {
|
getPlaylists().then((all) => {
|
||||||
setPlaylists(all.sort((a, b) => a.name.localeCompare(b.name)));
|
setPlaylists(
|
||||||
|
all.filter(p => !isSmartPlaylistName(p.name)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -669,9 +682,11 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
|
|||||||
const [flipUp, setFlipUp] = useState(false);
|
const [flipUp, setFlipUp] = useState(false);
|
||||||
const storePlaylists = usePlaylistStore((s) => s.playlists);
|
const storePlaylists = usePlaylistStore((s) => s.playlists);
|
||||||
|
|
||||||
// Filter out the current playlist from the list
|
// Filter out the current playlist and smart playlists from the list
|
||||||
const allPlaylists = useMemo(() => {
|
const allPlaylists = useMemo(() => {
|
||||||
return storePlaylists.filter((p) => p.id !== playlist.id);
|
return storePlaylists.filter(
|
||||||
|
(p) => p.id !== playlist.id && !isSmartPlaylistName(p.name),
|
||||||
|
);
|
||||||
}, [storePlaylists, playlist.id]);
|
}, [storePlaylists, playlist.id]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -814,10 +829,12 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
|
|||||||
const [flipUp, setFlipUp] = useState(false);
|
const [flipUp, setFlipUp] = useState(false);
|
||||||
const storePlaylists = usePlaylistStore((s) => s.playlists);
|
const storePlaylists = usePlaylistStore((s) => s.playlists);
|
||||||
|
|
||||||
// Filter out the selected playlists from the list
|
// Filter out the selected playlists and smart playlists from the list
|
||||||
const allPlaylists = useMemo(() => {
|
const allPlaylists = useMemo(() => {
|
||||||
const selectedIds = new Set(playlists.map(p => p.id));
|
const selectedIds = new Set(playlists.map(p => p.id));
|
||||||
return storePlaylists.filter((p) => !selectedIds.has(p.id));
|
return storePlaylists.filter(
|
||||||
|
(p) => !selectedIds.has(p.id) && !isSmartPlaylistName(p.name),
|
||||||
|
);
|
||||||
}, [storePlaylists, playlists]);
|
}, [storePlaylists, playlists]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1746,6 +1763,19 @@ export default function ContextMenu() {
|
|||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
|
||||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</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;
|
||||||
|
if (!currentTrack) {
|
||||||
|
playTrack(tracks[0], tracks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentIdx = usePlayerStore.getState().queueIndex;
|
||||||
|
usePlayerStore.getState().enqueueAt(tracks, currentIdx + 1);
|
||||||
|
})}>
|
||||||
|
<ChevronRight size={14} /> {t('contextMenu.playNext')}
|
||||||
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||||
const albumData = await getAlbum(album.id);
|
const albumData = await getAlbum(album.id);
|
||||||
enqueue(albumData.songs.map(songToTrack));
|
enqueue(albumData.songs.map(songToTrack));
|
||||||
|
|||||||
Reference in New Issue
Block a user