mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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 <app_config_dir>/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) <noreply@anthropic.com>
This commit is contained in:
+118
-2
@@ -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
|
||||
/// `<app_config_dir>/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<std::path::PathBuf> {
|
||||
app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json"))
|
||||
}
|
||||
|
||||
fn read_mini_pos(app: &tauri::AppHandle) -> Option<MiniPlayerPosition> {
|
||||
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<Mutex<std::time::Instant>> = 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<tauri::PhysicalPosition<i32>> {
|
||||
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<f64>,
|
||||
min_height: Option<f64>,
|
||||
) -> 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();
|
||||
|
||||
+36
-1
@@ -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 <MiniPlayer />;
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<MiniPlayer />
|
||||
<TooltipPortal />
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||
|
||||
@@ -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<HTMLDivElement>(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<void>) => {
|
||||
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(
|
||||
<div
|
||||
ref={ref}
|
||||
className="context-menu mini-context-menu"
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-item"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => run(() => emit('mini:remove', { index }))}
|
||||
>
|
||||
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
{track.albumId && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
|
||||
>
|
||||
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{track.artistId && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
|
||||
>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => run(toggleStar)}>
|
||||
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
|
||||
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
|
||||
>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+243
-20
@@ -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<number | null>(null);
|
||||
const queueScrollRef = useRef<HTMLDivElement>(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<number | null>(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() {
|
||||
</div>
|
||||
|
||||
{queueOpen && (
|
||||
<div className="mini-queue" ref={queueScrollRef}>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="mini-queue__empty">Queue is empty</div>
|
||||
) : (
|
||||
state.queue.map((t, i) => (
|
||||
<button
|
||||
key={`${t.id}-${i}`}
|
||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}`}
|
||||
onClick={() => jumpTo(i)}
|
||||
>
|
||||
<span className="mini-queue__num">{i + 1}</span>
|
||||
<div className="mini-queue__meta">
|
||||
<div className="mini-queue__title">{t.title}</div>
|
||||
<div className="mini-queue__artist">{t.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
<div
|
||||
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
|
||||
onMouseMove={(e) => {
|
||||
if (!isReorderDrag || !queueScrollRef.current) return;
|
||||
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[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);
|
||||
}}
|
||||
>
|
||||
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
|
||||
{state.queue.length === 0 ? (
|
||||
<div className="mini-queue__empty">Queue is empty</div>
|
||||
) : (
|
||||
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 (
|
||||
<button
|
||||
key={`${t.id}-${i}`}
|
||||
data-mq-idx={i}
|
||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
|
||||
onClick={() => jumpTo(i)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
// Don't start drag while a click would also be valid —
|
||||
// the threshold check below upgrades to a drag once
|
||||
// the pointer leaves the deadband.
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDragFromIdxRef.current = i;
|
||||
startDrag(
|
||||
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
|
||||
me.clientX,
|
||||
me.clientY,
|
||||
);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}}
|
||||
style={dragStyle}
|
||||
>
|
||||
<span className="mini-queue__num">{i + 1}</span>
|
||||
<div className="mini-queue__meta">
|
||||
<div className="mini-queue__title">{t.title}</div>
|
||||
<div className="mini-queue__artist">{t.artist}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{scrollMeta.visible && (
|
||||
<div
|
||||
className="mini-queue__thumb"
|
||||
style={{
|
||||
height: `${scrollMeta.thumbH}px`,
|
||||
transform: `translateY(${scrollMeta.thumbT}px)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctxMenu && (
|
||||
<MiniContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
track={ctxMenu.track}
|
||||
index={ctxMenu.index}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user