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