diff --git a/src/locales/de.ts b/src/locales/de.ts
index f7a30ae4..a8b61c08 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -117,6 +117,13 @@ export const deTranslation = {
rgAutoTooltip: 'ReplayGain (Auto)',
showMoreTracks: '{{count}} weitere anzeigen',
showLessTracks: 'Weniger anzeigen',
+ hideCard: 'Karte ausblenden',
+ layoutMenu: 'Layout',
+ visibleCards: 'Sichtbare Karten',
+ hiddenCards: 'Ausgeblendete Karten',
+ noHiddenCards: 'Keine ausgeblendeten Karten',
+ resetLayout: 'Layout zurücksetzen',
+ emptyColumn: 'Karten hier ablegen',
},
contextMenu: {
playNow: 'Direkt abspielen',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index cdb8090e..1b5b68e1 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -118,6 +118,13 @@ export const enTranslation = {
rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Show {{count}} more',
showLessTracks: 'Show less',
+ hideCard: 'Hide card',
+ layoutMenu: 'Layout',
+ visibleCards: 'Visible cards',
+ hiddenCards: 'Hidden cards',
+ noHiddenCards: 'No hidden cards',
+ resetLayout: 'Reset layout',
+ emptyColumn: 'Drop cards here',
},
contextMenu: {
playNow: 'Play Now',
diff --git a/src/locales/es.ts b/src/locales/es.ts
index aab7380a..d97b63ee 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -118,6 +118,13 @@ export const esTranslation = {
rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Mostrar {{count}} más',
showLessTracks: 'Mostrar menos',
+ hideCard: 'Ocultar tarjeta',
+ layoutMenu: 'Diseño',
+ visibleCards: 'Tarjetas visibles',
+ hiddenCards: 'Tarjetas ocultas',
+ noHiddenCards: 'No hay tarjetas ocultas',
+ resetLayout: 'Restablecer diseño',
+ emptyColumn: 'Suelta tarjetas aquí',
},
contextMenu: {
playNow: 'Reproducir Ahora',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index 9214065e..14a992ee 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -117,6 +117,13 @@ export const frTranslation = {
rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Afficher {{count}} de plus',
showLessTracks: 'Réduire',
+ hideCard: 'Masquer la carte',
+ layoutMenu: 'Disposition',
+ visibleCards: 'Cartes visibles',
+ hiddenCards: 'Cartes masquées',
+ noHiddenCards: 'Aucune carte masquée',
+ resetLayout: 'Réinitialiser la disposition',
+ emptyColumn: 'Déposez des cartes ici',
},
contextMenu: {
playNow: 'Lire maintenant',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index d08f2ccc..8420dc11 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -117,6 +117,13 @@ export const nbTranslation = {
rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: 'Vis {{count}} til',
showLessTracks: 'Vis mindre',
+ hideCard: 'Skjul kort',
+ layoutMenu: 'Oppsett',
+ visibleCards: 'Synlige kort',
+ hiddenCards: 'Skjulte kort',
+ noHiddenCards: 'Ingen skjulte kort',
+ resetLayout: 'Tilbakestill oppsett',
+ emptyColumn: 'Slipp kort her',
},
contextMenu: {
playNow: 'Spill nå',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index 799cc248..bec45023 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -117,6 +117,13 @@ export const nlTranslation = {
rgAutoTooltip: 'ReplayGain (auto)',
showMoreTracks: '{{count}} meer tonen',
showLessTracks: 'Minder tonen',
+ hideCard: 'Kaart verbergen',
+ layoutMenu: 'Lay-out',
+ visibleCards: 'Zichtbare kaarten',
+ hiddenCards: 'Verborgen kaarten',
+ noHiddenCards: 'Geen verborgen kaarten',
+ resetLayout: 'Lay-out resetten',
+ emptyColumn: 'Sleep kaarten hier',
},
contextMenu: {
playNow: 'Nu afspelen',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index f0be1266..fb4fe425 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -122,6 +122,13 @@ export const ruTranslation = {
rgAutoTooltip: 'ReplayGain (авто)',
showMoreTracks: 'Показать ещё {{count}}',
showLessTracks: 'Свернуть',
+ hideCard: 'Скрыть карточку',
+ layoutMenu: 'Макет',
+ visibleCards: 'Видимые карточки',
+ hiddenCards: 'Скрытые карточки',
+ noHiddenCards: 'Нет скрытых карточек',
+ resetLayout: 'Сбросить макет',
+ emptyColumn: 'Перетащите карточки сюда',
},
contextMenu: {
playNow: 'Играть сейчас',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 30621c9a..5bcdaf8c 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -117,6 +117,13 @@ export const zhTranslation = {
rgAutoTooltip: 'ReplayGain (自动)',
showMoreTracks: '显示另外 {{count}} 首',
showLessTracks: '收起',
+ hideCard: '隐藏卡片',
+ layoutMenu: '布局',
+ visibleCards: '可见卡片',
+ hiddenCards: '已隐藏的卡片',
+ noHiddenCards: '没有已隐藏的卡片',
+ resetLayout: '重置布局',
+ emptyColumn: '将卡片拖到此处',
},
contextMenu: {
playNow: '立即播放',
diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx
index 786d81ea..31351da2 100644
--- a/src/pages/NowPlaying.tsx
+++ b/src/pages/NowPlaying.tsx
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play } from 'lucide-react';
+import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward, Info, Headphones, Calendar, Disc3, TrendingUp, Play, EyeOff, LayoutGrid, RotateCcw, Eye } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -23,6 +23,11 @@ import { useCachedUrl } from '../components/CachedImage';
import CachedImage from '../components/CachedImage';
import LastfmIcon from '../components/LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
+import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
+import {
+ useNpLayoutStore, NP_CARD_IDS,
+ type NpCardId, type NpColumn,
+} from '../store/nowPlayingLayoutStore';
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -717,6 +722,84 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
);
});
+// ─── Widget wrapper (drag source via psyDnD) ─────────────────────────────────
+
+interface NpCardWrapProps {
+ id: NpCardId;
+ label: string;
+ isDraggingThis: boolean;
+ children: React.ReactNode;
+}
+
+function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) {
+ const dragProps = useDragSource(() => ({
+ data: JSON.stringify({ kind: 'np-card', id }),
+ label,
+ }));
+ return (
+
+ {children}
+
+ );
+}
+
+// ─── Column (drop target via psy-drop + global mousemove) ────────────────────
+
+interface NpColumnProps {
+ col: NpColumn;
+ children: React.ReactNode;
+ empty: boolean;
+ emptyLabel: string;
+ isDndActive: boolean;
+ draggingCardId: NpCardId | null;
+ onHover: (col: NpColumn, idx: number) => void;
+ isOverHere: boolean;
+}
+
+function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!draggingCardId) return;
+ const el = ref.current;
+ if (!el) return;
+
+ const onMove = (e: MouseEvent) => {
+ const rect = el.getBoundingClientRect();
+ // Use only the x-axis to decide "which column". This keeps the whole
+ // vertical strip above / below the last card part of the drop zone,
+ // so the user can drop "at the very bottom" of either column.
+ if (e.clientX < rect.left || e.clientX > rect.right) return;
+ const wrappers = Array.from(el.querySelectorAll('[data-np-wrapper]'))
+ .filter(w => w.getAttribute('data-np-card-id') !== draggingCardId);
+ let idx = wrappers.length;
+ for (let i = 0; i < wrappers.length; i++) {
+ const r = wrappers[i].getBoundingClientRect();
+ if (e.clientY < r.top + r.height / 2) { idx = i; break; }
+ }
+ onHover(col, idx);
+ };
+
+ document.addEventListener('mousemove', onMove);
+ return () => document.removeEventListener('mousemove', onMove);
+ }, [draggingCardId, col, onHover]);
+
+ return (
+
+ {children}
+ {empty &&
{emptyLabel}
}
+
+ );
+}
+
type NonNullStoreField> =
NonNullable[K]>;
@@ -1007,6 +1090,74 @@ export default function NowPlaying() {
if (hit) playTrackFn(hit, queue);
}, [topSongs, playTrackFn]);
+ // ── Widget layout (drag-to-reorder, hide/show, reset) ────────────────────
+ const layoutCards = useNpLayoutStore(s => s.cards);
+ const moveCard = useNpLayoutStore(s => s.moveCard);
+ const setCardVisible = useNpLayoutStore(s => s.setVisible);
+ const resetLayout = useNpLayoutStore(s => s.reset);
+ const { isDragging: dndActive, payload: dndPayload } = useDragDrop();
+
+ const [dragOver, setDragOver] = useState<{ col: NpColumn; idx: number } | null>(null);
+ const [layoutMenuOpen, setLayoutMenuOpen] = useState(false);
+
+ // Parse the current drag payload to know whether it's an np-card drag
+ const draggingCardId: NpCardId | null = useMemo(() => {
+ if (!dndActive || !dndPayload) return null;
+ try {
+ const parsed = JSON.parse(dndPayload.data);
+ if (parsed?.kind === 'np-card' && NP_CARD_IDS.includes(parsed.id)) return parsed.id as NpCardId;
+ } catch { /* not a card payload */ }
+ return null;
+ }, [dndActive, dndPayload]);
+
+ // Clear the drop indicator when the drag ends (no psy-drop on our target)
+ useEffect(() => { if (!draggingCardId) setDragOver(null); }, [draggingCardId]);
+
+ const toggleCardVisible = useCallback((id: NpCardId, next: boolean) => {
+ setCardVisible(id, next);
+ }, [setCardVisible]);
+
+ const onColumnHover = useCallback((col: NpColumn, idx: number) => {
+ setDragOver(prev => (prev && prev.col === col && prev.idx === idx) ? prev : { col, idx });
+ }, []);
+
+ // Ref mirror of dragOver so the document-level psy-drop handler always sees
+ // the latest hovered column/index regardless of closure timing.
+ const dragOverRef = useRef(dragOver);
+ dragOverRef.current = dragOver;
+
+ // Global psy-drop listener: catches drops anywhere on the page (even below a
+ // column when the cursor left the column bounds), then uses dragOverRef to
+ // decide which column/index the user actually meant.
+ useEffect(() => {
+ if (!draggingCardId) return;
+ const onPsyDrop = (evt: Event) => {
+ const ce = evt as CustomEvent<{ data: string }>;
+ try {
+ const parsed = JSON.parse(ce.detail?.data ?? '');
+ if (parsed?.kind !== 'np-card' || !NP_CARD_IDS.includes(parsed.id)) return;
+ const over = dragOverRef.current;
+ if (over) {
+ moveCard(parsed.id as NpCardId, over.col, over.idx);
+ }
+ } catch { /* ignore non-card drops */ }
+ setDragOver(null);
+ };
+ document.addEventListener('psy-drop', onPsyDrop as EventListener);
+ return () => document.removeEventListener('psy-drop', onPsyDrop as EventListener);
+ }, [draggingCardId, moveCard]);
+
+ // Close layout menu on outside click
+ useEffect(() => {
+ if (!layoutMenuOpen) return;
+ const onDoc = (e: MouseEvent) => {
+ const el = e.target as HTMLElement | null;
+ if (!el?.closest('.np-dash-toolbar-menu-wrap')) setLayoutMenuOpen(false);
+ };
+ document.addEventListener('mousedown', onDoc);
+ return () => document.removeEventListener('mousedown', onDoc);
+ }, [layoutMenuOpen]);
+
// ── Render ────────────────────────────────────────────────────────────────
return (
@@ -1047,49 +1198,165 @@ export default function NowPlaying() {
onOpenLyrics={openLyrics}
/>
-
+ {(() => {
+ const renderCard = (id: NpCardId): React.ReactNode => {
+ switch (id) {
+ case 'album': return (
+
+ );
+ case 'topSongs': return (
+
+ );
+ case 'credits': return
;
+ case 'artist': return (
+
+ );
+ case 'discography': return (
+
+ );
+ case 'tour': return (
+
+ );
+ }
+ };
+ const cardLabel = (id: NpCardId): string => {
+ const k: Record
= {
+ album: 'nowPlaying.fromAlbum',
+ topSongs: 'nowPlaying.topSongs',
+ credits: 'nowPlayingInfo.songInfo',
+ artist: 'nowPlaying.aboutArtist',
+ discography: 'nowPlaying.discography',
+ tour: 'nowPlayingInfo.onTour',
+ };
+ return t(k[id]);
+ };
+ const visibleCards = layoutCards.filter(c => c.visible);
+ const hiddenCards = layoutCards.filter(c => !c.visible);
+ const renderColumn = (col: NpColumn) => {
+ const cards = layoutCards.filter(c =>
+ c.column === col && c.visible && c.id !== draggingCardId,
+ );
+ const isOver = dragOver?.col === col;
+ return (
+
+ {cards.map((c, idx) => (
+
+ {isOver && dragOver.idx === idx && }
+
+ {renderCard(c.id)}
+
+
+ ))}
+ {isOver && dragOver.idx === cards.length && }
+
+ );
+ };
+ return (
+ <>
+
+
+
+ {layoutMenuOpen && (
+
+
+ {t('nowPlaying.visibleCards', 'Visible cards')}
+
+ {visibleCards.map(c => (
+
+ ))}
+ {hiddenCards.length > 0 && (
+ <>
+
+ {t('nowPlaying.hiddenCards', 'Hidden cards')}
+
+ {hiddenCards.map(c => (
+
+ ))}
+ >
+ )}
+
+
+
+ )}
+
+
+
+ {renderColumn('left')}
+ {renderColumn('right')}
+
+ >
+ );
+ })()}
) : (
diff --git a/src/store/nowPlayingLayoutStore.ts b/src/store/nowPlayingLayoutStore.ts
new file mode 100644
index 00000000..08724411
--- /dev/null
+++ b/src/store/nowPlayingLayoutStore.ts
@@ -0,0 +1,76 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export type NpColumn = 'left' | 'right';
+
+export type NpCardId =
+ | 'album'
+ | 'topSongs'
+ | 'credits'
+ | 'artist'
+ | 'discography'
+ | 'tour';
+
+export interface NpCardConfig {
+ id: NpCardId;
+ column: NpColumn;
+ visible: boolean;
+}
+
+export const NP_CARD_IDS: NpCardId[] = ['album', 'topSongs', 'credits', 'artist', 'discography', 'tour'];
+
+export const DEFAULT_NP_LAYOUT: NpCardConfig[] = [
+ { id: 'album', column: 'left', visible: true },
+ { id: 'topSongs', column: 'left', visible: true },
+ { id: 'credits', column: 'left', visible: true },
+ { id: 'artist', column: 'right', visible: true },
+ { id: 'discography', column: 'right', visible: true },
+ { id: 'tour', column: 'right', visible: true },
+];
+
+interface NpLayoutStore {
+ cards: NpCardConfig[];
+ /** Move a card to a column at a given insertion index (0-based within that column). */
+ moveCard: (id: NpCardId, toColumn: NpColumn, toIndex: number) => void;
+ setVisible: (id: NpCardId, visible: boolean) => void;
+ reset: () => void;
+}
+
+export const useNpLayoutStore = create()(
+ persist(
+ (set) => ({
+ cards: DEFAULT_NP_LAYOUT,
+
+ moveCard: (id, toColumn, toIndex) => set((s) => {
+ const target = s.cards.find(c => c.id === id);
+ if (!target) return s;
+ const without = s.cards.filter(c => c.id !== id);
+ const left = without.filter(c => c.column === 'left');
+ const right = without.filter(c => c.column === 'right');
+ const moved: NpCardConfig = { ...target, column: toColumn };
+ const targetBucket = toColumn === 'left' ? left : right;
+ const clamped = Math.max(0, Math.min(toIndex, targetBucket.length));
+ targetBucket.splice(clamped, 0, moved);
+ return { cards: [...left, ...right] };
+ }),
+
+ setVisible: (id, visible) => set((s) => ({
+ cards: s.cards.map(c => c.id === id ? { ...c, visible } : c),
+ })),
+
+ reset: () => set({ cards: DEFAULT_NP_LAYOUT }),
+ }),
+ {
+ name: 'psysonic_np_layout',
+ onRehydrateStorage: () => (state) => {
+ if (!state) return;
+ const safe = (state.cards ?? []).filter((c): c is NpCardConfig =>
+ c != null && typeof c.id === 'string' && NP_CARD_IDS.includes(c.id as NpCardId)
+ );
+ const known = new Set(safe.map(c => c.id));
+ const missing = DEFAULT_NP_LAYOUT.filter(c => !known.has(c.id));
+ state.cards = missing.length > 0 ? [...safe, ...missing] : safe;
+ },
+ },
+ ),
+);
diff --git a/src/styles/components.css b/src/styles/components.css
index 53319145..c553f371 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -10364,11 +10364,137 @@ html[data-app-hidden="true"] *::after {
font-weight: 600;
}
+/* Widget layout toolbar (layout / hidden-cards menu) */
+.np-dash-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: -4px;
+}
+.np-dash-toolbar-menu-wrap {
+ position: relative;
+}
+.np-dash-toolbar-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.75);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-sm);
+ padding: 6px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+.np-dash-toolbar-btn:hover {
+ background: rgba(255, 255, 255, 0.10);
+ color: var(--accent);
+}
+.np-dash-toolbar-badge {
+ background: var(--accent);
+ color: var(--ctp-base, #1e1e2e);
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 6px;
+ border-radius: 999px;
+ min-width: 14px;
+ text-align: center;
+ line-height: 1.3;
+}
+.np-dash-toolbar-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ min-width: 220px;
+ background: var(--ctp-base, #1e1e2e);
+ border: 1px solid rgba(255, 255, 255, 0.10);
+ border-radius: var(--radius-md);
+ padding: 6px;
+ z-index: 50;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+}
+.np-dash-toolbar-section {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 255, 255, 0.45);
+ padding: 6px 8px 4px;
+}
+.np-dash-toolbar-empty {
+ padding: 8px 10px;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.50);
+ font-style: italic;
+}
+.np-dash-toolbar-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: rgba(255, 255, 255, 0.85);
+ font-size: 12.5px;
+ padding: 7px 10px;
+ border-radius: var(--radius-sm);
+ text-align: left;
+ transition: background 0.1s;
+}
+.np-dash-toolbar-item:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--accent);
+}
+.np-dash-toolbar-divider {
+ height: 1px;
+ margin: 4px 0;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+/* Widget wrapper — makes each card a psyDnD drag source */
+.np-dash-card-wrap {
+ position: relative;
+ cursor: grab;
+}
+.np-dash-col.is-dnd-active .np-dash-card-wrap {
+ transition: transform 0.15s;
+}
+.np-dash-col.is-drop-target {
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: var(--radius-lg);
+ outline: 1px dashed rgba(255, 255, 255, 0.10);
+ outline-offset: 4px;
+}
+
+.np-dash-toolbar-item-label { flex: 1; min-width: 0; }
+.np-dash-toolbar-item.is-hidden .np-dash-toolbar-item-label { opacity: 0.55; }
+
+/* Drop indicator — accent line between cards while dragging */
+.np-dash-drop-indicator {
+ height: 3px;
+ border-radius: 2px;
+ background: var(--accent);
+ box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 60%, transparent);
+ margin: -8px 0;
+}
+
+/* Empty column placeholder */
+.np-dash-col-empty {
+ padding: 28px 18px;
+ text-align: center;
+ font-size: 12.5px;
+ color: rgba(255, 255, 255, 0.40);
+ border: 1px dashed rgba(255, 255, 255, 0.10);
+ border-radius: var(--radius-lg);
+ font-style: italic;
+}
+
/* Two flex columns — left stack, right stack */
.np-dash-grid {
display: flex;
gap: 18px;
- align-items: flex-start;
+ align-items: stretch; /* equal-height columns so drops below the last card still hit the column */
}
.np-dash-col {
@@ -10377,6 +10503,10 @@ html[data-app-hidden="true"] *::after {
display: flex;
flex-direction: column;
gap: 18px;
+ /* Ensures the area below the last card still belongs to this column — needed
+ so the user can drop a card "at the very bottom" even when the other
+ column is taller. */
+ min-height: 120px;
}
.np-dash-card {