mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
+18
@@ -38,6 +38,8 @@ import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import DeviceSync from './pages/DeviceSync';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import MiniPlayer from './components/MiniPlayer';
|
||||
import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
@@ -944,6 +946,12 @@ export default function App() {
|
||||
const font = useFontStore(s => s.font);
|
||||
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(() => {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
@@ -952,6 +960,16 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-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>
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
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';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -310,6 +312,16 @@ export default function PlayerBar() {
|
||||
<SlidersVertical size={15} />
|
||||
</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 */}
|
||||
<div className="player-volume-section">
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './styles/theme.css';
|
||||
import './styles/layout.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(
|
||||
<StrictMode>
|
||||
<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.
|
||||
Pausing them cuts that to near zero while hidden without touching the
|
||||
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"] *::before,
|
||||
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