diff --git a/src/locales/de.ts b/src/locales/de.ts
index e0f5a298..0258793a 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -658,6 +658,14 @@ export const deTranslation = {
homeCustomizerTitle: 'Startseite',
sidebarTitle: 'Seitenleiste',
sidebarReset: 'Zurücksetzen',
+ artistLayoutTitle: 'Künstlerseiten-Abschnitte',
+ artistLayoutDesc: 'Per Drag & Drop neu anordnen, einzelne Abschnitte der Künstlerseite ein- oder ausblenden. Abschnitte ohne Daten werden automatisch übersprungen.',
+ artistLayoutReset: 'Zurücksetzen',
+ artistLayoutBio: 'Künstler-Biografie',
+ artistLayoutTopTracks: 'Top-Tracks',
+ artistLayoutSimilar: 'Ähnliche Künstler*innen',
+ artistLayoutAlbums: 'Alben',
+ artistLayoutFeatured: 'Auch enthalten auf',
sidebarDrag: 'Ziehen zum Umsortieren',
sidebarFixed: 'Immer sichtbar',
randomNavSplitTitle: 'Mix-Navigation aufteilen',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index c49c200d..4dc93ead 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -660,6 +660,14 @@ export const enTranslation = {
homeCustomizerTitle: 'Home Page',
sidebarTitle: 'Sidebar',
sidebarReset: 'Reset to default',
+ artistLayoutTitle: 'Artist page sections',
+ artistLayoutDesc: 'Drag to reorder, toggle to hide individual sections of the artist page. Sections without data are skipped automatically.',
+ artistLayoutReset: 'Reset to default',
+ artistLayoutBio: 'Artist biography',
+ artistLayoutTopTracks: 'Top tracks',
+ artistLayoutSimilar: 'Similar artists',
+ artistLayoutAlbums: 'Albums',
+ artistLayoutFeatured: 'Also featured on',
sidebarDrag: 'Drag to reorder',
sidebarFixed: 'Always visible',
randomNavSplitTitle: 'Split Mix navigation',
diff --git a/src/locales/es.ts b/src/locales/es.ts
index d39073fb..98d8b944 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -651,6 +651,14 @@ export const esTranslation = {
homeCustomizerTitle: 'Página de Inicio',
sidebarTitle: 'Barra Lateral',
sidebarReset: 'Restablecer a predeterminado',
+ artistLayoutTitle: 'Secciones de la página del artista',
+ artistLayoutDesc: 'Arrastra para reordenar, alterna para ocultar secciones individuales de la página del artista. Las secciones sin datos se omiten automáticamente.',
+ artistLayoutReset: 'Restablecer a predeterminado',
+ artistLayoutBio: 'Biografía del artista',
+ artistLayoutTopTracks: 'Mejores pistas',
+ artistLayoutSimilar: 'Artistas similares',
+ artistLayoutAlbums: 'Álbumes',
+ artistLayoutFeatured: 'También presente en',
sidebarDrag: 'Arrastra para reordenar',
sidebarFixed: 'Siempre visible',
randomNavSplitTitle: 'Dividir navegación Mix',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index b3816b2b..7c4112ca 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -646,6 +646,14 @@ export const frTranslation = {
homeCustomizerTitle: 'Page d\'accueil',
sidebarTitle: 'Barre latérale',
sidebarReset: 'Réinitialiser',
+ artistLayoutTitle: 'Sections de la page artiste',
+ artistLayoutDesc: 'Glissez pour réorganiser, basculez pour masquer des sections individuelles de la page artiste. Les sections sans données sont ignorées automatiquement.',
+ artistLayoutReset: 'Réinitialiser',
+ artistLayoutBio: 'Biographie de l\'artiste',
+ artistLayoutTopTracks: 'Titres populaires',
+ artistLayoutSimilar: 'Artistes similaires',
+ artistLayoutAlbums: 'Albums',
+ artistLayoutFeatured: 'Apparaît aussi sur',
sidebarDrag: 'Glisser pour réorganiser',
sidebarFixed: 'Toujours visible',
randomNavSplitTitle: 'Diviser la navigation Mix',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index 9f6399f7..363b232e 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -645,6 +645,14 @@ export const nbTranslation = {
homeCustomizerTitle: 'Hjemmeside',
sidebarTitle: 'Sidefelt',
sidebarReset: 'Tilbakestill til standard',
+ artistLayoutTitle: 'Artistsidens seksjoner',
+ artistLayoutDesc: 'Dra for å omorganisere, veksle for å skjule individuelle seksjoner av artistsiden. Seksjoner uten data hoppes over automatisk.',
+ artistLayoutReset: 'Tilbakestill til standard',
+ artistLayoutBio: 'Artistbiografi',
+ artistLayoutTopTracks: 'Toppspor',
+ artistLayoutSimilar: 'Lignende artister',
+ artistLayoutAlbums: 'Album',
+ artistLayoutFeatured: 'Også med på',
sidebarDrag: 'Dra for å endre rekkefølge',
sidebarFixed: 'Alltid synlig',
randomNavSplitTitle: 'Del Mix-navigasjon',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index 321ea3d5..7f721f83 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -645,6 +645,14 @@ export const nlTranslation = {
homeCustomizerTitle: 'Startpagina',
sidebarTitle: 'Zijbalk',
sidebarReset: 'Standaard herstellen',
+ artistLayoutTitle: 'Secties artiestenpagina',
+ artistLayoutDesc: 'Versleep om te herschikken, schakel om individuele secties van de artiestenpagina te verbergen. Secties zonder gegevens worden automatisch overgeslagen.',
+ artistLayoutReset: 'Standaard herstellen',
+ artistLayoutBio: 'Biografie van de artiest',
+ artistLayoutTopTracks: 'Populaire nummers',
+ artistLayoutSimilar: 'Soortgelijke artiesten',
+ artistLayoutAlbums: 'Albums',
+ artistLayoutFeatured: 'Ook te horen op',
sidebarDrag: 'Slepen om te herordenen',
sidebarFixed: 'Altijd zichtbaar',
randomNavSplitTitle: 'Mix-navigatie splitsen',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index b22aa975..d79dce5b 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -673,6 +673,14 @@ export const ruTranslation = {
homeCustomizerTitle: 'Главная',
sidebarTitle: 'Боковая панель',
sidebarReset: 'Сбросить порядок',
+ artistLayoutTitle: 'Разделы страницы исполнителя',
+ artistLayoutDesc: 'Перетаскивайте, чтобы изменить порядок, переключайте, чтобы скрыть отдельные разделы страницы исполнителя. Разделы без данных пропускаются автоматически.',
+ artistLayoutReset: 'Сбросить',
+ artistLayoutBio: 'Биография исполнителя',
+ artistLayoutTopTracks: 'Топ-треки',
+ artistLayoutSimilar: 'Похожие исполнители',
+ artistLayoutAlbums: 'Альбомы',
+ artistLayoutFeatured: 'Также участвует в',
sidebarDrag: 'Перетащите для порядка',
sidebarFixed: 'Всегда показывать',
randomNavSplitTitle: 'Разделить навигацию микса',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index d25feb67..a54eb3c7 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -641,6 +641,14 @@ export const zhTranslation = {
homeCustomizerTitle: '首页',
sidebarTitle: '侧边栏',
sidebarReset: '重置为默认',
+ artistLayoutTitle: '艺术家页面板块',
+ artistLayoutDesc: '拖动以重新排序,切换以隐藏艺术家页面的各个板块。没有数据的板块会自动跳过。',
+ artistLayoutReset: '重置为默认',
+ artistLayoutBio: '艺术家简介',
+ artistLayoutTopTracks: '热门曲目',
+ artistLayoutSimilar: '相似艺术家',
+ artistLayoutAlbums: '专辑',
+ artistLayoutFeatured: '也参与了',
sidebarDrag: '拖动以重新排序',
sidebarFixed: '始终显示',
randomNavSplitTitle: '拆分混音导航',
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx
index 55a53535..de0cc086 100644
--- a/src/pages/ArtistDetail.tsx
+++ b/src/pages/ArtistDetail.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef, Fragment } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
@@ -18,6 +18,7 @@ import { invalidateCoverArt } from '../utils/imageCache';
import { showToast } from '../utils/toast';
import { extractCoverColors } from '../utils/dynamicColors';
import StarRating from '../components/StarRating';
+import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -83,6 +84,9 @@ export default function ArtistDetail() {
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
+ // MUST stay above the loading / !artist early returns or React's hook
+ // call order will mismatch between renders.
+ const sectionConfig = useArtistLayoutStore(s => s.sections);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
@@ -436,6 +440,26 @@ export default function ArtistDetail() {
(similarLoading || similarArtists.length > 0);
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
+ // ── User-customisable section order + visibility ────────────────────────────
+ // (`sectionConfig` is read at the top of the component — see comment there)
+ const sectionHasData = (id: ArtistSectionId): boolean => {
+ switch (id) {
+ case 'bio': return !!info?.biography;
+ case 'topTracks': return topSongs.length > 0;
+ case 'similar': return showSimilarSection;
+ case 'albums': return true; // always renders (empty state included)
+ case 'featured': return featuredLoading || featuredAlbums.length > 0;
+ }
+ };
+ // The order the user actually sees: hidden-via-toggle and empty sections
+ // are filtered out, so the "first rendered section gets marginTop: 0" rule
+ // works regardless of the configured order.
+ const renderableSectionIds = sectionConfig
+ .filter(s => s.visible)
+ .map(s => s.id)
+ .filter(sectionHasData);
+ const sectionMt = (id: ArtistSectionId) => renderableSectionIds[0] === id ? '0' : '2rem';
+
return (
- {/* Biography — sanitized HTML from server */}
- {info?.biography && (
-
-
-
{t('nowPlaying.aboutArtist')}
-
-
- {(info.largeImageUrl || coverId) && (
-
{ (e.target as HTMLImageElement).style.display = 'none'; }}
- />
- )}
-
-
-
setBioExpanded(v => !v)}>
- {bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
-
+ {/* User-reorderable sections — order + visibility configured in Settings.
+ * Each case renders the same JSX it did pre-refactor; only `marginTop`
+ * (now derived from the actual render order) and the outer wrapper changed. */}
+ {renderableSectionIds.map(sectionId => {
+ switch (sectionId) {
+ case 'bio': return (
+
+
+
{t('nowPlaying.aboutArtist')}
+
+
+ {(info?.largeImageUrl || coverId) && (
+
{ (e.target as HTMLImageElement).style.display = 'none'; }}
+ />
+ )}
+
+
+
setBioExpanded(v => !v)}>
+ {bioExpanded ? t('nowPlaying.showLess') : t('nowPlaying.readMore')}
+
+
+
-
-
- )}
+ );
- {/* Top Songs */}
- {topSongs.length > 0 && (
- <>
-
- {t('artistDetail.topTracks')}
-
+ case 'topTracks': return (
+
+
+ {t('artistDetail.topTracks')}
+
#
@@ -684,80 +715,85 @@ export default function ArtistDetail() {
);
})}
- >
- )}
+
+ );
- {showSimilarSection && (
- <>
-
-
- {t('artistDetail.similarArtists')}
-
- {isMobile && (() => {
- const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
- return list.length > 5 ? (
- setSimilarCollapsed(v => !v)}>
- {similarCollapsed ? : }
- {similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
-
- ) : null;
- })()}
-
- {showLastfmSimilar && similarLoading ? (
-
-
- {t('artistDetail.loading')}
-
- ) : (
-
- {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
- .slice(0, isMobile && similarCollapsed ? 5 : undefined)
- .map(a => (
- navigate(`/artist/${a.id}`)}
- >
- {a.name}
-
- ))}
-
- )}
- >
- )}
+ case 'similar': return (
+
+
+
+ {t('artistDetail.similarArtists')}
+
+ {isMobile && (() => {
+ const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
+ return list.length > 5 ? (
+ setSimilarCollapsed(v => !v)}>
+ {similarCollapsed ? : }
+ {similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
+
+ ) : null;
+ })()}
+
+ {showLastfmSimilar && similarLoading ? (
+
+
+ {t('artistDetail.loading')}
+
+ ) : (
+
+ {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
+ .slice(0, isMobile && similarCollapsed ? 5 : undefined)
+ .map(a => (
+ navigate(`/artist/${a.id}`)}
+ >
+ {a.name}
+
+ ))}
+
+ )}
+
+ );
- {/* Albums */}
-
0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}>
- {t('artistDetail.albumsBy', { name: artist.name })}
-
+ case 'albums': return (
+
+
+ {t('artistDetail.albumsBy', { name: artist.name })}
+
+ {albums.length > 0 ? (
+
+ ) : (
+ {t('artistDetail.noAlbums')}
+ )}
+
+ );
- {albums.length > 0 ? (
-
- ) : (
-
{t('artistDetail.noAlbums')}
- )}
+ case 'featured': return (
+
+
+ {t('artistDetail.featuredOn')}
+
+ {featuredLoading ? (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ {featuredAlbums.map(a =>
)}
+
+ )}
+
+ );
- {/* Also Featured On */}
- {(featuredLoading || featuredAlbums.length > 0) && (
- <>
-
- {t('artistDetail.featuredOn')}
-
- {featuredLoading ? (
-
- {[...Array(4)].map((_, i) => (
-
- ))}
-
- ) : (
-
- {featuredAlbums.map(a =>
)}
-
- )}
- >
- )}
+ default: return null;
+ }
+ })}
);
}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 2d5331ec..65b74e54 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -30,6 +30,7 @@ import { useFontStore, FontId } from '../store/fontStore';
import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore';
import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore';
import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore';
+import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } from '../store/artistLayoutStore';
import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../config/navItems';
@@ -2560,6 +2561,8 @@ export default function Settings() {
+
+
>
)}
@@ -3592,6 +3595,144 @@ function SidebarCustomizer() {
);
}
+// ── Artist Page Sections Customizer ────────────────────────────────────────
+
+const ARTIST_SECTION_LABEL_KEYS: Record = {
+ bio: 'settings.artistLayoutBio',
+ topTracks: 'settings.artistLayoutTopTracks',
+ similar: 'settings.artistLayoutSimilar',
+ albums: 'settings.artistLayoutAlbums',
+ featured: 'settings.artistLayoutFeatured',
+};
+
+type ArtistDropTarget = { idx: number; before: boolean } | null;
+
+function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) {
+ const { t } = useTranslation();
+ const { onMouseDown } = useDragSource(() => ({
+ data: JSON.stringify({ type: 'artist_section_reorder', index: idx }),
+ label,
+ }));
+ return (
+
+
+
+ );
+}
+
+function ArtistLayoutCustomizer() {
+ const { t } = useTranslation();
+ const sections = useArtistLayoutStore(s => s.sections);
+ const setSections = useArtistLayoutStore(s => s.setSections);
+ const toggleSection = useArtistLayoutStore(s => s.toggleSection);
+ const reset = useArtistLayoutStore(s => s.reset);
+ const { isDragging: isPsyDragging } = useDragDrop();
+ const [containerEl, setContainerEl] = useState(null);
+ const [dropTarget, setDropTarget] = useState(null);
+ const dropTargetRef = useRef(null);
+ const sectionsRef = useRef(sections);
+ sectionsRef.current = sections;
+
+ useEffect(() => {
+ if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); }
+ }, [isPsyDragging]);
+
+ useEffect(() => {
+ if (!containerEl) return;
+ const onPsyDrop = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (!detail?.data) return;
+ let parsed: { type?: string; index?: number };
+ try { parsed = JSON.parse(detail.data as string); } catch { return; }
+ if (parsed.type !== 'artist_section_reorder' || parsed.index == null) return;
+
+ const fromIdx = parsed.index;
+ const target = dropTargetRef.current;
+ dropTargetRef.current = null; setDropTarget(null);
+ if (!target) return;
+
+ const insertBefore = target.before ? target.idx : target.idx + 1;
+ if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return;
+
+ const next = [...sectionsRef.current];
+ const [moved] = next.splice(fromIdx, 1);
+ next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved);
+ setSections(next);
+ };
+ containerEl.addEventListener('psy-drop', onPsyDrop);
+ return () => containerEl.removeEventListener('psy-drop', onPsyDrop);
+ }, [containerEl, setSections]);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isPsyDragging || !containerEl) return;
+ const rows = containerEl.querySelectorAll('[data-artist-idx]');
+ let target: ArtistDropTarget = null;
+ for (const row of rows) {
+ const rect = row.getBoundingClientRect();
+ const idx = Number(row.dataset.artistIdx);
+ if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; }
+ target = { idx, before: false };
+ }
+ dropTargetRef.current = target;
+ setDropTarget(target);
+ };
+
+ return (
+
+
+
+
{t('settings.artistLayoutTitle')}
+
+
+
+
+
+ {t('settings.artistLayoutDesc')}
+
+
+ {sections.map((section: ArtistSectionConfig, i) => {
+ const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]);
+ const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before;
+ const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before;
+ return (
+
+
+
{label}
+
+ toggleSection(section.id)} />
+
+
+
+ );
+ })}
+
+
+ );
+}
+
function BackupSection() {
const { t } = useTranslation();
const [exporting, setExporting] = useState(false);
diff --git a/src/store/artistLayoutStore.ts b/src/store/artistLayoutStore.ts
new file mode 100644
index 00000000..240c741b
--- /dev/null
+++ b/src/store/artistLayoutStore.ts
@@ -0,0 +1,58 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export type ArtistSectionId = 'bio' | 'topTracks' | 'similar' | 'albums' | 'featured';
+
+export interface ArtistSectionConfig {
+ id: ArtistSectionId;
+ visible: boolean;
+}
+
+/**
+ * Default order matches the historical layout of `pages/ArtistDetail.tsx` so
+ * existing users see no change until they explicitly customise it.
+ */
+export const DEFAULT_ARTIST_SECTIONS: ArtistSectionConfig[] = [
+ { id: 'bio', visible: true },
+ { id: 'topTracks', visible: true },
+ { id: 'similar', visible: true },
+ { id: 'albums', visible: true },
+ { id: 'featured', visible: true },
+];
+
+interface ArtistLayoutStore {
+ sections: ArtistSectionConfig[];
+ setSections: (sections: ArtistSectionConfig[]) => void;
+ toggleSection: (id: ArtistSectionId) => void;
+ reset: () => void;
+}
+
+export const useArtistLayoutStore = create()(
+ persist(
+ (set) => ({
+ sections: DEFAULT_ARTIST_SECTIONS,
+
+ setSections: (sections) => set({ sections }),
+
+ toggleSection: (id) => set((s) => ({
+ sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
+ })),
+
+ reset: () => set({ sections: DEFAULT_ARTIST_SECTIONS }),
+ }),
+ {
+ name: 'psysonic_artist_layout',
+ onRehydrateStorage: () => (state) => {
+ if (!state) return;
+ // Sanitize: drop null/corrupt entries, append any new sections that
+ // were added in a later release so they don't silently disappear.
+ const knownIds = new Set(DEFAULT_ARTIST_SECTIONS.map(s => s.id));
+ const safe = (state.sections ?? [])
+ .filter((s): s is ArtistSectionConfig => s != null && typeof s.id === 'string' && knownIds.has(s.id as ArtistSectionId));
+ const seen = new Set(safe.map(s => s.id));
+ const missing = DEFAULT_ARTIST_SECTIONS.filter(s => !seen.has(s.id));
+ state.sections = missing.length > 0 ? [...safe, ...missing] : safe;
+ },
+ }
+ )
+);