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:
Psychotoxical
2026-04-19 12:08:05 +02:00
parent 42ad24cce1
commit 2871db9a96
6 changed files with 608 additions and 26 deletions
+36 -1
View File
@@ -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>
+122
View File
@@ -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
View File
@@ -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>
);
}
+41 -3
View File
@@ -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);
+48
View File
@@ -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(() => {});
};
}