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 {