From 2871db9a961448ad4d6d3e31e6a68bd4924331b8 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 19 Apr 2026 12:08:05 +0200 Subject: [PATCH] feat(mini-player): persistent geometry, queue DnD + context menu, overlay scrollbar, live theme sync This iteration fills in the pieces that make the mini player usable as a daily standalone window: - Window position persists to /mini_player_pos.json on every WindowEvent::Moved (throttled 250 ms); first launch lands in the bottom-right of the main window's monitor. - Queue keeps a 260 px floor when expanded (~2 visible rows). The user-resized expanded height is restored on next toggle via localStorage so reopening doesn't snap back to the default 440 px. - set_mini_player_always_on_top(true) now forces a false-then-true cycle so the WM re-evaluates the layer after a hide/show; the frontend also re-asserts the pin state on mount and on window focus, so the user no longer has to click the pin button twice for it to stick. - Native scrollbar in the queue is hidden in favour of a JS-driven overlay thumb so items use the full width of the queue area. - PsyDnD reorder works inside the mini queue: drag emits 'mini:reorder', the bridge calls reorderQueue on the source-of-truth store in main. - Localized queue-item context menu (MiniContextMenu) with Play now / Remove from queue / Open album / Go to artist / Favorite / Song info. Cross-window actions forward to main via new bridge events (mini:reorder, mini:remove, mini:navigate, mini:song-info); a psy:navigate CustomEvent picked up by AppShell handles routing. - Theme, font and language changes in main propagate live to the mini via a 'storage' event listener that re-hydrates the persisted Zustand stores and calls i18n.changeLanguage. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/lib.rs | 120 ++++++++++++- src/App.tsx | 37 +++- src/components/MiniContextMenu.tsx | 122 +++++++++++++ src/components/MiniPlayer.tsx | 263 ++++++++++++++++++++++++++--- src/styles/components.css | 44 ++++- src/utils/miniPlayerBridge.ts | 48 ++++++ 6 files changed, 608 insertions(+), 26 deletions(-) create mode 100644 src/components/MiniContextMenu.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1417a4eb..7ba4b708 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2603,6 +2603,81 @@ fn is_tiling_wm_cmd() -> bool { // "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so // we fall back to a regular window there. +/// Persisted geometry for the mini player. Stored in +/// `/mini_player_pos.json` and rewritten (throttled) on +/// every `WindowEvent::Moved` so the window reopens where the user last +/// left it. Coordinates are physical pixels — that's what `set_position` +/// and the move event both report, so we don't need to round-trip through +/// scale factors that may differ across monitors. +#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)] +struct MiniPlayerPosition { + x: i32, + y: i32, +} + +fn mini_pos_file(app: &tauri::AppHandle) -> Option { + app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json")) +} + +fn read_mini_pos(app: &tauri::AppHandle) -> Option { + let path = mini_pos_file(app)?; + let raw = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) { + let Some(path) = mini_pos_file(app) else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(&pos) { + let _ = std::fs::write(path, json); + } +} + +/// Throttle disk writes during a drag — `WindowEvent::Moved` fires on +/// every pointer step. 250 ms keeps the file fresh enough that any close +/// or release lands a recent position, without hammering the disk. +fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) { + static LAST_WRITE: OnceLock> = OnceLock::new(); + let mu = LAST_WRITE.get_or_init(|| { + Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10)) + }); + { + let mut last = mu.lock().unwrap(); + if last.elapsed() < std::time::Duration::from_millis(250) { + return; + } + *last = std::time::Instant::now(); + } + write_mini_pos(app, MiniPlayerPosition { x, y }); +} + +/// Default position when nothing is persisted: bottom-right of the monitor +/// the main window sits on (falls back to primary). A 24 px logical margin +/// keeps it off the screen edge; +56 px on the bottom margin avoids most +/// taskbars/docks since Tauri does not expose work-area rects. +fn default_mini_position(app: &tauri::AppHandle) -> Option> { + let monitor = app + .get_webview_window("main") + .and_then(|w| w.current_monitor().ok().flatten()) + .or_else(|| app.primary_monitor().ok().flatten())?; + + let scale = monitor.scale_factor(); + let m_pos = monitor.position(); + let m_size = monitor.size(); + + let win_w = (340.0 * scale).round() as i32; + let win_h = (180.0 * scale).round() as i32; + let margin_x = (24.0 * scale).round() as i32; + let margin_y = (56.0 * scale).round() as i32; + + Some(tauri::PhysicalPosition::new( + m_pos.x + (m_size.width as i32) - win_w - margin_x, + m_pos.y + (m_size.height as i32) - win_h - margin_y, + )) +} + /// Open (or toggle) the mini player window. Creates it on first call; on /// subsequent calls, hides it if visible, shows + focuses it if hidden. /// Opening the mini player minimizes the main window; hiding the mini player @@ -2647,9 +2722,19 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { .decorations(true) .always_on_top(use_always_on_top) .skip_taskbar(false) + .visible(false) // place first, then show — avoids the corner flash .build() .map_err(|e| format!("failed to build mini player window: {e}"))?; + // Restore last-known position, otherwise drop into the bottom-right of + // the main window's monitor. + let target = read_mini_pos(&app) + .map(|p| tauri::PhysicalPosition::new(p.x, p.y)) + .or_else(|| default_mini_position(&app)); + if let Some(pos) = target { + let _ = win.set_position(pos); + } + let _ = win.show(); let _ = win.set_focus(); if let Some(main) = app.get_webview_window("main") { let _ = main.minimize(); @@ -2690,19 +2775,43 @@ fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { } /// Toggle always-on-top on the mini player window. +/// +/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland) +/// silently ignore `set_always_on_top(true)` when the internal flag is +/// already `true` — which happens whenever the window was hidden and +/// re-shown, or focus was lost and the WM dropped the constraint. We +/// always force a `false → true` cycle so the WM re-evaluates the layer. #[tauri::command] fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> { if let Some(win) = app.get_webview_window("mini") { + if on_top { + let _ = win.set_always_on_top(false); + } win.set_always_on_top(on_top).map_err(|e| e.to_string())?; } Ok(()) } /// Resize the mini player window (logical pixels). Used when toggling the -/// queue panel to expand/collapse without a capability dance. +/// queue panel to expand/collapse without a capability dance. Optional +/// `minWidth` / `minHeight` adjust the window's resize floor so the user +/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when +/// the queue panel is open). #[tauri::command] -fn resize_mini_player(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> { +fn resize_mini_player( + app: tauri::AppHandle, + width: f64, + height: f64, + min_width: Option, + min_height: Option, +) -> Result<(), String> { if let Some(win) = app.get_webview_window("mini") { + // Lower the floor first; otherwise set_size to a value below the + // existing min would silently clamp. + if let (Some(mw), Some(mh)) = (min_width, min_height) { + win.set_min_size(Some(tauri::LogicalSize::new(mw, mh))) + .map_err(|e| e.to_string())?; + } win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?; } Ok(()) @@ -2891,6 +3000,13 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| { + // Persist mini player position whenever the user drags it. + if window.label() == "mini" { + if let tauri::WindowEvent::Moved(pos) = event { + persist_mini_pos_throttled(window.app_handle(), pos.x, pos.y); + } + } + if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() == "main" { api.prevent_close(); diff --git a/src/App.tsx b/src/App.tsx index 48e2b37f..b1972854 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -146,6 +146,17 @@ function AppShell() { const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + // Mini player → main: route requests dispatched as `psy:navigate` + // CustomEvents from the bridge land here so React Router can take over. + useEffect(() => { + const onPsyNavigate = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.to) navigate(detail.to); + }; + window.addEventListener('psy:navigate', onPsyNavigate); + return () => window.removeEventListener('psy:navigate', onPsyNavigate); + }, [navigate]); + // Sync custom titlebar preference with native decorations on Linux // On tiling WMs decorations are always off (no native title bar to replace). useEffect(() => { @@ -961,8 +972,32 @@ export default function App() { return initMiniPlayerBridgeOnMain(); }, [isMiniWindow]); + // Mini window only: re-hydrate persisted appearance stores when the main + // window writes new values. Both webviews share localStorage (same origin), + // so the `storage` event fires here whenever main mutates a key — but + // Zustand persist only reads localStorage on initial load, hence the + // explicit rehydrate. + useEffect(() => { + if (!isMiniWindow) return; + const onStorage = (e: StorageEvent) => { + if (!e.key) return; + if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate(); + else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate(); + else if (e.key === 'psysonic_language' && e.newValue) { + import('./i18n').then(m => m.default.changeLanguage(e.newValue!)); + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, [isMiniWindow]); + if (isMiniWindow) { - return ; + return ( + + + + + ); } // UI scaling is scoped to .main-content via an inner wrapper (see
diff --git a/src/components/MiniContextMenu.tsx b/src/components/MiniContextMenu.tsx new file mode 100644 index 00000000..a1b3db7f --- /dev/null +++ b/src/components/MiniContextMenu.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { emit } from '@tauri-apps/api/event'; +import { useTranslation } from 'react-i18next'; +import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react'; +import { star, unstar } from '../api/subsonic'; +import type { MiniTrackInfo } from '../utils/miniPlayerBridge'; + +interface Props { + x: number; + y: number; + track: MiniTrackInfo; + index: number; + onClose: () => void; +} + +/** + * Slim queue-item context menu for the mini player. The mini lives in its + * own webview, so all queue mutations forward to the main window via Tauri + * events; only the favorite call hits Subsonic directly because it has no + * cross-window state to keep in sync (next mini:sync from main reflects the + * new starred flag). + */ +export default function MiniContextMenu({ x, y, track, index, onClose }: Props) { + const { t } = useTranslation(); + const ref = useRef(null); + const [starred, setStarred] = useState(!!track.starred); + const [pos, setPos] = useState({ left: x, top: y }); + + // Clamp the menu inside the mini window's viewport (it pops near the + // cursor and would otherwise overflow at the right/bottom edges of the + // small window). + useEffect(() => { + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const left = Math.min(x, Math.max(4, vw - r.width - 4)); + const top = Math.min(y, Math.max(4, vh - r.height - 4)); + setPos({ left, top }); + }, [x, y]); + + // Dismiss on outside click + Escape. + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [onClose]); + + const run = (fn: () => void | Promise) => { + Promise.resolve(fn()).finally(onClose); + }; + + const toggleStar = async () => { + const next = !starred; + setStarred(next); + try { + if (next) await star(track.id, 'song'); + else await unstar(track.id, 'song'); + } catch { + setStarred(!next); + } + }; + + return createPortal( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > +
run(() => emit('mini:jump', { index }))}> + {t('contextMenu.playNow')} +
+
run(() => emit('mini:remove', { index }))} + > + {t('contextMenu.removeFromQueue')} +
+
+ {track.albumId && ( +
run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))} + > + {t('contextMenu.openAlbum')} +
+ )} + {track.artistId && ( +
run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))} + > + {t('contextMenu.goToArtist')} +
+ )} +
run(toggleStar)}> + + {starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} +
+
+
run(() => emit('mini:song-info', { id: track.id }))} + > + {t('contextMenu.songInfo')} +
+
, + document.body, + ); +} diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index c8def716..b99cb4c2 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,14 +1,35 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { emit, listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react'; import CachedImage from './CachedImage'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; +import { useDragDrop } from '../contexts/DragDropContext'; +import MiniContextMenu from './MiniContextMenu'; import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge'; const COLLAPSED_SIZE = { w: 340, h: 180 }; const EXPANDED_SIZE = { w: 340, h: 440 }; +// Minimum window dimensions per state. When the queue is open the floor must +// keep at least two queue rows visible; a stricter min would let the user +// collapse the queue area to nothing while it's still toggled on. +const COLLAPSED_MIN = { w: 320, h: 180 }; +const EXPANDED_MIN = { w: 320, h: 260 }; + +// Persist the expanded-window height so reopening the queue restores the +// user's preferred size instead of snapping back to EXPANDED_SIZE.h. +const EXPANDED_H_KEY = 'psysonic_mini_expanded_h'; +function readStoredExpandedHeight(): number { + try { + const raw = localStorage.getItem(EXPANDED_H_KEY); + if (raw) { + const n = parseInt(raw, 10); + if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n; + } + } catch {} + return EXPANDED_SIZE.h; +} function toMini(t: any): MiniTrackInfo { return { @@ -65,14 +86,73 @@ export default function MiniPlayer() { }); const [alwaysOnTop, setAlwaysOnTop] = useState(true); const [queueOpen, setQueueOpen] = useState(false); + const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false }); const ticker = useRef(null); const queueScrollRef = useRef(null); + // ── PsyDnD reorder ── + // Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove + // on the queue computes a drop indicator, psy-drop emits mini:reorder back + // to main where the source-of-truth store lives. + const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop(); + const psyDragFromIdxRef = useRef(null); + const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null); + const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null); + + const isReorderDrag = isPsyDragging && !!psyPayload && (() => { + try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; } + })(); + + useEffect(() => { + if (!isPsyDragging) { + dropTargetRef.current = null; + setDropTarget(null); + } + }, [isPsyDragging]); + + // ── Context menu state ── + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null); + + // Compute overlay-scrollbar thumb height + offset from the queue's scroll + // metrics. Native scrollbar is hidden via CSS; this thumb floats over the + // items so the queue keeps its full width. + const recomputeScroll = useCallback(() => { + const el = queueScrollRef.current; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + if (scrollHeight <= clientHeight + 1) { + setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev)); + return; + } + const ratio = clientHeight / scrollHeight; + const thumbH = Math.max(24, Math.round(ratio * clientHeight)); + const range = clientHeight - thumbH; + const scrollRange = scrollHeight - clientHeight; + const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0; + setScrollMeta({ thumbH, thumbT, visible: true }); + }, []); + // Announce to main window that we're mounted; it replies with a snapshot. useEffect(() => { emit('mini:ready', {}).catch(() => {}); }, []); + // Re-apply pin state on mount and whenever the window regains focus. + // After a Hide → Show cycle (which is what `open_mini_player` does on + // re-toggle) the WM often drops the always-on-top constraint silently; + // re-asserting it here means the user no longer has to click the pin + // button twice to make it stick. + useEffect(() => { + invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {}); + const reapply = () => { + if (alwaysOnTop) { + invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {}); + } + }; + window.addEventListener('focus', reapply); + return () => window.removeEventListener('focus', reapply); + }, [alwaysOnTop]); + // Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing. useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -127,13 +207,65 @@ export default function MiniPlayer() { const toggleQueue = async () => { const next = !queueOpen; + // Capture the current expanded height before collapsing so the next + // open restores it. Read window.innerHeight directly — it matches the + // logical inner size that resize_mini_player set previously. + if (!next) { + const h = Math.round(window.innerHeight); + if (h >= EXPANDED_MIN.h) { + try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {} + } + } setQueueOpen(next); - const size = next ? EXPANDED_SIZE : COLLAPSED_SIZE; - try { await invoke('resize_mini_player', { width: size.w, height: size.h }); } catch {} + const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h; + const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w; + const min = next ? EXPANDED_MIN : COLLAPSED_MIN; + try { + await invoke('resize_mini_player', { + width: targetW, + height: targetH, + minWidth: min.w, + minHeight: min.h, + }); + } catch {} }; const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {}); + // Listen for psy-drop on the queue. Only handles `queue_reorder` payloads + // since the mini player has no external drag sources. `queueOpen` must be + // in deps because the wrap (and thus queueScrollRef.current) only mounts + // when the queue is expanded — without it the ref is null on first run + // and the listener never attaches. + useEffect(() => { + if (!queueOpen) return; + const el = queueScrollRef.current; + if (!el) return; + const onPsyDrop = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail?.data) return; + let parsed: any = null; + try { parsed = JSON.parse(detail.data); } catch { return; } + const tgt = dropTargetRef.current; + dropTargetRef.current = null; + setDropTarget(null); + if (parsed.type !== 'queue_reorder') return; + const fromIdx = parsed.index as number; + psyDragFromIdxRef.current = null; + const queueLen = usePlayerStore.getState().queue.length || state.queue.length; + const insertIdx = tgt + ? (tgt.before ? tgt.idx : tgt.idx + 1) + : queueLen; + if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return; + // Adjust target index if removing the source first shifts later items. + const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx; + if (fromIdx === adjusted) return; + emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {}); + }; + el.addEventListener('psy-drop', onPsyDrop); + return () => el.removeEventListener('psy-drop', onPsyDrop); + }, [queueOpen, state.queue.length]); + // Auto-scroll the current track into view when the queue expands. useEffect(() => { if (!queueOpen) return; @@ -141,6 +273,15 @@ export default function MiniPlayer() { el?.scrollIntoView({ block: 'nearest' }); }, [queueOpen, state.queueIndex]); + // Recompute overlay-thumb on open, queue mutations, and window resize. + useEffect(() => { + if (!queueOpen) return; + recomputeScroll(); + const onResize = () => recomputeScroll(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [queueOpen, state.queue.length, recomputeScroll]); + const { track, isPlaying } = state; const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; @@ -213,26 +354,108 @@ export default function MiniPlayer() {
{queueOpen && ( -
- {state.queue.length === 0 ? ( -
Queue is empty
- ) : ( - state.queue.map((t, i) => ( - - )) +
{ + if (!isReorderDrag || !queueScrollRef.current) return; + const items = queueScrollRef.current.querySelectorAll('[data-mq-idx]'); + for (let i = 0; i < items.length; i++) { + const r = items[i].getBoundingClientRect(); + if (e.clientY >= r.top && e.clientY <= r.bottom) { + const before = e.clientY < r.top + r.height / 2; + const idx = parseInt(items[i].dataset.mqIdx!, 10); + const t = { idx, before }; + dropTargetRef.current = t; + setDropTarget(t); + return; + } + } + dropTargetRef.current = null; + setDropTarget(null); + }} + > +
+ {state.queue.length === 0 ? ( +
Queue is empty
+ ) : ( + state.queue.map((t, i) => { + let dragStyle: React.CSSProperties = {}; + if (isReorderDrag && psyDragFromIdxRef.current === i) { + dragStyle = { opacity: 0.4 }; + } else if (isReorderDrag && dropTarget?.idx === i) { + dragStyle = dropTarget.before + ? { boxShadow: 'inset 0 2px 0 var(--accent)' } + : { boxShadow: 'inset 0 -2px 0 var(--accent)' }; + } + return ( + + ); + }) + )} +
+ {scrollMeta.visible && ( +
)}
)} + + {ctxMenu && ( + setCtxMenu(null)} + /> + )}
); } diff --git a/src/styles/components.css b/src/styles/components.css index 9fc0408f..b90ea3cf 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -9190,17 +9190,47 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active { justify-content: flex-end; } -.mini-queue { +.mini-queue-wrap { grid-column: 1 / -1; grid-row: 3; - overflow-y: auto; - overscroll-behavior: contain; + position: relative; background: var(--bg-card); border-radius: 8px; + overflow: hidden; + min-height: 0; +} + +.mini-queue { + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; padding: 4px; display: flex; flex-direction: column; gap: 1px; + box-sizing: border-box; + /* Hide native scrollbar — we render an overlay thumb beside it. */ + scrollbar-width: none; +} +.mini-queue::-webkit-scrollbar { + display: none; +} + +.mini-queue__thumb { + position: absolute; + top: 4px; + right: 3px; + width: 4px; + border-radius: 2px; + background: var(--text-muted); + opacity: 0; + transition: opacity 0.18s ease; + pointer-events: none; + will-change: transform; +} +.mini-queue-wrap:hover .mini-queue__thumb, +.mini-queue.is-scrolling ~ .mini-queue__thumb { + opacity: 0.55; } .mini-queue__empty { @@ -9228,6 +9258,14 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active { background: var(--bg-hover); } +.mini-queue__item--ctx { + background: var(--bg-hover); +} + +.mini-queue-wrap--drop-active .mini-queue { + scroll-behavior: auto; +} + .mini-queue__item--current { background: var(--accent-dim); color: var(--accent); diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts index 3d9fa5fb..8ad23246 100644 --- a/src/utils/miniPlayerBridge.ts +++ b/src/utils/miniPlayerBridge.ts @@ -120,10 +120,58 @@ export function initMiniPlayerBridgeOnMain(): () => void { if (track) store.playTrack(track, store.queue, true); }); + // PsyDnD reorder forwarded from the mini queue. + const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => { + const store = usePlayerStore.getState(); + const { from, to } = e.payload ?? { from: -1, to: -1 }; + if (from < 0 || from >= store.queue.length) return; + if (to < 0 || to > store.queue.length) return; + if (from === to) return; + store.reorderQueue(from, to); + }); + + // Remove a track at index (context menu → "Remove from queue"). + const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => { + const store = usePlayerStore.getState(); + const idx = e.payload?.index ?? -1; + if (idx < 0 || idx >= store.queue.length) return; + store.removeTrack(idx); + }); + + // Navigate the main app to a route. Used by mini context menu actions + // like "Open Album" / "Go to Artist" — those need the full main UI. + const navigateUnlisten = listen<{ to: string }>('mini:navigate', (e) => { + const to = e.payload?.to; + if (!to) return; + // Surface the main window first so the navigation is visible. + const w = getCurrentWindow(); + w.unminimize().catch(() => {}); + w.show().catch(() => {}); + w.setFocus().catch(() => {}); + // React Router lives in main; route via a custom event the AppShell + // picks up (defined in App.tsx). + window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } })); + }); + + // Open the SongInfo modal in main for a given track id. + const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => { + const id = e.payload?.id; + if (!id) return; + const w = getCurrentWindow(); + w.unminimize().catch(() => {}); + w.show().catch(() => {}); + w.setFocus().catch(() => {}); + usePlayerStore.getState().openSongInfo(id); + }); + return () => { unsub(); readyUnlisten.then(fn => fn()).catch(() => {}); controlUnlisten.then(fn => fn()).catch(() => {}); jumpUnlisten.then(fn => fn()).catch(() => {}); + reorderUnlisten.then(fn => fn()).catch(() => {}); + removeUnlisten.then(fn => fn()).catch(() => {}); + navigateUnlisten.then(fn => fn()).catch(() => {}); + songInfoUnlisten.then(fn => fn()).catch(() => {}); }; }