feat(mini-player): expandable queue + UX polish

Adds a queue panel that toggles via a new toolbar button and resizes the
mini window between 340×180 (collapsed) and 340×440 (expanded). Tracks
in the queue are clickable — click jumps to that index via a new
mini:jump event handled in the main-window bridge. The current track
auto-scrolls into view when the queue opens.

Resize uses a new resize_mini_player Rust command (more reliable than
JS setSize, which was silently no-op'ing because the
core:window:allow-set-size capability was missing). That capability is
now granted as well.

The mini player hydrates its initial state from the persisted
playerStore, so real content (cover, title, artist, queue) shows as
soon as React mounts instead of "—" while we wait for the first
mini:sync. Dynamic state (isPlaying, progress) still comes from
events.

Window sizing:
- inner_size bumped to 340×180 so the toolbar row isn't clipped
- mini label added to tauri-plugin-window-state denylist so old saved
  sizes don't come back
- show_main_window now also hides the mini — clicking expand no longer
  leaves both windows on screen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-19 00:46:28 +02:00
parent ab35ef5eb4
commit 0afcc4ab68
5 changed files with 254 additions and 36 deletions
+47 -25
View File
@@ -4,18 +4,22 @@ import { usePlayerStore } from '../store/playerStore';
export const MINI_WINDOW_LABEL = 'mini';
export interface MiniTrackInfo {
id: string;
title: string;
artist: string;
album: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
starred?: boolean;
}
export interface MiniSyncPayload {
track: {
id: string;
title: string;
artist: string;
album: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
starred?: boolean;
} | null;
track: MiniTrackInfo | null;
queue: MiniTrackInfo[];
queueIndex: number;
isPlaying: boolean;
isMobile: false;
}
@@ -26,21 +30,26 @@ export type MiniControlAction =
| 'prev'
| 'show-main';
function toMini(t: any): MiniTrackInfo {
return {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
};
}
function snapshot(): MiniSyncPayload {
const s = usePlayerStore.getState();
const t = s.currentTrack;
return {
track: t ? {
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
} : null,
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
isPlaying: s.isPlaying,
isMobile: false,
};
@@ -61,7 +70,8 @@ export function initMiniPlayerBridgeOnMain(): () => void {
let last = '';
const push = () => {
const payload = snapshot();
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}`;
const queueIds = payload.queue.map(q => q.id).join(',');
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}|${payload.queueIndex}|${queueIds}`;
if (key === last) return;
last = key;
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
@@ -70,7 +80,9 @@ export function initMiniPlayerBridgeOnMain(): () => void {
const unsub = usePlayerStore.subscribe((state, prev) => {
if (state.currentTrack?.id !== prev.currentTrack?.id
|| state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred) {
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|| state.queueIndex !== prev.queueIndex
|| state.queue !== prev.queue) {
push();
}
});
@@ -99,9 +111,19 @@ export function initMiniPlayerBridgeOnMain(): () => void {
}
});
// Jump to a specific queue index.
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1;
if (idx < 0 || idx >= store.queue.length) return;
const track = store.queue[idx];
if (track) store.playTrack(track, store.queue, true);
});
return () => {
unsub();
readyUnlisten.then(fn => fn()).catch(() => {});
controlUnlisten.then(fn => fn()).catch(() => {});
jumpUnlisten.then(fn => fn()).catch(() => {});
};
}