mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
refactor(artist-detail): G.85 — extract Hero + TopTracks + SimilarArtists + action utilities (cluster) (#652)
Four-cut cluster closing out the major ArtistDetail extraction. 707 → 339 LOC (−368). ArtistDetailHero — the full hero header: back button, lightbox trigger, avatar with hover upload overlay + camera/loader icon + hidden file input, glow effect from extractCoverColors onLoad, title + album count, entity-rating row, Last.fm + Wikipedia + favourite link row, and the action button strip (play all, shuffle, radio, share, offline cache with progress / done state). Subscribes to useOfflineStore / useOfflineJobStore / useAuthStore directly so the page doesn't have to thread bulk-progress through. ArtistDetailTopTracks — the four-column tracklist with each row's inline play-next + preview ring + track cover thumbnail + click into playTopSongWithContinuation. Subscribes to playerStore / previewStore / useOrbitSongRowBehavior directly. ArtistDetailSimilarArtists — section header (with show-more toggle on mobile), loading spinner, and the chip list (using serverSimilarArtists vs. similarArtists depending on which path fed it). runArtistDetailActions — four parameterized async actions: runArtistEntityRating (with full / track_only fallback + saveFailed toast), runArtistToggleStar (optimistic state + revert on error), runArtistShare (copy link + success / failure toast), runArtistImageUpload (upload + invalidate cover-art cache + bump revision). ArtistDetail drops the inline definitions; formatDuration moves into the TopTracks component. Pure code move otherwise.
This commit is contained in:
committed by
GitHub
parent
ba0bf8aa9d
commit
bb0fe828bf
@@ -0,0 +1,250 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Camera, Check, ExternalLink, HardDriveDownload, Heart,
|
||||
Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../../store/offlineJobStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { extractCoverColors } from '../../utils/dynamicColors';
|
||||
import CachedImage from '../CachedImage';
|
||||
import CoverLightbox from '../CoverLightbox';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
id: string | undefined;
|
||||
albums: SubsonicAlbum[];
|
||||
info: SubsonicArtistInfo | null;
|
||||
isStarred: boolean;
|
||||
artistEntityRating: number;
|
||||
handleArtistEntityRating: (rating: number) => Promise<void>;
|
||||
toggleStar: () => Promise<void>;
|
||||
handlePlayAll: () => void;
|
||||
handleShuffle: () => void;
|
||||
handleStartRadio: () => void;
|
||||
handleShareArtist: () => void;
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
playAllLoading: boolean;
|
||||
radioLoading: boolean;
|
||||
uploading: boolean;
|
||||
openedLink: string | null;
|
||||
openLink: (url: string, key: string) => void;
|
||||
coverId: string;
|
||||
artistCover300Src: string;
|
||||
artistCover300Key: string;
|
||||
artistCover2000Src: string;
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
avatarGlow: string;
|
||||
setAvatarGlow: React.Dispatch<React.SetStateAction<string>>;
|
||||
lightboxOpen: boolean;
|
||||
setLightboxOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, artistCover300Src, artistCover300Key, artistCover2000Src,
|
||||
coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
avatarGlow, setAvatarGlow, lightboxOpen, setLightboxOpen,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && (
|
||||
<CoverLightbox
|
||||
src={artistCover2000Src}
|
||||
alt={artist.name}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div
|
||||
className="artist-detail-avatar"
|
||||
style={{
|
||||
position: 'relative',
|
||||
boxShadow: avatarGlow ? `0 0 36px 8px ${avatarGlow.replace('rgb(', 'rgba(').replace(')', ', 0.55)')}` : undefined,
|
||||
transition: 'box-shadow 0.6s ease',
|
||||
}}
|
||||
>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
{!headerCoverFailed ? (
|
||||
<CachedImage
|
||||
key={coverRevision}
|
||||
src={artistCover300Src}
|
||||
cacheKey={artistCover300Key}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" style={{ margin: 'auto', display: 'block' }} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className="artist-avatar-upload-overlay"
|
||||
onClick={e => { e.stopPropagation(); imageInputRef.current?.click(); }}
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 size={22} className="spin-slow" />
|
||||
: <Camera size={22} />}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
||||
{artist.name}
|
||||
</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
||||
<LastfmIcon size={14} />
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<ExternalLink size={14} />
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffle}
|
||||
disabled={playAllLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.shuffle') : undefined}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && t('artistDetail.shuffle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleStartRadio}
|
||||
disabled={radioLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.radio') : undefined}
|
||||
>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
|
||||
</button>
|
||||
{id && artist && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
onClick={handleShareArtist}
|
||||
aria-label={t('artistDetail.shareArtist')}
|
||||
data-tooltip={t('artistDetail.shareArtist')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{!isMobile && (isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
|
||||
interface Props {
|
||||
marginTop: string;
|
||||
showAudiomuseSimilar: boolean;
|
||||
showLastfmSimilar: boolean;
|
||||
similarLoading: boolean;
|
||||
similarArtists: SubsonicArtist[];
|
||||
serverSimilarArtists: SubsonicArtist[];
|
||||
similarCollapsed: boolean;
|
||||
setSimilarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function ArtistDetailSimilarArtists({
|
||||
marginTop, showAudiomuseSimilar, showLastfmSimilar,
|
||||
similarLoading, similarArtists, serverSimilarArtists,
|
||||
similarCollapsed, setSimilarCollapsed,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop, marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{isMobile && (() => {
|
||||
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
|
||||
return list.length > 5 ? (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
|
||||
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
||||
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
||||
.map((a, i) => (
|
||||
<button
|
||||
key={`${a.id}-${i}`}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { usePreviewStore } from '../../store/previewStore';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/songToTrack';
|
||||
import { formatDuration } from '../../utils/artistDetailHelpers';
|
||||
import ArtistSuggestionTrackCover from './ArtistSuggestionTrackCover';
|
||||
|
||||
interface Props {
|
||||
topSongs: SubsonicSong[];
|
||||
marginTop: string;
|
||||
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArtistDetailTopTracks({ topSongs, marginTop, playTopSongWithContinuation }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<h2 className="section-title" style={{ marginTop, marginBottom: '1rem' }}>
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" data-preview-loc="artist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{topSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={`${song.id}-${idx}`}
|
||||
className="track-row track-row-with-actions"
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (orbitActive) { queueHint(); return; }
|
||||
playTopSongWithContinuation(idx);
|
||||
}}
|
||||
onDoubleClick={orbitActive ? e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addTrackToOrbit(song.id);
|
||||
} : undefined}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
|
||||
{currentTrack?.id === song.id && isPlaying ? (
|
||||
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
|
||||
) : (
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-info track-info-suggestion" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
{song.coverArt && (
|
||||
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div className="track-title">{song.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
|
||||
{song.album}
|
||||
</div>
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
+65
-361
@@ -28,13 +28,18 @@ import { extractCoverColors } from '../utils/dynamicColors';
|
||||
import StarRating from '../components/StarRating';
|
||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||
|
||||
import { formatDuration, sanitizeHtml } from '../utils/artistDetailHelpers';
|
||||
import ArtistSuggestionTrackCover from '../components/artistDetail/ArtistSuggestionTrackCover';
|
||||
import { sanitizeHtml } from '../utils/artistDetailHelpers';
|
||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||
import {
|
||||
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||
} from '../utils/runArtistDetailPlay';
|
||||
import {
|
||||
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
|
||||
} from '../utils/runArtistDetailActions';
|
||||
import ArtistDetailHero from '../components/artistDetail/ArtistDetailHero';
|
||||
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
|
||||
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
||||
|
||||
|
||||
export default function ArtistDetail() {
|
||||
@@ -95,28 +100,10 @@ export default function ArtistDetail() {
|
||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||
}, [id, artist?.id, artist?.userRating]);
|
||||
|
||||
const handleArtistEntityRating = async (rating: number) => {
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleArtistEntityRating = (rating: number) => runArtistEntityRating({
|
||||
artist, id, rating, artistEntityRatingSupport, activeServerId, t,
|
||||
setArtistEntityRating, setArtist,
|
||||
});
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
@@ -124,18 +111,7 @@ export default function ArtistDetail() {
|
||||
setTimeout(() => setOpenedLink(null), 2500);
|
||||
};
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(artist.id, 'artist');
|
||||
else await star(artist.id, 'artist');
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
};
|
||||
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
|
||||
|
||||
const handlePlayAll = () => runArtistDetailPlayAll({ albums, setPlayAllLoading, playTrack });
|
||||
const handleShuffle = () => runArtistDetailShuffle({ albums, setPlayAllLoading, playTrack });
|
||||
@@ -144,15 +120,9 @@ export default function ArtistDetail() {
|
||||
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||
};
|
||||
|
||||
const handleShareArtist = async () => {
|
||||
const handleShareArtist = () => {
|
||||
if (!id || !artist) return;
|
||||
try {
|
||||
const ok = await copyEntityShareLink('artist', artist.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
return runArtistShare({ artist, t });
|
||||
};
|
||||
|
||||
const playTopSongWithContinuation = async (startIndex: number) => {
|
||||
@@ -184,31 +154,9 @@ export default function ArtistDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !artist) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadArtistImage(artist.id, file);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
await invalidateCoverArt(coverId);
|
||||
// Also invalidate with bare artist.id in case coverArt differs
|
||||
if (artist.coverArt && artist.coverArt !== artist.id) {
|
||||
await invalidateCoverArt(artist.id);
|
||||
}
|
||||
setCoverRevision(r => r + 1);
|
||||
showToast(t('artistDetail.uploadImage'));
|
||||
} catch (err) {
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
|
||||
4000,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => runArtistImageUpload({
|
||||
e, artist, t, setUploading, setCoverRevision,
|
||||
});
|
||||
|
||||
// Cover URLs — must run every render (before early returns) or hook order breaks.
|
||||
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
||||
@@ -318,180 +266,37 @@ export default function ArtistDetail() {
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && (
|
||||
<CoverLightbox
|
||||
src={artistCover2000Src}
|
||||
alt={artist.name}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div
|
||||
className="artist-detail-avatar"
|
||||
style={{
|
||||
position: 'relative',
|
||||
boxShadow: avatarGlow ? `0 0 36px 8px ${avatarGlow.replace('rgb(', 'rgba(').replace(')', ', 0.55)')}` : undefined,
|
||||
transition: 'box-shadow 0.6s ease',
|
||||
}}
|
||||
>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
{!headerCoverFailed ? (
|
||||
<CachedImage
|
||||
key={coverRevision}
|
||||
src={artistCover300Src}
|
||||
cacheKey={artistCover300Key}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onLoad={e => extractCoverColors(e.currentTarget.src).then(({ accent }) => { if (accent) setAvatarGlow(accent); })}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" style={{ margin: 'auto', display: 'block' }} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className="artist-avatar-upload-overlay"
|
||||
onClick={e => { e.stopPropagation(); imageInputRef.current?.click(); }}
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 size={22} className="spin-slow" />
|
||||
: <Camera size={22} />}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
||||
{artist.name}
|
||||
</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
||||
<LastfmIcon size={14} />
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<ExternalLink size={14} />
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffle}
|
||||
disabled={playAllLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.shuffle') : undefined}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && t('artistDetail.shuffle')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleStartRadio}
|
||||
disabled={radioLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.radio') : undefined}
|
||||
>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
|
||||
</button>
|
||||
{id && artist && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
onClick={handleShareArtist}
|
||||
aria-label={t('artistDetail.shareArtist')}
|
||||
data-tooltip={t('artistDetail.shareArtist')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{!isMobile && (isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArtistDetailHero
|
||||
artist={artist}
|
||||
id={id}
|
||||
albums={albums}
|
||||
info={info}
|
||||
isStarred={isStarred}
|
||||
artistEntityRating={artistEntityRating}
|
||||
handleArtistEntityRating={handleArtistEntityRating}
|
||||
toggleStar={toggleStar}
|
||||
handlePlayAll={handlePlayAll}
|
||||
handleShuffle={handleShuffle}
|
||||
handleStartRadio={handleStartRadio}
|
||||
handleShareArtist={handleShareArtist}
|
||||
handleImageUpload={handleImageUpload}
|
||||
playAllLoading={playAllLoading}
|
||||
radioLoading={radioLoading}
|
||||
uploading={uploading}
|
||||
openedLink={openedLink}
|
||||
openLink={openLink}
|
||||
coverId={coverId}
|
||||
artistCover300Src={artistCover300Src}
|
||||
artistCover300Key={artistCover300Key}
|
||||
artistCover2000Src={artistCover2000Src}
|
||||
coverRevision={coverRevision}
|
||||
headerCoverFailed={headerCoverFailed}
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
avatarGlow={avatarGlow}
|
||||
setAvatarGlow={setAvatarGlow}
|
||||
lightboxOpen={lightboxOpen}
|
||||
setLightboxOpen={setLightboxOpen}
|
||||
/>
|
||||
|
||||
{/* User-reorderable sections — order + visibility configured in Settings.
|
||||
* Each case renders the same JSX it did pre-refactor; only `marginTop`
|
||||
@@ -530,127 +335,26 @@ export default function ArtistDetail() {
|
||||
);
|
||||
|
||||
case 'topTracks': return (
|
||||
<Fragment key="topTracks">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('topTracks'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" data-preview-loc="artist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{topSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={`${song.id}-${idx}`}
|
||||
className="track-row track-row-with-actions"
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (orbitActive) { queueHint(); return; }
|
||||
playTopSongWithContinuation(idx);
|
||||
}}
|
||||
onDoubleClick={orbitActive ? e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addTrackToOrbit(song.id);
|
||||
} : undefined}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
|
||||
{currentTrack?.id === song.id && isPlaying ? (
|
||||
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
|
||||
) : (
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-info track-info-suggestion" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
{song.coverArt && (
|
||||
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div className="track-title">{song.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
|
||||
{song.album}
|
||||
</div>
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
<ArtistDetailTopTracks
|
||||
key="topTracks"
|
||||
topSongs={topSongs}
|
||||
marginTop={sectionMt('topTracks')}
|
||||
playTopSongWithContinuation={playTopSongWithContinuation}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'similar': return (
|
||||
<Fragment key="similar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: sectionMt('similar'), marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{isMobile && (() => {
|
||||
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
|
||||
return list.length > 5 ? (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
|
||||
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
||||
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
||||
.map((a, i) => (
|
||||
<button
|
||||
key={`${a.id}-${i}`}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
<ArtistDetailSimilarArtists
|
||||
key="similar"
|
||||
marginTop={sectionMt('similar')}
|
||||
showAudiomuseSimilar={showAudiomuseSimilar}
|
||||
showLastfmSimilar={showLastfmSimilar}
|
||||
similarLoading={similarLoading}
|
||||
similarArtists={similarArtists}
|
||||
serverSimilarArtists={serverSimilarArtists}
|
||||
similarCollapsed={similarCollapsed}
|
||||
setSimilarCollapsed={setSimilarCollapsed}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'albums': return (
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { uploadArtistImage } from '../api/subsonicPlaylists';
|
||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { copyEntityShareLink } from './copyEntityShareLink';
|
||||
import { invalidateCoverArt } from './imageCache';
|
||||
import { showToast } from './toast';
|
||||
|
||||
export interface RunArtistEntityRatingDeps {
|
||||
artist: SubsonicArtist | null;
|
||||
id: string | undefined;
|
||||
rating: number;
|
||||
artistEntityRatingSupport: string;
|
||||
activeServerId: string;
|
||||
t: TFunction;
|
||||
setArtistEntityRating: (v: number) => void;
|
||||
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
|
||||
}
|
||||
|
||||
export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise<void> {
|
||||
const { artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist } = deps;
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
useAuthStore.getState().setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistToggleStarDeps {
|
||||
artist: SubsonicArtist | null;
|
||||
isStarred: boolean;
|
||||
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promise<void> {
|
||||
const { artist, isStarred, setIsStarred } = deps;
|
||||
if (!artist) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
if (currentlyStarred) await unstar(artist.id, 'artist');
|
||||
else await star(artist.id, 'artist');
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistShareDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export async function runArtistShare(deps: RunArtistShareDeps): Promise<void> {
|
||||
const { artist, t } = deps;
|
||||
try {
|
||||
const ok = await copyEntityShareLink('artist', artist.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistImageUploadDeps {
|
||||
e: React.ChangeEvent<HTMLInputElement>;
|
||||
artist: SubsonicArtist | null;
|
||||
t: TFunction;
|
||||
setUploading: (v: boolean) => void;
|
||||
setCoverRevision: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export async function runArtistImageUpload(deps: RunArtistImageUploadDeps): Promise<void> {
|
||||
const { e, artist, t, setUploading, setCoverRevision } = deps;
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !artist) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadArtistImage(artist.id, file);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
await invalidateCoverArt(coverId);
|
||||
// Also invalidate with bare artist.id in case coverArt differs
|
||||
if (artist.coverArt && artist.coverArt !== artist.id) {
|
||||
await invalidateCoverArt(artist.id);
|
||||
}
|
||||
setCoverRevision(r => r + 1);
|
||||
showToast(t('artistDetail.uploadImage'));
|
||||
} catch (err) {
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
|
||||
4000,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user