mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user