mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user