mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(mini): queue-style meta block, action toolbar, vertical volume slider
Restructure the MiniPlayer to mirror the main window's queue panel layout: UI changes: - Meta block: cover + title/artist/album/year (year added to MiniTrackInfo) - Action toolbar styled like .queue-round-btn (30px round, accent fill when active), in order: volume, shuffle | gapless, crossfade, infinite | queue - Volume button opens a thin 5px vertical strip slider that drops down (click / drag / wheel to adjust). Right-click on the volume button mutes. - Controls + progress moved to the bottom as a true footer (margin-top: auto), so they stay anchored even with the queue expanded - Queue toggle moved out of the titlebar into the action bar (logically lives with the other queue/playback toggles) - Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout Bridge changes (miniPlayerBridge.ts): - MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled, infiniteQueueEnabled - Bridge now subscribes to authStore in addition to playerStore so toolbar toggle states propagate cross-window - New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade, mini:set-infinite-queue - Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha) so the mini doesn't need to know about both states to act Misc: - Belt-and-suspenders user-select: none on .mini-player-shell * to kill Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through
This commit is contained in:
@@ -2838,7 +2838,7 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPositi
|
|||||||
let m_size = monitor.size();
|
let m_size = monitor.size();
|
||||||
|
|
||||||
let win_w = (340.0 * scale).round() as i32;
|
let win_w = (340.0 * scale).round() as i32;
|
||||||
let win_h = (180.0 * scale).round() as i32;
|
let win_h = (260.0 * scale).round() as i32;
|
||||||
let margin_x = (24.0 * scale).round() as i32;
|
let margin_x = (24.0 * scale).round() as i32;
|
||||||
let margin_y = (56.0 * scale).round() as i32;
|
let margin_y = (56.0 * scale).round() as i32;
|
||||||
|
|
||||||
@@ -2886,8 +2886,8 @@ fn build_mini_player_window(
|
|||||||
tauri::WebviewUrl::App("index.html".into()),
|
tauri::WebviewUrl::App("index.html".into()),
|
||||||
)
|
)
|
||||||
.title("Psysonic Mini")
|
.title("Psysonic Mini")
|
||||||
.inner_size(340.0, 180.0)
|
.inner_size(340.0, 260.0)
|
||||||
.min_inner_size(320.0, 180.0)
|
.min_inner_size(320.0, 240.0)
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.decorations(use_decorations)
|
.decorations(use_decorations)
|
||||||
.always_on_top(use_always_on_top)
|
.always_on_top(use_always_on_top)
|
||||||
|
|||||||
+189
-35
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { emit, listen } from '@tauri-apps/api/event';
|
import { emit, listen } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, ArrowUpToLine } from 'lucide-react';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
@@ -12,13 +12,13 @@ import { IS_LINUX } from '../utils/platform';
|
|||||||
import MiniContextMenu from './MiniContextMenu';
|
import MiniContextMenu from './MiniContextMenu';
|
||||||
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||||
|
|
||||||
const COLLAPSED_SIZE = { w: 340, h: 180 };
|
const COLLAPSED_SIZE = { w: 340, h: 260 };
|
||||||
const EXPANDED_SIZE = { w: 340, h: 440 };
|
const EXPANDED_SIZE = { w: 340, h: 500 };
|
||||||
// Minimum window dimensions per state. When the queue is open the floor must
|
// Minimum window dimensions per state. When the queue is open the floor must
|
||||||
// keep at least two queue rows visible; a stricter min would let the user
|
// keep at least two queue rows visible; a stricter min would let the user
|
||||||
// collapse the queue area to nothing while it's still toggled on.
|
// collapse the queue area to nothing while it's still toggled on.
|
||||||
const COLLAPSED_MIN = { w: 320, h: 180 };
|
const COLLAPSED_MIN = { w: 320, h: 240 };
|
||||||
const EXPANDED_MIN = { w: 320, h: 260 };
|
const EXPANDED_MIN = { w: 320, h: 340 };
|
||||||
|
|
||||||
// Persist the expanded-window height so reopening the queue restores the
|
// Persist the expanded-window height so reopening the queue restores the
|
||||||
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
|
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
|
||||||
@@ -53,6 +53,7 @@ function toMini(t: any): MiniTrackInfo {
|
|||||||
coverArt: t.coverArt,
|
coverArt: t.coverArt,
|
||||||
duration: t.duration,
|
duration: t.duration,
|
||||||
starred: !!t.starred,
|
starred: !!t.starred,
|
||||||
|
year: t.year,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +70,18 @@ function initialSnapshot(): MiniSyncPayload {
|
|||||||
queue: (s.queue ?? []).map(toMini),
|
queue: (s.queue ?? []).map(toMini),
|
||||||
queueIndex: s.queueIndex ?? 0,
|
queueIndex: s.queueIndex ?? 0,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
|
volume: s.volume ?? 1,
|
||||||
|
gaplessEnabled: false,
|
||||||
|
crossfadeEnabled: false,
|
||||||
|
infiniteQueueEnabled: false,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { track: null, queue: [], queueIndex: 0, isPlaying: false, isMobile: false };
|
return {
|
||||||
|
track: null, queue: [], queueIndex: 0, isPlaying: false,
|
||||||
|
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
|
||||||
|
infiniteQueueEnabled: false, isMobile: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +108,11 @@ export default function MiniPlayer() {
|
|||||||
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
||||||
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
|
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
|
||||||
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||||
|
const [volume, setVolumeState] = useState(() => initialSnapshot().volume);
|
||||||
|
const [volumeOpen, setVolumeOpen] = useState(false);
|
||||||
const ticker = useRef<number | null>(null);
|
const ticker = useRef<number | null>(null);
|
||||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const volumeWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// ── PsyDnD reorder ──
|
// ── PsyDnD reorder ──
|
||||||
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
|
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
|
||||||
@@ -223,6 +235,7 @@ export default function MiniPlayer() {
|
|||||||
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
|
const unSync = listen<MiniSyncPayload>('mini:sync', (e) => {
|
||||||
setState(e.payload);
|
setState(e.payload);
|
||||||
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
|
if (e.payload.track?.duration) setDuration(e.payload.track.duration);
|
||||||
|
if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume);
|
||||||
});
|
});
|
||||||
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
|
const unProgress = listen<ProgressPayload>('audio:progress', (e) => {
|
||||||
setCurrentTime(e.payload.current_time);
|
setCurrentTime(e.payload.current_time);
|
||||||
@@ -239,6 +252,35 @@ export default function MiniPlayer() {
|
|||||||
|
|
||||||
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
|
const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {});
|
||||||
|
|
||||||
|
const handleVolumeChange = (v: number) => {
|
||||||
|
const clamped = Math.max(0, Math.min(1, v));
|
||||||
|
setVolumeState(clamped);
|
||||||
|
emit('mini:set-volume', { value: clamped }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
handleVolumeChange(volume === 0 ? 1 : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close the volume popover on outside click / Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!volumeOpen) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (volumeWrapRef.current && !volumeWrapRef.current.contains(e.target as Node)) {
|
||||||
|
setVolumeOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setVolumeOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [volumeOpen]);
|
||||||
|
|
||||||
const toggleOnTop = async () => {
|
const toggleOnTop = async () => {
|
||||||
const next = !alwaysOnTop;
|
const next = !alwaysOnTop;
|
||||||
setAlwaysOnTop(next);
|
setAlwaysOnTop(next);
|
||||||
@@ -348,16 +390,6 @@ export default function MiniPlayer() {
|
|||||||
// action buttons sit right.
|
// action buttons sit right.
|
||||||
<span className="mini-player__titlebar-spacer" />
|
<span className="mini-player__titlebar-spacer" />
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`mini-player__titlebar-btn${queueOpen ? ' mini-player__titlebar-btn--active' : ''}`}
|
|
||||||
onClick={toggleQueue}
|
|
||||||
data-tauri-drag-region="false"
|
|
||||||
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
|
||||||
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
|
||||||
>
|
|
||||||
<ListMusic size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
|
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
|
||||||
@@ -395,6 +427,7 @@ export default function MiniPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
|
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
|
||||||
|
<div className="mini-player__meta">
|
||||||
<div className="mini-player__art">
|
<div className="mini-player__art">
|
||||||
{track?.coverArt ? (
|
{track?.coverArt ? (
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -407,35 +440,134 @@ export default function MiniPlayer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mini-player__body" data-tauri-drag-region="false">
|
<div className="mini-player__meta-text" data-tauri-drag-region="false">
|
||||||
<div className="mini-player__titles">
|
|
||||||
<div className="mini-player__title" title={track?.title}>
|
<div className="mini-player__title" title={track?.title}>
|
||||||
{track?.title ?? '—'}
|
{track?.title ?? '—'}
|
||||||
</div>
|
</div>
|
||||||
<div className="mini-player__artist" title={track?.artist}>
|
{track?.artist && (
|
||||||
{track?.artist ?? ''}
|
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
|
||||||
|
)}
|
||||||
|
{track?.album && (
|
||||||
|
<div className="mini-player__album" title={track.album}>{track.album}</div>
|
||||||
|
)}
|
||||||
|
{track?.year && (
|
||||||
|
<div className="mini-player__year">{track.year}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mini-player__controls">
|
<div className="mini-player__toolbar" data-tauri-drag-region="false">
|
||||||
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
|
<div className="mini-player__volume-wrap" ref={volumeWrapRef}>
|
||||||
<SkipBack size={16} />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
|
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
|
||||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
onClick={() => setVolumeOpen(v => !v)}
|
||||||
</button>
|
onContextMenu={(e) => { e.preventDefault(); toggleMute(); }}
|
||||||
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
|
data-tauri-drag-region="false"
|
||||||
<SkipForward size={16} />
|
data-tooltip={volume === 0 ? t('player.volume') : `${t('player.volume')} ${Math.round(volume * 100)}%`}
|
||||||
|
aria-label={t('player.volume')}
|
||||||
|
>
|
||||||
|
{volume === 0 ? <VolumeX size={13} /> : <Volume2 size={13} />}
|
||||||
</button>
|
</button>
|
||||||
|
{volumeOpen && (
|
||||||
|
<div className="mini-player__volume-popover" data-tauri-drag-region="false">
|
||||||
|
<span className="mini-player__volume-pct">{Math.round(volume * 100)}%</span>
|
||||||
|
<div
|
||||||
|
className="mini-player__volume-bar"
|
||||||
|
role="slider"
|
||||||
|
aria-label={t('player.volume')}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={Math.round(volume * 100)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const setFromY = (clientY: number) => {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const ratio = 1 - (clientY - rect.top) / rect.height;
|
||||||
|
handleVolumeChange(ratio);
|
||||||
|
};
|
||||||
|
setFromY(e.clientY);
|
||||||
|
const onMove = (me: MouseEvent) => setFromY(me.clientY);
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleVolumeChange(volume + (e.deltaY > 0 ? -0.05 : 0.05));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mini-player__volume-bar-fill"
|
||||||
|
style={{ height: `${Math.round(volume * 100)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mini-player__progress" data-tauri-drag-region="false">
|
<button
|
||||||
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
|
type="button"
|
||||||
<div className="mini-player__progress-track">
|
className="mini-player__tool"
|
||||||
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
|
onClick={() => emit('mini:shuffle').catch(() => {})}
|
||||||
</div>
|
disabled={state.queue.length < 2}
|
||||||
<div className="mini-player__progress-time">{fmt(duration)}</div>
|
data-tauri-drag-region="false"
|
||||||
|
data-tooltip={t('queue.shuffle')}
|
||||||
|
aria-label={t('queue.shuffle')}
|
||||||
|
>
|
||||||
|
<Shuffle size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mini-player__toolbar-sep" aria-hidden />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mini-player__tool${state.gaplessEnabled ? ' mini-player__tool--active' : ''}`}
|
||||||
|
onClick={() => emit('mini:set-gapless', { value: !state.gaplessEnabled }).catch(() => {})}
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
data-tooltip={t('queue.gapless')}
|
||||||
|
aria-label={t('queue.gapless')}
|
||||||
|
>
|
||||||
|
<InfinityIcon size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
|
||||||
|
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
data-tooltip={t('queue.crossfade')}
|
||||||
|
aria-label={t('queue.crossfade')}
|
||||||
|
>
|
||||||
|
<Waves size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mini-player__tool${state.infiniteQueueEnabled ? ' mini-player__tool--active' : ''}`}
|
||||||
|
onClick={() => emit('mini:set-infinite-queue', { value: !state.infiniteQueueEnabled }).catch(() => {})}
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
data-tooltip={t('queue.infiniteQueue')}
|
||||||
|
aria-label={t('queue.infiniteQueue')}
|
||||||
|
>
|
||||||
|
<ArrowUpToLine size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mini-player__toolbar-sep" aria-hidden />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`mini-player__tool${queueOpen ? ' mini-player__tool--active' : ''}`}
|
||||||
|
onClick={toggleQueue}
|
||||||
|
data-tauri-drag-region="false"
|
||||||
|
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
||||||
|
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
|
||||||
|
>
|
||||||
|
<ListMusic size={13} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{queueOpen && (
|
{queueOpen && (
|
||||||
@@ -532,6 +664,28 @@ export default function MiniPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mini-player__bottom" data-tauri-drag-region="false">
|
||||||
|
<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')} data-tauri-drag-region="false">
|
||||||
|
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||||
|
</button>
|
||||||
|
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
|
||||||
|
<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>
|
||||||
|
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<MiniContextMenu
|
<MiniContextMenu
|
||||||
x={ctxMenu.x}
|
x={ctxMenu.x}
|
||||||
|
|||||||
+135
-36
@@ -9010,10 +9010,21 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Belt-and-suspenders: WebKit/WebView2 occasionally lets descendants pick up
|
||||||
|
user-select: auto from UA defaults (especially inside <button>). Force it
|
||||||
|
off for every node in the mini so neither mouse-drag nor Ctrl+A can ever
|
||||||
|
highlight track meta or titlebar text. */
|
||||||
|
.mini-player-shell,
|
||||||
|
.mini-player-shell * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Custom in-page titlebar.
|
/* ── Custom in-page titlebar.
|
||||||
Win/Linux: full bar with drag region, track title, and action buttons.
|
Win/Linux: full bar with drag region, track title, and action buttons.
|
||||||
macOS: slim transparent bar holding only the action buttons; the
|
macOS: slim transparent bar holding only the action buttons; the
|
||||||
@@ -9075,26 +9086,27 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
|
|
||||||
.mini-player {
|
.mini-player {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 84px 1fr;
|
flex-direction: column;
|
||||||
grid-template-rows: 84px auto;
|
gap: 10px;
|
||||||
gap: 8px 10px;
|
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player--queue-open {
|
.mini-player__meta {
|
||||||
grid-template-rows: 84px auto 1fr;
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player__art {
|
.mini-player__art {
|
||||||
grid-column: 1;
|
flex: 0 0 84px;
|
||||||
grid-row: 1;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
width: 84px;
|
width: 84px;
|
||||||
height: 84px;
|
height: 84px;
|
||||||
|
aspect-ratio: 1;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -9111,31 +9123,25 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
|
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-player__body {
|
.mini-player__meta-text {
|
||||||
grid-column: 2;
|
flex: 1;
|
||||||
grid-row: 1;
|
|
||||||
padding: 0 2px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.mini-player__titles {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.mini-player__title {
|
.mini-player__title {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.mini-player__artist {
|
.mini-player__artist,
|
||||||
|
.mini-player__album,
|
||||||
|
.mini-player__year {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -9143,6 +9149,90 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-player__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__toolbar-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
margin: 0 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__volume-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__volume-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 6px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__volume-pct {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__volume-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 5px;
|
||||||
|
height: 110px;
|
||||||
|
background: var(--ctp-surface1);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
/* Visual hit-box wider than the strip so dragging is forgiving. */
|
||||||
|
padding: 0 6px;
|
||||||
|
background-clip: content-box;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__volume-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 6px;
|
||||||
|
width: 5px;
|
||||||
|
background: var(--volume-accent, var(--accent));
|
||||||
|
border-radius: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__bottom {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
/* When the queue is closed there is no flex-grow sibling to fill the
|
||||||
|
extra space — push the bottom to the actual bottom of the player so
|
||||||
|
the controls + progress always sit on the floor. When the queue IS
|
||||||
|
open, queue takes flex: 1 and this margin collapses to 0. */
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-player__controls {
|
.mini-player__controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -9177,8 +9267,6 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-player__progress {
|
.mini-player__progress {
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 2;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -9204,13 +9292,13 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
|
|
||||||
|
|
||||||
.mini-queue-wrap {
|
.mini-queue-wrap {
|
||||||
grid-column: 1 / -1;
|
flex: 1 1 auto;
|
||||||
grid-row: 3;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-queue {
|
.mini-queue {
|
||||||
@@ -9329,20 +9417,31 @@ html.no-compositing .fs-seekbar-played {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 30px;
|
||||||
height: 22px;
|
height: 30px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 50%;
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.mini-player__tool:hover {
|
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.mini-player__tool:hover:not(:disabled) {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.mini-player__tool:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.mini-player__tool--active {
|
.mini-player__tool--active {
|
||||||
color: var(--accent);
|
color: var(--ctp-base);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.mini-player__tool--active:hover:not(:disabled) {
|
||||||
|
color: var(--ctp-base);
|
||||||
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-app-hidden="true"] *,
|
html[data-app-hidden="true"] *,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
export const MINI_WINDOW_LABEL = 'mini';
|
export const MINI_WINDOW_LABEL = 'mini';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export interface MiniTrackInfo {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
starred?: boolean;
|
starred?: boolean;
|
||||||
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MiniSyncPayload {
|
export interface MiniSyncPayload {
|
||||||
@@ -21,6 +23,10 @@ export interface MiniSyncPayload {
|
|||||||
queue: MiniTrackInfo[];
|
queue: MiniTrackInfo[];
|
||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
volume: number;
|
||||||
|
gaplessEnabled: boolean;
|
||||||
|
crossfadeEnabled: boolean;
|
||||||
|
infiniteQueueEnabled: boolean;
|
||||||
isMobile: false;
|
isMobile: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,16 +47,22 @@ function toMini(t: any): MiniTrackInfo {
|
|||||||
coverArt: t.coverArt,
|
coverArt: t.coverArt,
|
||||||
duration: t.duration,
|
duration: t.duration,
|
||||||
starred: !!t.starred,
|
starred: !!t.starred,
|
||||||
|
year: t.year,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(): MiniSyncPayload {
|
function snapshot(): MiniSyncPayload {
|
||||||
const s = usePlayerStore.getState();
|
const s = usePlayerStore.getState();
|
||||||
|
const a = useAuthStore.getState();
|
||||||
return {
|
return {
|
||||||
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
track: s.currentTrack ? toMini(s.currentTrack) : null,
|
||||||
queue: (s.queue ?? []).map(toMini),
|
queue: (s.queue ?? []).map(toMini),
|
||||||
queueIndex: s.queueIndex ?? 0,
|
queueIndex: s.queueIndex ?? 0,
|
||||||
isPlaying: s.isPlaying,
|
isPlaying: s.isPlaying,
|
||||||
|
volume: s.volume,
|
||||||
|
gaplessEnabled: !!a.gaplessEnabled,
|
||||||
|
crossfadeEnabled: !!a.crossfadeEnabled,
|
||||||
|
infiniteQueueEnabled: !!a.infiniteQueueEnabled,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,7 +83,17 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
const push = () => {
|
const push = () => {
|
||||||
const payload = snapshot();
|
const payload = snapshot();
|
||||||
const queueIds = payload.queue.map(q => q.id).join(',');
|
const queueIds = payload.queue.map(q => q.id).join(',');
|
||||||
const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}|${payload.queueIndex}|${queueIds}`;
|
const key = [
|
||||||
|
payload.track?.id ?? '',
|
||||||
|
payload.isPlaying,
|
||||||
|
payload.track?.starred ?? '',
|
||||||
|
payload.queueIndex,
|
||||||
|
payload.volume,
|
||||||
|
payload.gaplessEnabled,
|
||||||
|
payload.crossfadeEnabled,
|
||||||
|
payload.infiniteQueueEnabled,
|
||||||
|
queueIds,
|
||||||
|
].join('|');
|
||||||
if (key === last) return;
|
if (key === last) return;
|
||||||
last = key;
|
last = key;
|
||||||
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
|
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
|
||||||
@@ -82,7 +104,18 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
|| state.isPlaying !== prev.isPlaying
|
|| state.isPlaying !== prev.isPlaying
|
||||||
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|
||||||
|| state.queueIndex !== prev.queueIndex
|
|| state.queueIndex !== prev.queueIndex
|
||||||
|| state.queue !== prev.queue) {
|
|| state.queue !== prev.queue
|
||||||
|
|| state.volume !== prev.volume) {
|
||||||
|
push();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar toggles (gapless / crossfade / infinite queue) live in authStore;
|
||||||
|
// subscribe so changes from the main window propagate to the mini.
|
||||||
|
const unsubAuth = useAuthStore.subscribe((state, prev) => {
|
||||||
|
if (state.gaplessEnabled !== prev.gaplessEnabled
|
||||||
|
|| state.crossfadeEnabled !== prev.crossfadeEnabled
|
||||||
|
|| state.infiniteQueueEnabled !== prev.infiniteQueueEnabled) {
|
||||||
push();
|
push();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -153,6 +186,39 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
|
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Volume changes from the mini's vertical slider.
|
||||||
|
const volumeUnlisten = listen<{ value: number }>('mini:set-volume', (e) => {
|
||||||
|
const v = e.payload?.value;
|
||||||
|
if (typeof v !== 'number') return;
|
||||||
|
usePlayerStore.getState().setVolume(Math.max(0, Math.min(1, v)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar actions from the mini.
|
||||||
|
const shuffleUnlisten = listen('mini:shuffle', () => {
|
||||||
|
usePlayerStore.getState().shuffleQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gapless ↔ Crossfade are mutually exclusive (see CLAUDE.md). Bridge handles
|
||||||
|
// the exclusion so the mini doesn't need to know about both states to act.
|
||||||
|
const gaplessUnlisten = listen<{ value: boolean }>('mini:set-gapless', (e) => {
|
||||||
|
const v = !!e.payload?.value;
|
||||||
|
const a = useAuthStore.getState();
|
||||||
|
if (v) a.setCrossfadeEnabled(false);
|
||||||
|
a.setGaplessEnabled(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
const crossfadeUnlisten = listen<{ value: boolean }>('mini:set-crossfade', (e) => {
|
||||||
|
const v = !!e.payload?.value;
|
||||||
|
const a = useAuthStore.getState();
|
||||||
|
if (v) a.setGaplessEnabled(false);
|
||||||
|
a.setCrossfadeEnabled(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
const infiniteQueueUnlisten = listen<{ value: boolean }>('mini:set-infinite-queue', (e) => {
|
||||||
|
const v = !!e.payload?.value;
|
||||||
|
useAuthStore.getState().setInfiniteQueueEnabled(v);
|
||||||
|
});
|
||||||
|
|
||||||
// Open the SongInfo modal in main for a given track id.
|
// Open the SongInfo modal in main for a given track id.
|
||||||
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
|
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
|
||||||
const id = e.payload?.id;
|
const id = e.payload?.id;
|
||||||
@@ -166,12 +232,18 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
|
unsubAuth();
|
||||||
readyUnlisten.then(fn => fn()).catch(() => {});
|
readyUnlisten.then(fn => fn()).catch(() => {});
|
||||||
controlUnlisten.then(fn => fn()).catch(() => {});
|
controlUnlisten.then(fn => fn()).catch(() => {});
|
||||||
jumpUnlisten.then(fn => fn()).catch(() => {});
|
jumpUnlisten.then(fn => fn()).catch(() => {});
|
||||||
reorderUnlisten.then(fn => fn()).catch(() => {});
|
reorderUnlisten.then(fn => fn()).catch(() => {});
|
||||||
removeUnlisten.then(fn => fn()).catch(() => {});
|
removeUnlisten.then(fn => fn()).catch(() => {});
|
||||||
navigateUnlisten.then(fn => fn()).catch(() => {});
|
navigateUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
volumeUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
shuffleUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
gaplessUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
crossfadeUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
infiniteQueueUnlisten.then(fn => fn()).catch(() => {});
|
||||||
songInfoUnlisten.then(fn => fn()).catch(() => {});
|
songInfoUnlisten.then(fn => fn()).catch(() => {});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user