mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(updater+contextMenu): co-locate updater feature (AppUpdater+Changelog) and the context-menu subsystem into features/
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 {};
|
||||
Reference in New Issue
Block a user