refactor(updater+contextMenu): co-locate updater feature (AppUpdater+Changelog) and the context-menu subsystem into features/

This commit is contained in:
Psychotoxical
2026-06-30 19:12:26 +02:00
parent 7d2645171b
commit 15a6590e77
24 changed files with 72 additions and 59 deletions
@@ -2,7 +2,7 @@ import React from 'react';
import { ListPlus, Search, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { useSelectionStore } from '@/store/selectionStore';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
interface Props {
@@ -0,0 +1,175 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { getPlaylist, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { showToast } from '@/utils/ui/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
interface Props {
songIds: string[];
/** When set (bulk toolbar pickers), read IDs at action time — avoids stale props if selection changes after open. */
resolveSongIds?: () => readonly string[];
onDone: () => void;
dropDown?: boolean;
triggerId?: string;
}
export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown, triggerId }: Props) {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const songIdsRef = useRef(songIds);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
songIdsRef.current = songIds;
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 idsForAction = () => [...(resolveSongIds?.() ?? songIdsRef.current)];
const handleAdd = async (pl: SubsonicPlaylist) => {
const ids = idsForAction();
setAdding(pl.id);
try {
const { songs } = await getPlaylist(pl.id);
const existingIds = new Set(songs.map((s) => s.id));
const newIds = ids.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, ids.length, t);
if (accepted) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...ids]);
showToast(t('playlists.addedAsDuplicates', { count: ids.length, playlist: pl.name }), 3000, 'info');
touchPlaylist(pl.id);
} else {
showToast(t('playlists.addAllSkipped', { count: ids.length, playlist: pl.name }), 3000, 'info');
}
}
} catch {
showToast(t('playlists.addError'), 3000, 'error');
}
setAdding(null);
onDone();
};
const handleCreate = async () => {
const ids = idsForAction();
const name = newName.trim() || t('playlists.unnamed');
try {
const pl = await createPlaylist(name, ids);
if (pl?.id) {
showToast(t('playlists.createAndAddSuccess', { count: ids.length, playlist: pl.name || name }));
}
} catch {
showToast(t('playlists.createError'), 3000, 'error');
}
onDone();
};
// Flush to the parent edge (left/right/top 100%). Actual “hole” cases are handled
// in ContextMenu via a short delayed mouseleave + :hover check on the trigger row.
const subStyle: React.CSSProperties = dropDown
? { top: '100%', left: 0, right: 'auto' }
: flipLeft
? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
return (
<div
className="context-submenu"
data-parent-trigger-id={triggerId ?? ''}
ref={subRef}
style={subStyle}
onMouseDown={dropDown ? (e) => e.stopPropagation() : undefined}
>
{!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,75 @@
import React, { useEffect, useState } from 'react';
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '@/features/offline';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
interface AlbumProps {
albumId: string;
onDone: () => void;
triggerId?: string;
}
export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProps) {
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
useEffect(() => {
const serverId = resolveMediaServerId();
if (!serverId) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedIds([]);
return;
}
resolveAlbum(serverId, albumId).then((data) => {
setResolvedIds(data ? 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 serverId = resolveMediaServerId();
if (!serverId) {
setResolvedIds([]);
return;
}
const artistData = await resolveArtist(serverId, artistId);
if (!artistData) {
setResolvedIds([]);
return;
}
const albumSongs = await Promise.all(
artistData.albums.map(a => resolveAlbum(serverId, 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,199 @@
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 { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import { star, unstar } from '@/lib/api/subsonicStarRating';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import StarRating from '@/ui/StarRating';
import { AlbumToPlaylistSubmenu } from '@/features/contextMenu/components/AlbumArtistToPlaylistSubmenu';
import { MultiAlbumToPlaylistSubmenu } from '@/features/contextMenu/components/MultiAlbumToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
export default function AlbumContextItems(props: ContextMenuItemsProps) {
const {
type, item, playNext, enqueue, closeContextMenu,
setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
entityRatingSupport, applyAlbumRating,
handleAction, downloadAlbum, copyShareLink, isStarred,
pinToPlaybackServer, navigateLibrary, offlinePolicy,
} = props;
const { t } = useTranslation();
const navigate = useNavigate();
const goLibrary = pinToPlaybackServer ? navigateLibrary : (path: string) => { navigate(path); };
return (
<>
{type === 'album' && (() => {
const album = item as SubsonicAlbum;
const albumRatingDisabled = entityRatingSupport === 'track_only';
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
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 serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
enqueue(albumData.songs.map(songToTrack));
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
const meta = {
serverId: album.serverId,
name: album.name,
artist: album.artist,
artistId: album.artistId,
coverArtId: album.coverArt,
year: album.year,
};
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
})}>
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div>
)}
{offlinePolicy.canRate && (
<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>
{offlinePolicy.canDownload && (
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
)}
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`album:${album.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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 () => {
const results = await Promise.all(albums.map(async a => {
const serverId = resolveMediaServerId(a.serverId);
if (!serverId) return null;
return resolveAlbum(serverId, a.id);
}));
const allTracks = results
.filter((r): r is NonNullable<typeof r> => r != null)
.flatMap(r => r.songs.map(songToTrack));
enqueue(allTracks);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
</div>
{offlinePolicy.canAddToPlaylist && (
<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={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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>
)}
{offlinePolicy.canRate && (
<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,155 @@
import { useTranslation } from 'react-i18next';
import { Radio, Heart, ChevronRight, ListMusic, Star, Share2 } from 'lucide-react';
import { star, unstar } from '@/lib/api/subsonicStarRating';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import StarRating from '@/ui/StarRating';
import { ArtistToPlaylistSubmenu } from '@/features/contextMenu/components/AlbumArtistToPlaylistSubmenu';
import { MultiArtistToPlaylistSubmenu } from '@/features/contextMenu/components/MultiArtistToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
export default function ArtistContextItems(props: ContextMenuItemsProps) {
const {
type, item, shareKindOverride, closeContextMenu,
setStarredOverride, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
entityRatingSupport, applyArtistRating,
handleAction, startRadio, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
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>
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`artist:${artist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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>
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
<>
<div className="context-menu-divider" />
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
return starred
? unstar(artist.id, 'artist', meta)
: star(artist.id, 'artist', meta);
})}>
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
)}
{offlinePolicy.canRate && (
<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" />
{offlinePolicy.canAddToPlaylist && (
<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={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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>
)}
{offlinePolicy.canRate && (
<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,215 @@
/**
* `ContextMenu` characterization (Phase F5a).
*
* Drives the menu via `usePlayerStore.openContextMenu(...)`, asserts on
* the rendered items + their click → store-action wiring. Avoids deep
* snapshots — tests survive a refactor that re-orders or re-styles the
* markup as long as the menu items + their handlers stay observable.
*/
import type { ServerProfile } from '@/store/authStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
getAlbum: vi.fn(async () => ({ album: { id: 'a1', songs: [] }, songs: [] })),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
setRating: vi.fn(async () => undefined),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
}));
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
vi.mock('@/features/offline/hooks/useOfflineBrowseContext', () => ({
useOfflineBrowseContext: () => ({
active: false,
serverId: 'srv-1',
capabilities: {
localLibrary: false,
favorites: false,
playlists: false,
manualPins: false,
playerStats: false,
},
hasBrowseCapability: false,
hasBrowsingContent: false,
connStatus: 'connected' as const,
}),
}));
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, makeServer, seedQueue } from '@/test/helpers/factories';
import { onInvoke } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
function setUpActiveServer(): ServerProfile {
const server = makeServer();
const id = useAuthStore.getState().addServer({
name: server.name, url: server.url, username: server.username, password: server.password,
});
useAuthStore.getState().setActiveServer(id);
useAuthStore.getState().setLoggedIn(true);
return { ...server, id };
}
function openMenuFor(type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', item: unknown, queueIndex?: number): void {
usePlayerStore.getState().openContextMenu(100, 100, item as never, type, queueIndex);
}
beforeEach(() => {
resetAllStores();
setUpActiveServer();
// Several menu actions invoke playback / engine commands — stub the common ones.
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('audio_set_normalization', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('frontend_debug_log', () => undefined);
});
afterEach(() => {
// Close any open menu so the next test starts clean.
usePlayerStore.getState().closeContextMenu();
});
describe('ContextMenu — visibility', () => {
it('renders nothing when the menu is closed', () => {
const { container } = renderWithProviders(<ContextMenu />);
// No items, no portal.
expect(container.querySelector('.context-menu')).toBeNull();
});
it('renders the menu when openContextMenu has run', () => {
openMenuFor('song', makeTrack());
const { container } = renderWithProviders(<ContextMenu />);
expect(container.querySelector('.context-menu')).not.toBeNull();
});
it('closeContextMenu hides the rendered menu on the next render', () => {
openMenuFor('song', makeTrack());
const { container, rerender } = renderWithProviders(<ContextMenu />);
expect(container.querySelector('.context-menu')).not.toBeNull();
usePlayerStore.getState().closeContextMenu();
rerender(<ContextMenu />);
expect(container.querySelector('.context-menu')).toBeNull();
});
});
describe('ContextMenu — type=song', () => {
it('shows Play Now / Play Next / Add to Queue items', () => {
openMenuFor('song', makeTrack({ id: 'tr-1' }));
const { getByText } = renderWithProviders(<ContextMenu />);
expect(getByText('Play Now')).toBeInTheDocument();
expect(getByText('Play Next')).toBeInTheDocument();
expect(getByText('Add to Queue')).toBeInTheDocument();
});
it('"Play Next" click calls playerStore.playNext with the song', () => {
const track = makeTrack({ id: 'tr-pn' });
const playNextSpy = vi.spyOn(usePlayerStore.getState(), 'playNext');
openMenuFor('song', track);
const { getByText } = renderWithProviders(<ContextMenu />);
fireEvent.click(getByText('Play Next'));
expect(playNextSpy).toHaveBeenCalledTimes(1);
expect(playNextSpy.mock.calls[0]?.[0]).toHaveLength(1);
expect(playNextSpy.mock.calls[0]?.[0][0].id).toBe('tr-pn');
});
it('"Add to Queue" click calls playerStore.enqueue', () => {
const track = makeTrack({ id: 'tr-eq' });
const enqueueSpy = vi.spyOn(usePlayerStore.getState(), 'enqueue');
openMenuFor('song', track);
const { getByText } = renderWithProviders(<ContextMenu />);
fireEvent.click(getByText('Add to Queue'));
expect(enqueueSpy).toHaveBeenCalled();
expect(enqueueSpy.mock.calls[0]?.[0]?.[0]?.id).toBe('tr-eq');
});
it('selecting any action closes the menu', () => {
const track = makeTrack();
openMenuFor('song', track);
const { getByText } = renderWithProviders(<ContextMenu />);
fireEvent.click(getByText('Play Next'));
expect(usePlayerStore.getState().contextMenu.isOpen).toBe(false);
});
});
describe('ContextMenu — type=album', () => {
it('shows the album surface (Open Album / Play Next / Enqueue Album / Go to Artist)', () => {
openMenuFor('album', {
id: 'al-1', name: 'Album', artist: 'Artist', artistId: 'ar-1',
songCount: 5, duration: 1200, year: 2024,
});
const { getByText } = renderWithProviders(<ContextMenu />);
expect(getByText('Open Album')).toBeInTheDocument();
expect(getByText('Play Next')).toBeInTheDocument();
expect(getByText('Enqueue Album')).toBeInTheDocument();
expect(getByText('Go to Artist')).toBeInTheDocument();
});
});
describe('ContextMenu — type=artist', () => {
it('shows the artist menu surface (Start Radio + share-link affordances)', () => {
openMenuFor('artist', {
id: 'ar-1', name: 'Artist', albumCount: 3,
});
const { container } = renderWithProviders(<ContextMenu />);
expect(container.querySelector('.context-menu')).not.toBeNull();
expect(container.textContent).toMatch(/Start Radio/i);
expect(container.textContent).toMatch(/share/i);
});
});
describe('ContextMenu — type=queue-item', () => {
it('shows a Remove from Queue affordance the song menu does not have', () => {
const track = makeTrack({ id: 'q-1' });
seedQueue([track], { index: 0, currentTrack: track });
openMenuFor('queue-item', track, 0);
const { container } = renderWithProviders(<ContextMenu />);
expect(container.querySelector('.context-menu')).not.toBeNull();
// The Remove option's i18n key (queue.removeFromQueue) ends up rendered;
// assert *something* queue-flavoured appears (we don't pin the exact
// wording so a translation tweak doesn't flip the test).
expect(container.textContent).toMatch(/remove/i);
});
});
describe('ContextMenu — Escape closes', () => {
it('Escape on the menu closes it', () => {
openMenuFor('song', makeTrack());
const { container } = renderWithProviders(<ContextMenu />);
const menu = container.querySelector('.context-menu') as HTMLElement;
expect(menu).not.toBeNull();
fireEvent.keyDown(menu, { key: 'Escape' });
expect(usePlayerStore.getState().contextMenu.isOpen).toBe(false);
});
});
@@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { Track } from '@/lib/media/trackTypes';
import { useOrbitStore } from '@/features/orbit';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import { useTranslation } from 'react-i18next';
import type { EntityShareKind } from '@/utils/share/shareLink';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
import {
copyShareLink as copyShareLinkAction,
downloadAlbum as downloadAlbumAction,
startInstantMix as startInstantMixAction,
startRadio as startRadioAction,
} from '@/utils/componentHelpers/contextMenuActions';
import { useContextMenuKeyboardNav } from '@/hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '@/hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
import { useNavigate } from 'react-router-dom';
import { useOfflineBrowseContext } from '@/features/offline';
import {
offlineActionPolicy,
type OfflineSurface,
} from '@/features/offline';
import ContextMenuItems from '@/features/contextMenu/components/ContextMenuItems';
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
switch (type) {
case 'album':
case 'multi-album':
return 'contextMenuAlbum';
case 'artist':
case 'multi-artist':
return 'contextMenuArtist';
case 'playlist':
case 'multi-playlist':
return 'contextMenuPlaylist';
default:
return 'contextMenuSong';
}
}
export { AddToPlaylistSubmenu };
export default function ContextMenu() {
const { t } = useTranslation();
const navigate = useNavigate();
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
const orbitRole = useOrbitStore(s => s.role);
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, networkLovedCache, setNetworkLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({
contextMenu: s.contextMenu,
closeContextMenu: s.closeContextMenu,
playTrack: s.playTrack,
enqueue: s.enqueue,
playNext: s.playNext,
queueItems: s.queueItems,
currentTrack: s.currentTrack,
removeTrack: s.removeTrack,
networkLovedCache: s.networkLovedCache,
setNetworkLovedForSong: s.setNetworkLovedForSong,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
openSongInfo: s.openSongInfo,
userRatingOverrides: s.userRatingOverrides,
setUserRatingOverride: s.setUserRatingOverride,
}))
);
const auth = useAuthStore();
const entityRatingSupport =
auth.activeServerId ? auth.entityRatingSupportByServer[auth.activeServerId] ?? 'unknown' : 'unknown';
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
const menuRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Adjusted coordinates to keep menu on screen
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false);
const [playlistSongIds, setPlaylistSongIds] = useState<string[]>([]);
const [keyboardRating, setKeyboardRating] = useState<{ kind: 'song' | 'album' | 'artist'; id: string; value: number } | null>(null);
const [pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus] = useState(false);
const playlistSubmenuCloseTimerRef = useRef<number | null>(null);
const cancelPlaylistSubmenuCloseTimer = useCallback(() => {
if (playlistSubmenuCloseTimerRef.current != null) {
window.clearTimeout(playlistSubmenuCloseTimerRef.current);
playlistSubmenuCloseTimerRef.current = null;
}
}, []);
/** Delay close so a slow move across subpixel / border seams still lands on `.context-submenu` (a child of the row). */
const onPlaylistSubmenuTriggerMouseLeave = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
const cur = e.currentTarget;
const next = e.relatedTarget;
if (next instanceof Node && cur.contains(next)) return;
cancelPlaylistSubmenuCloseTimer();
playlistSubmenuCloseTimerRef.current = window.setTimeout(() => {
playlistSubmenuCloseTimerRef.current = null;
if (!cur.isConnected) return;
if (!cur.matches(':hover')) setPlaylistSubmenuOpen(false);
}, 140);
},
[cancelPlaylistSubmenuCloseTimer],
);
useEffect(() => {
if (contextMenu.isOpen) {
cancelPlaylistSubmenuCloseTimer();
// React Compiler set-state-in-effect rule: local coords synced from the store's contextMenu position when the menu opens.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCoords({ x: contextMenu.x, y: contextMenu.y });
setPlaylistSubmenuOpen(false);
setPlaylistSongIds([]);
setKeyboardRating(null);
setPendingSubmenuKeyboardFocus(false);
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y, cancelPlaylistSubmenuCloseTimer]);
useEffect(() => {
if (contextMenu.isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const winW = window.innerWidth;
const winH = window.innerHeight;
let finalX = contextMenu.x;
let finalY = contextMenu.y;
if (finalX + rect.width > winW) finalX = winW - rect.width - 10;
if (finalY + rect.height > winH) finalY = winH - rect.height - 10;
setCoords({ x: finalX, y: finalY });
}
}, [contextMenu.isOpen, contextMenu.x, contextMenu.y]);
// Close on any window resize. The menu is absolutely positioned at fixed
// coordinates, so a resize would otherwise leave it stranded and drifting
// off-screen. Whether a resize closed the menu was inconsistent across
// setups (it stayed open on some Windows and Linux environments); always
// closing it here makes the behaviour the same everywhere.
useEffect(() => {
if (!contextMenu.isOpen) return;
const onResize = () => closeContextMenu();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [contextMenu.isOpen, closeContextMenu]);
useEffect(() => {
if (contextMenu.isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement | null;
return;
}
cancelPlaylistSubmenuCloseTimer();
// Clean up any keyboard focus styling when menu closes
menuRef.current
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
.forEach(el => el.classList.remove('context-menu-keyboard-active'));
const prev = previousFocusRef.current;
previousFocusRef.current = null;
if (prev?.isConnected) {
requestAnimationFrame(() => {
prev.focus({ preventScroll: true });
});
}
}, [contextMenu.isOpen, closeContextMenu, cancelPlaylistSubmenuCloseTimer]);
const {
type,
item,
queueIndex,
playlistId,
playlistSongIndex,
shareKindOverride,
pinToPlaybackServer = false,
} = contextMenu;
const navigateLibrary = pinToPlaybackServer
? navigatePlaybackLibrary
: (path: string) => { navigate(path); };
const isStarred = (id: string, itemStarred?: string) =>
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
const { applySongRating, applyAlbumRating, applyArtistRating, getRatingValueByKind, commitRatingByKind } =
useContextMenuRating({ type, item, userRatingOverrides, setUserRatingOverride, entityRatingSupport, t });
const { onMenuKeyDown } = useContextMenuKeyboardNav({
menuRef,
isOpen: contextMenu.isOpen,
closeContextMenu,
keyboardRating,
setKeyboardRating,
getRatingValueByKind,
commitRatingByKind,
playlistSubmenuOpen,
setPlaylistSubmenuOpen,
setPlaylistSongIds,
pendingSubmenuKeyboardFocus,
setPendingSubmenuKeyboardFocus,
});
const handleAction = async (action: () => void | Promise<void>) => {
closeContextMenu();
await action();
};
const copyShareLink = useCallback(
(kind: EntityShareKind, id: string) => copyShareLinkAction(kind, id, t),
[t],
);
const startRadio = (artistId: string, artistName: string, seedTrack?: Track) =>
startRadioAction(artistId, artistName, playTrack, seedTrack);
const startInstantMix = (song: Track) => startInstantMixAction(song, t);
const downloadAlbum = downloadAlbumAction;
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const offlinePolicy = offlineActionPolicy(
contextMenuSurfaceForType(type),
offlineBrowseActive,
);
if (!contextMenu.isOpen || !contextMenu.item) return null;
return (
<>
<div
ref={menuRef}
className="context-menu animate-fade-in"
style={{ left: coords.x, top: coords.y }}
tabIndex={-1}
onKeyDown={onMenuKeyDown}
>
<ContextMenuItems
type={type}
item={item}
queueIndex={queueIndex}
playlistId={playlistId}
playlistSongIndex={playlistSongIndex}
shareKindOverride={shareKindOverride}
playTrack={playTrack}
playNext={playNext}
enqueue={enqueue}
removeTrack={removeTrack}
queue={queueItems}
currentTrack={currentTrack}
closeContextMenu={closeContextMenu}
starredOverrides={starredOverrides}
setStarredOverride={setStarredOverride}
networkLovedCache={networkLovedCache}
setNetworkLovedForSong={setNetworkLovedForSong}
openSongInfo={openSongInfo}
userRatingOverrides={userRatingOverrides}
setKeyboardRating={setKeyboardRating}
keyboardRating={keyboardRating}
playlistSubmenuOpen={playlistSubmenuOpen}
setPlaylistSubmenuOpen={setPlaylistSubmenuOpen}
cancelPlaylistSubmenuCloseTimer={cancelPlaylistSubmenuCloseTimer}
onPlaylistSubmenuTriggerMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
playlistSongIds={playlistSongIds}
setPlaylistSongIds={setPlaylistSongIds}
orbitRole={orbitRole}
entityRatingSupport={entityRatingSupport}
audiomuseNavidromeEnabled={audiomuseNavidromeEnabled}
applySongRating={applySongRating}
applyAlbumRating={applyAlbumRating}
applyArtistRating={applyArtistRating}
handleAction={handleAction}
startRadio={startRadio}
startInstantMix={startInstantMix}
downloadAlbum={downloadAlbum}
copyShareLink={copyShareLink}
isStarred={isStarred}
pinToPlaybackServer={pinToPlaybackServer}
navigateLibrary={navigateLibrary}
offlinePolicy={offlinePolicy}
/>
</div>
</>
);
}
@@ -0,0 +1,30 @@
import React from 'react';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
import SongContextItems from '@/features/contextMenu/components/SongContextItems';
import QueueItemContextItems from '@/features/contextMenu/components/QueueItemContextItems';
import AlbumContextItems from '@/features/contextMenu/components/AlbumContextItems';
import ArtistContextItems from '@/features/contextMenu/components/ArtistContextItems';
import PlaylistContextItems from '@/features/contextMenu/components/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,111 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, Folder, FolderMinus, Plus } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { EMPTY_SERVER_FOLDERS, usePlaylistFolderStore } from '@/features/playlist';
interface Props {
playlistId: string;
onDone: () => void;
triggerId?: string;
}
/**
* Submenu for assigning a single playlist to a (local) folder. Mirrors the
* "Add to playlist" submenu's layout/positioning so it shares the context-menu
* hover machinery. Folder assignment is purely local state, so it stays
* available offline.
*/
export default function MoveToFolderSubmenu({ playlistId, onDone, triggerId }: Props) {
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 serverId = useAuthStore(s => s.activeServerId);
const bucket =
usePlaylistFolderStore(s => (serverId ? s.byServer[serverId] : undefined)) ?? EMPTY_SERVER_FOLDERS;
const createFolder = usePlaylistFolderStore(s => s.createFolder);
const setPlaylistFolder = usePlaylistFolderStore(s => s.setPlaylistFolder);
const currentFolderId = bucket.assignments[playlistId];
const folders = useMemo(
() => [...bucket.folders].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)),
[bucket.folders],
);
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 assign = (folderId: string | null) => {
if (!serverId) return;
setPlaylistFolder(serverId, playlistId, folderId);
onDone();
};
const handleCreate = () => {
const name = newName.trim();
if (!name || !serverId) return;
const id = createFolder(serverId, name);
setPlaylistFolder(serverId, playlistId, id);
setCreating(false);
setNewName('');
onDone();
};
const subStyle: React.CSSProperties = flipLeft
? { right: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', 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.folders.newFolder')}
</div>
) : (
<div className="context-submenu-create" onClick={e => e.stopPropagation()}>
<input
ref={newNameRef}
className="context-submenu-input"
placeholder={t('playlists.folders.namePlaceholder')}
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" />
{currentFolderId != null && (
<div className="context-menu-item" onClick={() => assign(null)}>
<FolderMinus size={13} /> {t('playlists.folders.removeFromFolder')}
</div>
)}
{folders.map(f => (
<div key={f.id} className="context-menu-item" onClick={() => assign(f.id)}>
<Folder size={13} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{f.name}</span>
{currentFolderId === f.id && <Check size={13} style={{ marginLeft: 'auto' }} />}
</div>
))}
</div>
);
}
@@ -0,0 +1,204 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { showToast } from '@/utils/ui/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/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(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalAlbums(albumIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
const serverId = resolveMediaServerId();
const albumSongs = serverId
? await Promise.all(albumIds.map(id => resolveAlbum(serverId, 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 { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
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('@/lib/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: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', 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;
// React Compiler rule: component intentionally defined inline for closure access.
// eslint-disable-next-line react-hooks/static-components
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
@@ -0,0 +1,218 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { getPlaylists } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { showToast } from '@/utils/ui/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/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(() => {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTotalArtists(artistIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
const allSongs: string[] = [];
const serverId = resolveMediaServerId();
if (!serverId) {
setResolvedIds([]);
return;
}
for (const artistId of artistIds) {
try {
const artistData = await resolveArtist(serverId, artistId);
if (!artistData) continue;
const albumSongs = await Promise.all(
artistData.albums.map(a => resolveAlbum(serverId, 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 { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
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('@/lib/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: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', 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;
// React Compiler rule: component intentionally defined inline for closure access.
// eslint-disable-next-line react-hooks/static-components
return <MultiAddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
}
@@ -0,0 +1,145 @@
import { useTranslation } from 'react-i18next';
import { Play, ChevronRight, FolderTree, ListMusic, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { MultiPlaylistToPlaylistSubmenu, SinglePlaylistToPlaylistSubmenu } from '@/features/contextMenu/components/PlaylistToPlaylistSubmenus';
import MoveToFolderSubmenu from '@/features/contextMenu/components/MoveToFolderSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
export default function PlaylistContextItems(props: ContextMenuItemsProps) {
const {
type, item, closeContextMenu,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
handleAction,
offlinePolicy,
} = props;
const { t } = useTranslation();
return (
<>
{type === 'playlist' && (() => {
const playlist = item as SubsonicPlaylist;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const { playPlaylistById } = await import('@/features/playlist');
try {
await playPlaylistById(playlist.id);
} catch {
// Network/load failure — leave playback untouched rather than crash.
}
})}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-divider" />
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`playlist:${playlist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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>
)}
{/* Folder assignment is local-only state, so it stays available offline. */}
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`folder:${playlist.id}`}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`folder:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<FolderTree size={14} /> {t('playlists.folders.moveToFolder')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `folder:${playlist.id}` && (
<MoveToFolderSubmenu playlistId={playlist.id} triggerId={`folder:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{offlinePolicy.canEditPlaylist && (
<>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('@/utils/ui/toast');
const { deletePlaylist } = await import('@/lib/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" />
{offlinePolicy.canAddToPlaylist && (
<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={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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>
)}
{offlinePolicy.canEditPlaylist && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('@/utils/ui/toast');
const { deletePlaylist } = await import('@/lib/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 '@/features/playlist';
import { showToast } from '@/utils/ui/toast';
import { isSmartPlaylistName } from '@/utils/componentHelpers/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('@/lib/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('@/lib/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('@/lib/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: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', 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('@/lib/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('@/lib/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('@/lib/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: '100%', left: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }
: { left: '100%', 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 { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { renderPresetIcon } from '@/components/settings/musicNetwork/presetIcon';
import StarRating from '@/ui/StarRating';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
export default function QueueItemContextItems(props: ContextMenuItemsProps) {
const {
type, item, queueIndex,
playTrack, removeTrack, closeContextMenu,
networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
audiomuseNavidromeEnabled,
applySongRating,
handleAction, startRadio, startInstantMix, copyShareLink, isStarred,
navigateLibrary,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
const networkPrimary = useEnrichmentPrimary();
const networkLabel = networkPrimary?.label ?? '';
const networkIcon = networkPrimary?.icon ?? 'custom';
return (
<>
{type === 'queue-item' && (() => {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, undefined, 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={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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(() => navigateLibrary(`/album/${song.albumId}`))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
{song.artistId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateLibrary(`/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(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
{auth.enrichmentPrimaryId !== null && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = networkLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setNetworkLovedForSong(song.title, song.artist, newLoved);
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
})}>
{renderPresetIcon(networkIcon, 14)}
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
</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,339 @@
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 { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistStore } from '@/features/playlist';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/utils/ui/toast';
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '@/features/orbit';
import { renderPresetIcon } from '@/components/settings/musicNetwork/presetIcon';
import StarRating from '@/ui/StarRating';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/AddToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
export default function SongContextItems(props: ContextMenuItemsProps) {
const {
type, item, playlistId, playlistSongIndex,
playTrack, playNext, enqueue, closeContextMenu,
networkLovedCache, setNetworkLovedForSong,
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
playlistSongIds, setPlaylistSongIds,
orbitRole, audiomuseNavidromeEnabled,
applySongRating,
handleAction, startRadio, startInstantMix, copyShareLink, isStarred,
offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
const networkPrimary = useEnrichmentPrimary();
const networkLabel = networkPrimary?.label ?? '';
const networkIcon = networkPrimary?.icon ?? 'custom';
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
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>
)}
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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 serverId = resolveMediaServerId(song.serverId);
if (!serverId || !song.albumId) return;
const albumData = await resolveAlbum(serverId, song.albumId);
if (!albumData) return;
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(() => navigateToAlbum(song.albumId!))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
{song.artistId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToArtist(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>
)}
{offlinePolicy.canFavorite && (
<div className="context-menu-item" onClick={() => handleAction(() => {
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
})}>
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
)}
{auth.enrichmentPrimaryId !== null && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = networkLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setNetworkLovedForSong(song.title, song.artist, newLoved);
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
})}>
{renderPresetIcon(networkIcon, 14)}
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
</div>
);
})()}
{offlinePolicy.canRate && (
<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>
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const { showToast } = await import('@/utils/ui/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, playlistId);
if (!resolved) return;
const { songs } = resolved;
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>
)}
{offlinePolicy.canAddToPlaylist && (
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
>
<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(() => navigateToAlbum(song.albumId!))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
{song.artistId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToArtist(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.enrichmentPrimaryId !== null && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = networkLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setNetworkLovedForSong(song.title, song.artist, newLoved);
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
})}>
{renderPresetIcon(networkIcon, 14)}
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
</div>
);
})()}
{offlinePolicy.canRate && (
<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>
{offlinePolicy.canFavorite && (
<>
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
queueSongStar(song.id, false, song.serverId);
})}>
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
</div>
</>
)}
</>
);
})()}
</>
);
}
@@ -0,0 +1,61 @@
import type React from 'react';
import type { SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import type { EntityShareKind } from '@/utils/share/shareLink';
import type { OfflineActionPolicy } from '@/features/offline';
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;
/** Thin-state: the canonical queue refs. The queue-item "Play now" action uses
* the row's `queueIndex` to jump in place — no full Track[] needed. */
queue: QueueItemRef[];
currentTrack: Track | null;
closeContextMenu: () => void;
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
networkLovedCache: Record<string, boolean>;
setNetworkLovedForSong: (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>>;
cancelPlaylistSubmenuCloseTimer: () => void;
onPlaylistSubmenuTriggerMouseLeave: (e: React.MouseEvent<HTMLElement>) => void;
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;
/** When true, album/artist links switch to the queue server before routing. */
pinToPlaybackServer: boolean;
navigateLibrary: (path: string) => void | Promise<void>;
offlinePolicy: OfflineActionPolicy;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Context-menu feature — the rendered ContextMenu plus the per-entity menu-item
* builders (album/artist/playlist/song/queue items + add-to-playlist /
* move-to-folder submenus). ContextMenu is deep-imported by its consumers
* (app shell + favorites/album/playlist surfaces); the item builders are
* internal to the subsystem, so nothing is re-exported here.
*/
export {};
@@ -5,7 +5,7 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useSelectionStore } from '@/store/selectionStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu';
import GenreFilterBar from '@/ui/GenreFilterBar';
interface Props {
@@ -7,7 +7,7 @@ import { songToTrack } from '@/lib/media/songToTrack';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu';
function PlaylistSearchResultThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
return (
@@ -19,7 +19,7 @@ import { useDragDrop } from '@/lib/dnd/DragDropContext';
import { useOrbitSongRowBehavior } from '@/features/orbit';
import { songToTrack } from '@/lib/media/songToTrack';
import type { PlaylistSortKey, PlaylistSortDir } from '@/features/playlist/utils/playlistDisplayedSongs';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu';
const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
@@ -0,0 +1,232 @@
import { type ReactNode } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '@/../package.json';
import { formatBytes } from '@/lib/format/formatBytes';
import { useAppUpdater } from '@/hooks/useAppUpdater';
import Modal from '@/components/Modal';
import Changelog from '@/features/updater/components/Changelog';
export default function AppUpdater() {
const { t } = useTranslation();
const {
release, dismissed, setDismissed, changelogOpen, setChangelogOpen,
dlState, dlProgress, dlError, countdown,
asset, showAurHint, showWingetHint, useTauriUpdater, showInstallBtn, pct,
handleSkip, handleRestartNow, handleDownload, handleShowFolder,
} = useAppUpdater();
if (!release || dismissed) return null;
// Footer actions — state-dependent. Downloading has no actions (no footer).
// When there is no in-app install (AUR / from-source), "Remind me later" is the
// primary action, so it gets the accent button; Skip stays a clear button.
let footer: ReactNode = null;
if (dlState === 'idle') {
footer = (
<>
<button className="btn btn-surface" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button
className={`btn ${showInstallBtn ? 'btn-surface' : 'btn-primary'}`}
onClick={() => setDismissed(true)}
>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
)}
</>
);
} else if (dlState === 'done' && useTauriUpdater) {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
);
} else if (dlState === 'done') {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
);
} else if (dlState === 'error') {
footer = (
<>
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
);
}
return (
<Modal
open
onClose={() => setDismissed(true)}
icon={<ArrowUpCircle size={18} />}
title={t('common.updaterModalTitle')}
subtitle={<>v{currentVersion} <strong>v{release.version}</strong></>}
closeLabel={t('common.updaterRemindBtn')}
footer={footer}
>
{/* Collapsible changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
)}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'idle' && showWingetHint && (
<div className="update-modal-winget">
<div className="update-modal-winget-title">{t('common.updaterWingetHint')}</div>
<code className="update-modal-winget-cmd">winget upgrade Psysonic</code>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,38 @@
import React from 'react';
// Minimal inline-markdown renderer (bold, italic, code)
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (!part) return null;
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
/** Renders a GitHub release body as themed changelog markup. */
export default function Changelog({ body }: { body: string }) {
return (
<>
{body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('## '))
return null; // skip nested release headers in body
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Updater feature — the in-app update checker/notification (AppUpdater) and its
* changelog display. Mounted by the app shell; deep-imported, not re-exported.
*/
export {};