mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
f3a0b3f7af
* fix(artist-detail): keep ext-link border visible on hover Hover used --border-subtle, which on Catppuccin matches --bg-card and visually erased the rim while only the fill changed. Match btn-surface: --ctp-surface1 border, --ctp-overlay0 on hover. * docs(changelog): credit zunoz on Psysonic Discord for PR #966 * fix(playlists): tooltips on Play/Add Songs and song count pluralization Add data-tooltip to Play and Add Songs in playlist hero; switch playlists.songs to count-based _one/_other forms (was {{n}} without i18next plural suffix, breaking spacing and singular). * fix(playlists): render BPM and optional cols in Suggested Songs rows PlaylistSuggestions shared column headers with the main tracklist but its row switch omitted bpm, genre, playCount, and lastPlayed.
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
import React from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Camera, ChevronLeft, Download, FileUp, Globe, HardDriveDownload, ListPlus,
|
|
Loader2, Lock, Pencil, Play, Search, Shuffle, Sparkles, Trash2,
|
|
} from 'lucide-react';
|
|
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
|
import type { ZipDownload } from '../../store/zipDownloadStore';
|
|
import { useThemeStore } from '../../store/themeStore';
|
|
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
|
|
import {
|
|
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
|
|
} from '../../utils/componentHelpers/playlistDetailHelpers';
|
|
import type { CoverArtId } from '../../cover/types';
|
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
|
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
|
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
|
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
|
|
|
interface Props {
|
|
playlist: SubsonicPlaylist;
|
|
songs: SubsonicSong[];
|
|
id: string | undefined;
|
|
customCoverId: string | null;
|
|
coverQuadIds: (CoverArtId | null)[];
|
|
resolvedBgUrl: string | null;
|
|
saving: boolean;
|
|
searchOpen: boolean;
|
|
csvImporting: boolean;
|
|
activeZip: ZipDownload | undefined;
|
|
isCached: boolean;
|
|
isDownloading: boolean;
|
|
offlineProgress: { done: number; total: number } | null;
|
|
activeServerId: string;
|
|
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
|
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
|
setSelectedSearchIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
setSearchPlPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
handlePlayAll: () => void;
|
|
handleShuffleAll: () => void;
|
|
handleEnqueueAll: () => void;
|
|
handleImportCsv: () => void;
|
|
handleDownload: () => void;
|
|
deleteAlbum: (id: string, serverId: string) => void;
|
|
downloadPlaylist: (id: string, name: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => void;
|
|
}
|
|
|
|
export default function PlaylistHero({
|
|
playlist, songs, id,
|
|
customCoverId, coverQuadIds,
|
|
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
|
isCached, isDownloading, offlineProgress, activeServerId,
|
|
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
|
setSelectedSearchIds, setSearchPlPickerOpen,
|
|
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
|
deleteAlbum, downloadPlaylist,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
|
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
|
|
const layoutItems = usePlaylistLayoutStore(s => s.items);
|
|
const isLayoutVisible = (id: PlaylistLayoutItemId) =>
|
|
layoutItems.find(i => i.id === id)?.visible !== false;
|
|
|
|
return (
|
|
<div className="album-detail-header">
|
|
{resolvedBgUrl && enableCoverArtBackground && (
|
|
<>
|
|
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
|
|
<div className="album-detail-overlay" aria-hidden="true" />
|
|
</>
|
|
)}
|
|
|
|
<div className="album-detail-content">
|
|
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
|
|
<ChevronLeft size={16} /> {t('playlists.title')}
|
|
</button>
|
|
|
|
<div className="album-detail-hero">
|
|
{/* Cover — click to open edit modal */}
|
|
{enablePlaylistCoverPhoto && (
|
|
<div
|
|
className="playlist-hero-cover"
|
|
onClick={() => setEditingMeta(true)}
|
|
>
|
|
{customCoverId ? (
|
|
<AlbumCoverArtImage
|
|
albumId={customCoverId}
|
|
coverArt={customCoverId}
|
|
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
|
surface="dense"
|
|
alt=""
|
|
className="playlist-cover-grid"
|
|
style={{ objectFit: 'cover', display: 'block' }}
|
|
/>
|
|
) : (
|
|
<div className="playlist-cover-grid">
|
|
{coverQuadIds.map((coverId, i) =>
|
|
coverId
|
|
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
|
|
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="playlist-hero-cover-overlay">
|
|
<Camera size={28} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="album-detail-meta">
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
|
|
<span>{displayPlaylistName(playlist.name)}</span>
|
|
</h1>
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={() => setEditingMeta(true)}
|
|
data-tooltip={t('playlists.editMeta')}
|
|
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
</div>
|
|
{playlist.comment && (
|
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
|
)}
|
|
</>
|
|
<div className="album-detail-info">
|
|
<span>{t('playlists.songs', { count: songs.length })}</span>
|
|
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
|
|
{playlist.public !== undefined && (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
|
· {playlist.public
|
|
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
|
|
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
|
|
</span>
|
|
)}
|
|
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
|
|
</div>
|
|
<div className="album-detail-actions">
|
|
<div className="album-detail-actions-primary">
|
|
<button
|
|
className="btn btn-primary"
|
|
disabled={songs.length === 0}
|
|
onClick={handlePlayAll}
|
|
data-tooltip={t('playlists.playTooltip')}
|
|
>
|
|
<Play size={15} /> {t('common.play', 'Reproducir')}
|
|
</button>
|
|
<button
|
|
className="btn btn-ghost"
|
|
disabled={songs.length === 0}
|
|
onClick={handleShuffleAll}
|
|
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
|
>
|
|
<Shuffle size={16} />
|
|
</button>
|
|
<button
|
|
className="btn btn-ghost"
|
|
disabled={songs.length === 0}
|
|
onClick={handleEnqueueAll}
|
|
data-tooltip={t('playlists.addToQueue')}
|
|
>
|
|
<ListPlus size={16} />
|
|
</button>
|
|
</div>
|
|
{isLayoutVisible('addSongs') && (
|
|
<button
|
|
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
|
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
|
data-tooltip={t('playlists.addSongsTooltip')}
|
|
>
|
|
<Search size={16} /> {t('playlists.addSongs')}
|
|
</button>
|
|
)}
|
|
{isLayoutVisible('importCsv') && (
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={handleImportCsv}
|
|
disabled={csvImporting}
|
|
data-tooltip={t('playlists.importCSVTooltip')}
|
|
>
|
|
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
|
|
{t('playlists.importCSV')}
|
|
</button>
|
|
)}
|
|
{isLayoutVisible('downloadZip') && songs.length > 0 && (
|
|
activeZip && !activeZip.done && !activeZip.error ? (
|
|
<div className="download-progress-wrap">
|
|
<Download size={14} />
|
|
<div className="download-progress-bar">
|
|
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
|
|
</div>
|
|
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
|
|
</div>
|
|
) : (
|
|
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
|
|
<Download size={16} /> {t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''}
|
|
</button>
|
|
)
|
|
)}
|
|
{isLayoutVisible('offlineCache') && songs.length > 0 && id && (
|
|
<button
|
|
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
|
disabled={isDownloading}
|
|
onClick={() => {
|
|
if (isCached) {
|
|
deleteAlbum(id, activeServerId);
|
|
} else if (playlist) {
|
|
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
|
|
}
|
|
}}
|
|
data-tooltip={isDownloading
|
|
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
|
|
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
|
|
>
|
|
{isDownloading ? (
|
|
<>
|
|
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
|
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
|
|
</>
|
|
) : isCached ? (
|
|
<>
|
|
<Trash2 size={16} />
|
|
{t('playlists.removeOffline')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<HardDriveDownload size={16} />
|
|
{t('playlists.cacheOffline')}
|
|
</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|