From bb0fe828bff5d60082d2f7ba2cf0bd03d8f12bf6 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 17:26:05 +0200 Subject: [PATCH] =?UTF-8?q?refactor(artist-detail):=20G.85=20=E2=80=94=20e?= =?UTF-8?q?xtract=20Hero=20+=20TopTracks=20+=20SimilarArtists=20+=20action?= =?UTF-8?q?=20utilities=20(cluster)=20(#652)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../artistDetail/ArtistDetailHero.tsx | 250 ++++++++++ .../ArtistDetailSimilarArtists.tsx | 66 +++ .../artistDetail/ArtistDetailTopTracks.tsx | 111 +++++ src/pages/ArtistDetail.tsx | 426 +++--------------- src/utils/runArtistDetailActions.ts | 115 +++++ 5 files changed, 607 insertions(+), 361 deletions(-) create mode 100644 src/components/artistDetail/ArtistDetailHero.tsx create mode 100644 src/components/artistDetail/ArtistDetailSimilarArtists.tsx create mode 100644 src/components/artistDetail/ArtistDetailTopTracks.tsx create mode 100644 src/utils/runArtistDetailActions.ts diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx new file mode 100644 index 00000000..ce61f529 --- /dev/null +++ b/src/components/artistDetail/ArtistDetailHero.tsx @@ -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; + toggleStar: () => Promise; + handlePlayAll: () => void; + handleShuffle: () => void; + handleStartRadio: () => void; + handleShareArtist: () => void; + handleImageUpload: (e: React.ChangeEvent) => Promise; + 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>; + avatarGlow: string; + setAvatarGlow: React.Dispatch>; + lightboxOpen: boolean; + setLightboxOpen: React.Dispatch>; +} + +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(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 ( + <> + + + {lightboxOpen && ( + setLightboxOpen(false)} + /> + )} + +
+
+ {coverId ? ( + + ) : ( + + )} + {/* Upload overlay */} +
{ e.stopPropagation(); imageInputRef.current?.click(); }} + > + {uploading + ? + : } +
+ +
+ +
+

+ {artist.name} +

+
+ {t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })} +
+ +
+ {t('entityRating.artistShort')} + +
+ +
+ {(info?.lastFmUrl || artist.name) && ( +
+ {info?.lastFmUrl && ( + + )} + +
+ )} + + +
+ +
+ {albums.length > 0 && ( + <> + + + + )} + + {id && artist && ( + + )} + {albums.length > 0 && (() => { + const progress = id ? bulkProgress[id] : undefined; + const isDone = progress && progress.done === progress.total; + const isDownloading = progress && !isDone; + return ( + + ); + })()} +
+
+
+ + ); +} diff --git a/src/components/artistDetail/ArtistDetailSimilarArtists.tsx b/src/components/artistDetail/ArtistDetailSimilarArtists.tsx new file mode 100644 index 00000000..83ab04db --- /dev/null +++ b/src/components/artistDetail/ArtistDetailSimilarArtists.tsx @@ -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>; +} + +export default function ArtistDetailSimilarArtists({ + marginTop, showAudiomuseSimilar, showLastfmSimilar, + similarLoading, similarArtists, serverSimilarArtists, + similarCollapsed, setSimilarCollapsed, +}: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + + return ( + +
+

+ {t('artistDetail.similarArtists')} +

+ {isMobile && (() => { + const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists; + return list.length > 5 ? ( + + ) : null; + })()} +
+ {showLastfmSimilar && similarLoading ? ( +
+
+ {t('artistDetail.loading')} +
+ ) : ( +
+ {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists) + .slice(0, isMobile && similarCollapsed ? 5 : undefined) + .map((a, i) => ( + + ))} +
+ )} + + ); +} diff --git a/src/components/artistDetail/ArtistDetailTopTracks.tsx b/src/components/artistDetail/ArtistDetailTopTracks.tsx new file mode 100644 index 00000000..06421a4b --- /dev/null +++ b/src/components/artistDetail/ArtistDetailTopTracks.tsx @@ -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; +} + +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 ( + +

+ {t('artistDetail.topTracks')} +

+
+
+
#
+
{t('artistDetail.trackTitle')}
+
{t('artistDetail.trackAlbum')}
+
{t('artistDetail.trackDuration')}
+
+ {topSongs.map((song, idx) => { + const track = songToTrack(song); + return ( +
{ + 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'); + }} + > +
+ {currentTrack?.id === song.id && isPlaying ? ( + + ) : ( + {idx + 1} + )} +
+
+ + + {song.coverArt && ( + + )} +
+
{song.title}
+
+
+
+ {song.album} +
+
+ {formatDuration(song.duration)} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index a97b5b77..6c13612d 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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) => { - 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) => 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 (
- - - {lightboxOpen && ( - setLightboxOpen(false)} - /> - )} - -
-
- {coverId ? ( - - ) : ( - - )} - {/* Upload overlay */} -
{ e.stopPropagation(); imageInputRef.current?.click(); }} - > - {uploading - ? - : } -
- -
- -
-

- {artist.name} -

-
- {t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })} -
- -
- {t('entityRating.artistShort')} - -
- -
- {(info?.lastFmUrl || artist.name) && ( -
- {info?.lastFmUrl && ( - - )} - -
- )} - - -
- -
- {albums.length > 0 && ( - <> - - - - )} - - {id && artist && ( - - )} - {albums.length > 0 && (() => { - const progress = id ? bulkProgress[id] : undefined; - const isDone = progress && progress.done === progress.total; - const isDownloading = progress && !isDone; - return ( - - ); - })()} -
-
-
+ {/* 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 ( - -

- {t('artistDetail.topTracks')} -

-
-
-
#
-
{t('artistDetail.trackTitle')}
-
{t('artistDetail.trackAlbum')}
-
{t('artistDetail.trackDuration')}
-
- {topSongs.map((song, idx) => { - const track = songToTrack(song); - return ( -
{ - 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'); - }} - > -
- {currentTrack?.id === song.id && isPlaying ? ( - - ) : ( - {idx + 1} - )} -
-
- - - {song.coverArt && ( - - )} -
-
{song.title}
-
-
-
- {song.album} -
-
- {formatDuration(song.duration)} -
-
- ); - })} -
-
+ ); case 'similar': return ( - -
-

- {t('artistDetail.similarArtists')} -

- {isMobile && (() => { - const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists; - return list.length > 5 ? ( - - ) : null; - })()} -
- {showLastfmSimilar && similarLoading ? ( -
-
- {t('artistDetail.loading')} -
- ) : ( -
- {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists) - .slice(0, isMobile && similarCollapsed ? 5 : undefined) - .map((a, i) => ( - - ))} -
- )} - + ); case 'albums': return ( diff --git a/src/utils/runArtistDetailActions.ts b/src/utils/runArtistDetailActions.ts new file mode 100644 index 00000000..562edba6 --- /dev/null +++ b/src/utils/runArtistDetailActions.ts @@ -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>; +} + +export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise { + 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>; +} + +export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promise { + 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 { + 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; + artist: SubsonicArtist | null; + t: TFunction; + setUploading: (v: boolean) => void; + setCoverRevision: React.Dispatch>; +} + +export async function runArtistImageUpload(deps: RunArtistImageUploadDeps): Promise { + 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); + } +}