From 0afcc4ab68b945b439ec42e6833e0bb6948f326d Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 19 Apr 2026 00:46:28 +0200 Subject: [PATCH] feat(mini-player): expandable queue + UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 27 ++++++-- src/components/MiniPlayer.tsx | 99 +++++++++++++++++++++++++++-- src/styles/components.css | 91 +++++++++++++++++++++++++- src/utils/miniPlayerBridge.ts | 72 +++++++++++++-------- 5 files changed, 254 insertions(+), 36 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6e9268b2..d478fb91 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6694791f..1417a4eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index c08cf4ca..c8def716 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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({ track: null, isPlaying: false, isMobile: false }); + const [state, setState] = useState(() => 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(null); + const queueScrollRef = useRef(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('.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 ( -
+
{track?.coverArt ? (
+
+ + {queueOpen && ( +
+ {state.queue.length === 0 ? ( +
Queue is empty
+ ) : ( + state.queue.map((t, i) => ( + + )) + )} +
+ )}
); } diff --git a/src/styles/components.css b/src/styles/components.css index 7cd43dc0..94c18f86 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts index a6c0da4c..3d9fa5fb 100644 --- a/src/utils/miniPlayerBridge.ts +++ b/src/utils/miniPlayerBridge.ts @@ -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(() => {}); }; }