feat(mini-player): floating mini window — early alpha (#162)

A second webview window (label "mini") with a compact player: album art,
title, artist, prev/play/next, progress bar, pin-on-top toggle, expand
back to main, close. Main minimizes on open and restores when the mini
is hidden or closed. Spacebar toggles, arrow keys skip tracks.

Cross-window sync: main window subscribes to playerStore and pushes
`mini:sync` via emitTo on track / play-state change. Audio progress
already broadcasts to all windows via `audio:progress`. Control actions
(prev/next/toggle) come back via `mini:control`; main-window restore
goes through a direct Rust command because WebKitGTK pauses JS in a
minimized webview.

Tiling-WM detection reused from existing `is_tiling_wm()` — always-on-top
is skipped on Hyprland/Sway/i3 since it's ignored there anyway.

Early alpha: no queue expand, no lyrics, no EQ, no drag-snap. Scope kept
deliberately tight for v1; follow-ups planned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 23:32:20 +02:00
parent 72e193cf2c
commit cef2db92cb
8 changed files with 560 additions and 2 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"identifier": "default", "identifier": "default",
"description": "Default capabilities for Psysonic", "description": "Default capabilities for Psysonic",
"platforms": ["linux", "macOS", "windows"], "platforms": ["linux", "macOS", "windows"],
"windows": ["main"], "windows": ["main", "mini"],
"permissions": [ "permissions": [
"core:default", "core:default",
"shell:default", "shell:default",
+111
View File
@@ -2597,6 +2597,103 @@ fn is_tiling_wm_cmd() -> bool {
} }
} }
// ── Mini Player window ──────────────────────────────────────────────────────
// Secondary always-on-top window with minimal playback controls. Uses the
// same frontend bundle as the main window; disambiguated by window label
// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so
// we fall back to a regular window there.
/// Open (or toggle) the mini player window. Creates it on first call; on
/// subsequent calls, hides it if visible, shows + focuses it if hidden.
/// Opening the mini player minimizes the main window; hiding the mini player
/// restores the main window.
#[tauri::command]
fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
let visible = win.is_visible().unwrap_or(false);
if visible {
win.hide().map_err(|e| e.to_string())?;
if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize();
let _ = main.show();
let _ = main.set_focus();
}
} else {
win.show().map_err(|e| e.to_string())?;
let _ = win.set_focus();
if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize();
}
}
return Ok(());
}
let use_always_on_top = {
#[cfg(target_os = "linux")]
{ !is_tiling_wm() }
#[cfg(not(target_os = "linux"))]
{ true }
};
let win = tauri::WebviewWindowBuilder::new(
&app,
"mini",
tauri::WebviewUrl::App("index.html".into()),
)
.title("Psysonic Mini")
.inner_size(340.0, 140.0)
.min_inner_size(320.0, 120.0)
.resizable(true)
.decorations(true)
.always_on_top(use_always_on_top)
.skip_taskbar(false)
.build()
.map_err(|e| format!("failed to build mini player window: {e}"))?;
let _ = win.set_focus();
if let Some(main) = app.get_webview_window("main") {
let _ = main.minimize();
}
Ok(())
}
/// Hide the mini player window if it exists and restore the main window.
/// Does not destroy the mini window so its state is preserved for next open.
#[tauri::command]
fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
win.hide().map_err(|e| e.to_string())?;
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.unminimize();
let _ = main.show();
let _ = main.set_focus();
}
Ok(())
}
/// 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.
#[tauri::command]
fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
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())?;
main.set_focus().map_err(|e| e.to_string())?;
}
Ok(())
}
/// Toggle always-on-top on the mini player window.
#[tauri::command]
fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
if let Some(win) = app.get_webview_window("mini") {
win.set_always_on_top(on_top).map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn run() { pub fn run() {
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup. // Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -2794,6 +2891,16 @@ pub fn run() {
// Let JS decide: minimize to tray or exit, based on user setting. // Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ()); let _ = window.emit("window:close-requested", ());
} }
} else if window.label() == "mini" {
// Native close on the mini: hide instead of destroying so
// state is preserved, and restore the main window.
api.prevent_close();
let _ = window.hide();
if let Some(main) = window.app_handle().get_webview_window("main") {
let _ = main.unminimize();
let _ = main.show();
let _ = main.set_focus();
}
} }
} }
}) })
@@ -2809,6 +2916,10 @@ pub fn run() {
set_linux_webkit_smooth_scrolling, set_linux_webkit_smooth_scrolling,
no_compositing_mode, no_compositing_mode,
is_tiling_wm_cmd, is_tiling_wm_cmd,
open_mini_player,
close_mini_player,
set_mini_player_always_on_top,
show_main_window,
register_global_shortcut, register_global_shortcut,
unregister_global_shortcut, unregister_global_shortcut,
mpris_set_metadata, mpris_set_metadata,
+18
View File
@@ -38,6 +38,8 @@ import InternetRadio from './pages/InternetRadio';
import FolderBrowser from './pages/FolderBrowser'; import FolderBrowser from './pages/FolderBrowser';
import DeviceSync from './pages/DeviceSync'; import DeviceSync from './pages/DeviceSync';
import NowPlayingPage from './pages/NowPlaying'; import NowPlayingPage from './pages/NowPlaying';
import MiniPlayer from './components/MiniPlayer';
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
import FullscreenPlayer from './components/FullscreenPlayer'; import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu'; import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal'; import SongInfoModal from './components/SongInfoModal';
@@ -944,6 +946,12 @@ export default function App() {
const font = useFontStore(s => s.font); const font = useFontStore(s => s.font);
const [exportPickerOpen, setExportPickerOpen] = useState(false); const [exportPickerOpen, setExportPickerOpen] = useState(false);
// Mini Player window: detected via Tauri window label. Rendered without
// router / sidebar / full audio listeners — it just listens for state + sends
// control events. Label is read synchronously from a global set in main.tsx
// so the initial render picks the right tree.
const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini';
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme); document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]); }, [effectiveTheme]);
@@ -952,6 +960,16 @@ export default function App() {
document.documentElement.setAttribute('data-font', font); document.documentElement.setAttribute('data-font', font);
}, [font]); }, [font]);
// Main window only: push playback state to mini window + handle control events.
useEffect(() => {
if (isMiniWindow) return;
return initMiniPlayerBridgeOnMain();
}, [isMiniWindow]);
if (isMiniWindow) {
return <MiniPlayer />;
}
// UI scaling is scoped to .main-content via an inner wrapper (see <main> // UI scaling is scoped to .main-content via an inner wrapper (see <main>
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1 // below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
// because they live in separate grid cells. Document-level zoom is not used // because they live in separate grid cells. Document-level zoom is not used
+151
View File
@@ -0,0 +1,151 @@
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 CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import type { MiniSyncPayload, MiniControlAction } from '../utils/miniPlayerBridge';
interface ProgressPayload {
current_time: number;
duration: number;
}
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) seconds = 0;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function MiniPlayer() {
const [state, setState] = useState<MiniSyncPayload>({ track: null, isPlaying: false, isMobile: false });
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
const ticker = useRef<number | null>(null);
// Announce to main window that we're mounted; it replies with a snapshot.
useEffect(() => {
emit('mini:ready', {}).catch(() => {});
}, []);
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tgt = e.target as HTMLElement | null;
const tag = tgt?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return;
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
emit('mini:control', 'toggle').catch(() => {});
} else if (e.key === 'ArrowRight') {
emit('mini:control', 'next').catch(() => {});
} else if (e.key === 'ArrowLeft') {
emit('mini:control', 'prev').catch(() => {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Subscribe to state + progress from the main window / Rust.
useEffect(() => {
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
setState(e.payload);
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
});
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
setCurrentTime(e.payload.current_time);
if (e.payload.duration > 0) setDuration(e.payload.duration);
});
const unEnded = listen('audio:ended', () => setCurrentTime(0));
return () => {
unSync.then(fn => fn()).catch(() => {});
unProgress.then(fn => fn()).catch(() => {});
unEnded.then(fn => fn()).catch(() => {});
if (ticker.current) window.clearInterval(ticker.current);
};
}, []);
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
const toggleOnTop = async () => {
const next = !alwaysOnTop;
setAlwaysOnTop(next);
try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {}
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch {}
};
const showMain = () => invoke('show_main_window').catch(() => {});
const { track, isPlaying } = state;
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="mini-player">
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
alt={track.album}
/>
) : (
<div className="mini-player__art-fallback" />
)}
</div>
<div className="mini-player__body">
<div className="mini-player__titles">
<div className="mini-player__title" title={track?.title}>
{track?.title ?? '—'}
</div>
<div className="mini-player__artist" title={track?.artist}>
{track?.artist ?? ''}
</div>
</div>
<div className="mini-player__controls">
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
<SkipBack size={16} />
</button>
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')}>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')}>
<SkipForward size={16} />
</button>
</div>
<div className="mini-player__progress">
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
<div className="mini-player__progress-track">
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="mini-player__progress-time">{fmt(duration)}</div>
</div>
</div>
<div className="mini-player__toolbar">
<button
className={`mini-player__tool${alwaysOnTop ? ' mini-player__tool--active' : ''}`}
onClick={toggleOnTop}
data-tooltip={alwaysOnTop ? 'Pin off' : 'Pin on top'}
>
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
</button>
<button className="mini-player__tool" onClick={showMain} data-tooltip="Open main window">
<Maximize2 size={13} />
</button>
<button className="mini-player__tool" onClick={closeMini} data-tooltip="Close">
<X size={13} />
</button>
</div>
</div>
);
}
+13 -1
View File
@@ -2,8 +2,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
PictureInPicture2,
} from 'lucide-react'; } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -310,6 +312,16 @@ export default function PlayerBar() {
<SlidersVertical size={15} /> <SlidersVertical size={15} />
</button> </button>
{/* Mini Player */}
<button
className="player-btn player-btn-sm"
onClick={() => invoke('open_mini_player').catch(() => {})}
aria-label="Mini Player"
data-tooltip="Mini Player"
>
<PictureInPicture2 size={15} />
</button>
{/* Volume */} {/* Volume */}
<div className="player-volume-section"> <div className="player-volume-section">
<button <button
+9
View File
@@ -1,11 +1,20 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { getCurrentWindow } from '@tauri-apps/api/window';
import App from './App'; import App from './App';
import './i18n'; import './i18n';
import './styles/theme.css'; import './styles/theme.css';
import './styles/layout.css'; import './styles/layout.css';
import './styles/components.css'; import './styles/components.css';
// Expose the Tauri window label synchronously so App() can pick its root
// component (main app vs mini player) on first render without flicker.
try {
(window as any).__PSY_WINDOW_LABEL__ = getCurrentWindow().label;
} catch {
(window as any).__PSY_WINDOW_LABEL__ = 'main';
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
+150
View File
@@ -8832,6 +8832,156 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
led-pulse, …) even when the app is minimized, causing constant GPU use. led-pulse, …) even when the app is minimized, causing constant GPU use.
Pausing them cuts that to near zero while hidden without touching the Pausing them cuts that to near zero while hidden without touching the
running Rust/audio threads. */ running Rust/audio threads. */
/* ─ Mini Player window ───────────────────────────────────────────────────── */
.mini-player {
width: 100%;
height: 100vh;
display: grid;
grid-template-columns: 112px 1fr;
grid-template-rows: 1fr auto;
gap: 10px;
padding: 12px;
background: var(--bg-app);
color: var(--text-primary);
overflow: hidden;
user-select: none;
box-sizing: border-box;
}
.mini-player__art {
grid-row: 1 / span 2;
aspect-ratio: 1;
width: 112px;
height: 112px;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.mini-player__art img,
.mini-player__art-fallback {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.mini-player__art-fallback {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
}
.mini-player__body {
padding: 0 2px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.mini-player__titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mini-player__title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player__artist {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player__controls {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 2px;
}
.mini-player__btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background 0.12s;
}
.mini-player__btn:hover {
background: var(--bg-hover);
}
.mini-player__btn--primary {
width: 36px;
height: 36px;
background: var(--accent);
color: var(--ctp-crust);
}
.mini-player__btn--primary:hover {
background: var(--accent);
filter: brightness(1.08);
}
.mini-player__progress {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-muted);
}
.mini-player__progress-time {
font-variant-numeric: tabular-nums;
min-width: 30px;
}
.mini-player__progress-track {
flex: 1;
height: 3px;
border-radius: 2px;
background: var(--ctp-surface1);
overflow: hidden;
}
.mini-player__progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.25s linear;
}
.mini-player__toolbar {
grid-column: 2;
display: flex;
gap: 4px;
justify-content: flex-end;
}
.mini-player__tool {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.mini-player__tool:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.mini-player__tool--active {
color: var(--accent);
}
html[data-app-hidden="true"] *, html[data-app-hidden="true"] *,
html[data-app-hidden="true"] *::before, html[data-app-hidden="true"] *::before,
html[data-app-hidden="true"] *::after { html[data-app-hidden="true"] *::after {
+107
View File
@@ -0,0 +1,107 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore';
export const MINI_WINDOW_LABEL = 'mini';
export interface MiniSyncPayload {
track: {
id: string;
title: string;
artist: string;
album: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
starred?: boolean;
} | null;
isPlaying: boolean;
isMobile: false;
}
export type MiniControlAction =
| 'toggle'
| 'next'
| 'prev'
| 'show-main';
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,
isPlaying: s.isPlaying,
isMobile: false,
};
}
/**
* Bridge initialised on the main window. Pushes track/state changes to the
* mini window whenever they matter, and handles control events coming back
* from the mini window.
*
* Returns a cleanup function.
*/
export function initMiniPlayerBridgeOnMain(): () => void {
// Only run on the main window
if (getCurrentWindow().label !== 'main') return () => {};
// Push state to the mini window on every relevant store change.
let last = '';
const push = () => {
const payload = snapshot();
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}`;
if (key === last) return;
last = key;
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
};
const unsub = usePlayerStore.subscribe((state, prev) => {
if (state.currentTrack?.id !== prev.currentTrack?.id
|| state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred) {
push();
}
});
// Push an initial snapshot whenever a new mini window announces itself.
const readyUnlisten = listen('mini:ready', () => {
last = '';
push();
});
// Receive control actions from the mini window.
const controlUnlisten = listen<MiniControlAction>('mini:control', (e) => {
const action = e.payload;
const store = usePlayerStore.getState();
switch (action) {
case 'toggle': store.togglePlay(); break;
case 'next': store.next(true); break;
case 'prev': store.previous(); break;
case 'show-main': {
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
break;
}
}
});
return () => {
unsub();
readyUnlisten.then(fn => fn()).catch(() => {});
controlUnlisten.then(fn => fn()).catch(() => {});
};
}