diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a2a8cf99..355f6090 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2838,7 +2838,7 @@ fn default_mini_position(app: &tauri::AppHandle) -> Option initialSnapshot().volume); + const [volumeOpen, setVolumeOpen] = useState(false); const ticker = useRef(null); const queueScrollRef = useRef(null); + const volumeWrapRef = useRef(null); // ── PsyDnD reorder ── // Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove @@ -223,6 +235,7 @@ export default function MiniPlayer() { const unSync = listen('mini:sync', (e) => { setState(e.payload); if (e.payload.track?.duration) setDuration(e.payload.track.duration); + if (typeof e.payload.volume === 'number') setVolumeState(e.payload.volume); }); const unProgress = listen('audio:progress', (e) => { setCurrentTime(e.payload.current_time); @@ -239,6 +252,35 @@ export default function MiniPlayer() { 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 next = !alwaysOnTop; setAlwaysOnTop(next); @@ -348,16 +390,6 @@ export default function MiniPlayer() { // action buttons sit right. )} - - - + {track?.artist && ( +
{track.artist}
+ )} + {track?.album && ( +
{track.album}
+ )} + {track?.year && ( +
{track.year}
+ )} -
-
{fmt(currentTime)}
-
-
+
+
+ + {volumeOpen && ( +
+ {Math.round(volume * 100)}% +
{ + 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)); + }} + > +
+
+
+ )}
-
{fmt(duration)}
+ + + + + + + + + + + + + +
{queueOpen && ( @@ -532,6 +664,28 @@ export default function MiniPlayer() {
)} +
+
+ + + +
+ +
+
{fmt(currentTime)}
+
+
+
+
{fmt(duration)}
+
+
+ {ctxMenu && ( ). 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. Win/Linux: full bar with drag region, track title, and action buttons. macOS: slim transparent bar holding only the action buttons; the @@ -9075,26 +9086,27 @@ html.no-compositing .fs-seekbar-played { .mini-player { flex: 1; - display: grid; - grid-template-columns: 84px 1fr; - grid-template-rows: 84px auto; - gap: 8px 10px; + display: flex; + flex-direction: column; + gap: 10px; padding: 10px 12px; overflow: hidden; box-sizing: border-box; min-height: 0; } -.mini-player--queue-open { - grid-template-rows: 84px auto 1fr; +.mini-player__meta { + display: flex; + gap: 12px; + align-items: stretch; + flex-shrink: 0; } .mini-player__art { - grid-column: 1; - grid-row: 1; - aspect-ratio: 1; + flex: 0 0 84px; width: 84px; height: 84px; + aspect-ratio: 1; background: var(--bg-card); border-radius: 8px; overflow: hidden; @@ -9111,31 +9123,25 @@ html.no-compositing .fs-seekbar-played { background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card)); } -.mini-player__body { - grid-column: 2; - grid-row: 1; - padding: 0 2px; - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 4px; +.mini-player__meta-text { + flex: 1; min-width: 0; -} - -.mini-player__titles { display: flex; flex-direction: column; + justify-content: center; gap: 2px; - min-width: 0; } .mini-player__title { - font-size: 13px; + font-size: 14px; font-weight: 600; + color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.mini-player__artist { +.mini-player__artist, +.mini-player__album, +.mini-player__year { font-size: 11px; color: var(--text-muted); white-space: nowrap; @@ -9143,6 +9149,90 @@ html.no-compositing .fs-seekbar-played { 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 { display: flex; align-items: center; @@ -9177,8 +9267,6 @@ html.no-compositing .fs-seekbar-played { } .mini-player__progress { - grid-column: 1 / -1; - grid-row: 2; display: flex; align-items: center; gap: 6px; @@ -9204,13 +9292,13 @@ html.no-compositing .fs-seekbar-played { .mini-queue-wrap { - grid-column: 1 / -1; - grid-row: 3; + flex: 1 1 auto; position: relative; background: var(--bg-card); border-radius: 8px; overflow: hidden; min-height: 0; + margin-top: 10px; } .mini-queue { @@ -9329,20 +9417,31 @@ html.no-compositing .fs-seekbar-played { display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: 30px; + height: 30px; border: none; - border-radius: 4px; - background: transparent; - color: var(--text-muted); - cursor: pointer; -} -.mini-player__tool:hover { + border-radius: 50%; background: var(--bg-hover); 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 { - 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"] *, diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts index 8ad23246..db210a67 100644 --- a/src/utils/miniPlayerBridge.ts +++ b/src/utils/miniPlayerBridge.ts @@ -1,6 +1,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen, emitTo } from '@tauri-apps/api/event'; import { usePlayerStore } from '../store/playerStore'; +import { useAuthStore } from '../store/authStore'; export const MINI_WINDOW_LABEL = 'mini'; @@ -14,6 +15,7 @@ export interface MiniTrackInfo { coverArt?: string; duration?: number; starred?: boolean; + year?: number; } export interface MiniSyncPayload { @@ -21,6 +23,10 @@ export interface MiniSyncPayload { queue: MiniTrackInfo[]; queueIndex: number; isPlaying: boolean; + volume: number; + gaplessEnabled: boolean; + crossfadeEnabled: boolean; + infiniteQueueEnabled: boolean; isMobile: false; } @@ -41,16 +47,22 @@ function toMini(t: any): MiniTrackInfo { coverArt: t.coverArt, duration: t.duration, starred: !!t.starred, + year: t.year, }; } function snapshot(): MiniSyncPayload { const s = usePlayerStore.getState(); + const a = useAuthStore.getState(); return { track: s.currentTrack ? toMini(s.currentTrack) : null, queue: (s.queue ?? []).map(toMini), queueIndex: s.queueIndex ?? 0, isPlaying: s.isPlaying, + volume: s.volume, + gaplessEnabled: !!a.gaplessEnabled, + crossfadeEnabled: !!a.crossfadeEnabled, + infiniteQueueEnabled: !!a.infiniteQueueEnabled, isMobile: false, }; } @@ -71,7 +83,17 @@ export function initMiniPlayerBridgeOnMain(): () => void { const push = () => { const payload = snapshot(); 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; last = key; emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {}); @@ -82,7 +104,18 @@ export function initMiniPlayerBridgeOnMain(): () => void { || state.isPlaying !== prev.isPlaying || state.currentTrack?.starred !== prev.currentTrack?.starred || 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(); } }); @@ -153,6 +186,39 @@ export function initMiniPlayerBridgeOnMain(): () => void { 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. const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => { const id = e.payload?.id; @@ -166,12 +232,18 @@ export function initMiniPlayerBridgeOnMain(): () => void { return () => { unsub(); + unsubAuth(); readyUnlisten.then(fn => fn()).catch(() => {}); controlUnlisten.then(fn => fn()).catch(() => {}); jumpUnlisten.then(fn => fn()).catch(() => {}); reorderUnlisten.then(fn => fn()).catch(() => {}); removeUnlisten.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(() => {}); }; }