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
+1
View File
@@ -36,6 +36,7 @@
"core:window:allow-is-fullscreen",
"core:window:allow-start-dragging",
"core:window:allow-create",
"core:window:allow-set-size",
"core:webview:allow-create-webview-window",
"process:allow-restart",
"updater:default"
+23 -4
View File
@@ -2641,8 +2641,8 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
tauri::WebviewUrl::App("index.html".into()),
)
.title("Psysonic Mini")
.inner_size(340.0, 140.0)
.min_inner_size(320.0, 120.0)
.inner_size(340.0, 180.0)
.min_inner_size(320.0, 180.0)
.resizable(true)
.decorations(true)
.always_on_top(use_always_on_top)
@@ -2674,9 +2674,13 @@ fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
/// Unminimize + show + focus the main window. Called from the mini player's
/// "expand" button. Can't rely on a JS event bridge here because the main
/// window's JS is paused while minimized on WebKitGTK.
/// window's JS is paused while minimized on WebKitGTK. Also hides the mini
/// window so the two don't sit on screen at the same time.
#[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.hide();
}
if let Some(main) = app.get_webview_window("main") {
main.unminimize().map_err(|e| e.to_string())?;
main.show().map_err(|e| e.to_string())?;
@@ -2694,6 +2698,16 @@ fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<
Ok(())
}
/// Resize the mini player window (logical pixels). Used when toggling the
/// queue panel to expand/collapse without a capability dance.
#[tauri::command]
fn resize_mini_player(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn run() {
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")]
@@ -2721,7 +2735,11 @@ pub fn run() {
.manage(TrayState::default())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_denylist(&["mini"])
.build()
)
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
@@ -2919,6 +2937,7 @@ pub fn run() {
open_mini_player,
close_mini_player,
set_mini_player_always_on_top,
resize_mini_player,
show_main_window,
register_global_shortcut,
unregister_global_shortcut,
+93 -6
View File
@@ -1,11 +1,48 @@
import React, { useEffect, useRef, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X } from 'lucide-react';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import type { MiniSyncPayload, MiniControlAction } from '../utils/miniPlayerBridge';
import { usePlayerStore } from '../store/playerStore';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
const COLLAPSED_SIZE = { w: 340, h: 180 };
const EXPANDED_SIZE = { w: 340, h: 440 };
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,
};
}
/**
* Hydrate from the persisted playerStore so initial paint shows real content
* instead of "—" while we wait for the mini:sync event from the main window.
* The persisted state covers the cold-start window (webview boot + bundle).
*/
function initialSnapshot(): MiniSyncPayload {
try {
const s = usePlayerStore.getState();
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
isPlaying: s.isPlaying,
isMobile: false,
};
} catch {
return { track: null, queue: [], queueIndex: 0, isPlaying: false, isMobile: false };
}
}
interface ProgressPayload {
current_time: number;
@@ -20,11 +57,16 @@ function fmt(seconds: number): string {
}
export default function MiniPlayer() {
const [state, setState] = useState<MiniSyncPayload>({ track: null, isPlaying: false, isMobile: false });
const [state, setState] = useState<MiniSyncPayload>(() => initialSnapshot());
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [duration, setDuration] = useState(() => {
const initial = initialSnapshot();
return initial.track?.duration ?? 0;
});
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const [queueOpen, setQueueOpen] = useState(false);
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
// Announce to main window that we're mounted; it replies with a snapshot.
useEffect(() => {
@@ -83,11 +125,27 @@ export default function MiniPlayer() {
const showMain = () => invoke('show_main_window').catch(() => {});
const toggleQueue = async () => {
const next = !queueOpen;
setQueueOpen(next);
const size = next ? EXPANDED_SIZE : COLLAPSED_SIZE;
try { await invoke('resize_mini_player', { width: size.w, height: size.h }); } catch {}
};
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
// Auto-scroll the current track into view when the queue expands.
useEffect(() => {
if (!queueOpen) return;
const el = queueScrollRef.current?.querySelector<HTMLElement>('.mini-queue__item--current');
el?.scrollIntoView({ block: 'nearest' });
}, [queueOpen, state.queueIndex]);
const { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="mini-player">
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
@@ -132,6 +190,13 @@ export default function MiniPlayer() {
</div>
<div className="mini-player__toolbar">
<button
className={`mini-player__tool${queueOpen ? ' mini-player__tool--active' : ''}`}
onClick={toggleQueue}
data-tooltip={queueOpen ? 'Hide queue' : 'Show queue'}
>
<ListMusic size={13} />
</button>
<button
className={`mini-player__tool${alwaysOnTop ? ' mini-player__tool--active' : ''}`}
onClick={toggleOnTop}
@@ -146,6 +211,28 @@ export default function MiniPlayer() {
<X size={13} />
</button>
</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>
)}
</div>
);
}
+90 -1
View File
@@ -8838,7 +8838,7 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
height: 100vh;
display: grid;
grid-template-columns: 112px 1fr;
grid-template-rows: 1fr auto;
grid-template-rows: auto auto;
gap: 10px;
padding: 12px;
background: var(--bg-app);
@@ -8848,6 +8848,10 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
box-sizing: border-box;
}
.mini-player--queue-open {
grid-template-rows: auto auto 1fr;
}
.mini-player__art {
grid-row: 1 / span 2;
aspect-ratio: 1;
@@ -8962,6 +8966,91 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
gap: 4px;
justify-content: flex-end;
}
.mini-queue {
grid-column: 1 / -1;
grid-row: 3;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-card);
border-radius: 8px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 1px;
}
.mini-queue__empty {
padding: 16px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.mini-queue__item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border: none;
background: transparent;
color: var(--text-primary);
text-align: left;
border-radius: 4px;
cursor: pointer;
min-width: 0;
}
.mini-queue__item:hover {
background: var(--bg-hover);
}
.mini-queue__item--current {
background: var(--accent-dim);
color: var(--accent);
}
.mini-queue__num {
flex-shrink: 0;
width: 20px;
font-size: 10px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
.mini-queue__item--current .mini-queue__num {
color: var(--accent);
font-weight: 600;
}
.mini-queue__meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.mini-queue__title {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-queue__artist {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-queue__item--current .mini-queue__artist {
color: var(--accent);
opacity: 0.8;
}
.mini-player__tool {
display: flex;
align-items: center;
+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(() => {});
};
}