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
+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(() => {});
};
}