import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { emit, listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { useTranslation } from 'react-i18next'; import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, MoveRight } from 'lucide-react'; import CachedImage from './CachedImage'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; import { useDragDrop } from '../contexts/DragDropContext'; import { useWindowVisibility } from '../hooks/useWindowVisibility'; import { IS_LINUX } from '../utils/platform'; import MiniContextMenu from './MiniContextMenu'; import OverlayScrollArea from './OverlayScrollArea'; import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge'; const COLLAPSED_SIZE = { w: 340, h: 260 }; const EXPANDED_SIZE = { w: 340, h: 500 }; // 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 // collapse the queue area to nothing while it's still toggled on. const COLLAPSED_MIN = { w: 320, h: 240 }; const EXPANDED_MIN = { w: 320, h: 340 }; // Persist the expanded-window height so reopening the queue restores the // user's preferred size instead of snapping back to EXPANDED_SIZE.h. const EXPANDED_H_KEY = 'psysonic_mini_expanded_h'; function readStoredExpandedHeight(): number { try { const raw = localStorage.getItem(EXPANDED_H_KEY); if (raw) { const n = parseInt(raw, 10); if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n; } } catch {} return EXPANDED_SIZE.h; } // Persist whether the queue panel was open so the next launch restores // the same state. Same scope as the height: localStorage of the mini // webview (shared across mini sessions, separate from the main store). const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open'; function readQueueOpen(): boolean { try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; } } function toMini(t: any): MiniTrackInfo { return { 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, year: t.year, }; } /** * Hydrate from the persisted playerStore so initial paint shows real content * instead of "—" while we wait for the mini:sync event from the main window. * The persisted state covers the cold-start window (webview boot + bundle). */ function initialSnapshot(): MiniSyncPayload { try { const s = usePlayerStore.getState(); return { track: s.currentTrack ? toMini(s.currentTrack) : null, queue: (s.queue ?? []).map(toMini), queueIndex: s.queueIndex ?? 0, isPlaying: s.isPlaying, volume: s.volume ?? 1, gaplessEnabled: false, crossfadeEnabled: false, infiniteQueueEnabled: false, isMobile: false, }; } catch { return { track: null, queue: [], queueIndex: 0, isPlaying: false, volume: 1, gaplessEnabled: false, crossfadeEnabled: false, infiniteQueueEnabled: false, isMobile: false, }; } } 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 { t } = useTranslation(); const [state, setState] = useState(() => initialSnapshot()); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(() => { const initial = initialSnapshot(); return initial.track?.duration ?? 0; }); const [alwaysOnTop, setAlwaysOnTop] = useState(true); const [queueOpen, setQueueOpen] = useState(readQueueOpen); const [volume, setVolumeState] = useState(() => initialSnapshot().volume); const [volumeOpen, setVolumeOpen] = useState(false); const ticker = useRef(null); const queueScrollRef = useRef(null); const volumeBtnRef = useRef(null); const volumePopRef = useRef(null); const [volumePopStyle, setVolumePopStyle] = useState({}); const hiddenRef = useRef(false); const isHidden = useWindowVisibility(); useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]); // ── PsyDnD reorder ── // Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove // on the queue computes a drop indicator, psy-drop emits mini:reorder back // to main where the source-of-truth store lives. const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop(); const psyDragFromIdxRef = useRef(null); const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null); const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null); const isReorderDrag = isPsyDragging && !!psyPayload && (() => { try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; } })(); useEffect(() => { if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } }, [isPsyDragging]); // ── Context menu state ── const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null); // Announce to main window that we're mounted; it replies with a snapshot. // Also re-announce on window focus: on Windows the mini is pre-created at // app startup so the mount-time emit can race past main's bridge before // it has attached its listener. Re-emitting on focus means every actual // open of the mini (user clicks the player-bar icon) triggers a fresh // sync regardless of startup ordering. useEffect(() => { emit('mini:ready', {}).catch(() => {}); const onFocus = () => { emit('mini:ready', {}).catch(() => {}); }; window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); }, []); // Mini is a separate WebKitGTK webview: Rust applies smooth-wheel per window. // Re-send after auth persist hydrates so preloaded/hidden mini matches Settings. useEffect(() => { if (!IS_LINUX) return; const apply = () => { invoke('set_linux_webkit_smooth_scrolling', { enabled: useAuthStore.getState().linuxWebkitKineticScroll, }).catch(() => {}); }; apply(); return useAuthStore.persist.onFinishHydration(() => { apply(); }); }, []); // Restore the expanded window size on initial mount when the queue was // open at the previous app close. Rust always builds the window at the // collapsed size; without this we'd render queueOpen=true into a 180 px // window. Brief jump from collapsed to expanded is unavoidable since // localStorage only lives in JS. useEffect(() => { if (!queueOpen) return; invoke('resize_mini_player', { width: EXPANDED_SIZE.w, height: readStoredExpandedHeight(), minWidth: EXPANDED_MIN.w, minHeight: EXPANDED_MIN.h, }).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Re-apply pin state on mount and whenever the window regains focus. // After a Hide → Show cycle (which is what `open_mini_player` does on // re-toggle) the WM often drops the always-on-top constraint silently; // re-asserting it here means the user no longer has to click the pin // button twice to make it stick. useEffect(() => { invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {}); const reapply = () => { if (alwaysOnTop) { invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {}); } }; window.addEventListener('focus', reapply); return () => window.removeEventListener('focus', reapply); }, [alwaysOnTop]); // Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing. // Also honour the user-configured 'open-mini-player' shortcut so the // same chord that opens the mini from main also closes it from here. useEffect(() => { const onKey = (e: KeyboardEvent) => { const tgt = e.target as HTMLElement | null; const tag = tgt?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return; const openMiniBinding = useKeybindingsStore.getState().bindings['open-mini-player']; if (matchInAppBinding(e, openMiniBinding)) { e.preventDefault(); invoke('open_mini_player').catch(() => {}); 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('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) => { if (hiddenRef.current || window.__psyHidden) return; 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 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); }; // Position the portaled volume popover relative to its trigger button. // Auto-flip above when there is not enough room below (mini window is short). const updateVolumePopStyle = () => { if (!volumeBtnRef.current) return; const rect = volumeBtnRef.current.getBoundingClientRect(); const MARGIN = 6; const POP_W = 40; const POP_H = 150; const spaceBelow = window.innerHeight - rect.bottom - MARGIN; const spaceAbove = rect.top - MARGIN; const useAbove = spaceBelow < POP_H && spaceAbove > spaceBelow; const left = Math.min( Math.max(rect.left + rect.width / 2 - POP_W / 2, 6), window.innerWidth - POP_W - 6, ); setVolumePopStyle({ position: 'fixed', left, width: POP_W, ...(useAbove ? { bottom: window.innerHeight - rect.top + MARGIN } : { top: rect.bottom + MARGIN }), zIndex: 99998, }); }; useLayoutEffect(() => { if (!volumeOpen) return; updateVolumePopStyle(); }, [volumeOpen]); useEffect(() => { if (!volumeOpen) return; const onReposition = () => updateVolumePopStyle(); window.addEventListener('resize', onReposition); window.addEventListener('scroll', onReposition, true); return () => { window.removeEventListener('resize', onReposition); window.removeEventListener('scroll', onReposition, true); }; }, [volumeOpen]); // Close the volume popover on outside click / Escape. The popover is now // portaled, so check both the trigger button and the popover ref. useEffect(() => { if (!volumeOpen) return; const onDown = (e: MouseEvent) => { const target = e.target as Node; if ( !volumeBtnRef.current?.contains(target) && !volumePopRef.current?.contains(target) ) 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); 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 toggleQueue = async () => { const next = !queueOpen; // Capture the current expanded height before collapsing so the next // open restores it. Read window.innerHeight directly — it matches the // logical inner size that resize_mini_player set previously. if (!next) { const h = Math.round(window.innerHeight); if (h >= EXPANDED_MIN.h) { try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {} } } setQueueOpen(next); try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {} const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h; const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w; const min = next ? EXPANDED_MIN : COLLAPSED_MIN; try { await invoke('resize_mini_player', { width: targetW, height: targetH, minWidth: min.w, minHeight: min.h, }); } catch {} }; const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {}); // Listen for psy-drop on the queue. Only handles `queue_reorder` payloads // since the mini player has no external drag sources. `queueOpen` must be // in deps because the wrap (and thus queueScrollRef.current) only mounts // when the queue is expanded — without it the ref is null on first run // and the listener never attaches. useEffect(() => { if (!queueOpen) return; const el = queueScrollRef.current; if (!el) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; let parsed: any = null; try { parsed = JSON.parse(detail.data); } catch { return; } const tgt = dropTargetRef.current; dropTargetRef.current = null; setDropTarget(null); if (parsed.type !== 'queue_reorder') return; const fromIdx = parsed.index as number; psyDragFromIdxRef.current = null; const queueLen = usePlayerStore.getState().queue.length || state.queue.length; const insertIdx = tgt ? (tgt.before ? tgt.idx : tgt.idx + 1) : queueLen; if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return; // Adjust target index if removing the source first shifts later items. const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx; if (fromIdx === adjusted) return; emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {}); }; el.addEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop); }, [queueOpen, state.queue.length]); // Auto-scroll the current track into view when the queue expands. useEffect(() => { if (!queueOpen) return; const el = queueScrollRef.current?.querySelector('.mini-queue__item--current'); el?.scrollIntoView({ block: 'nearest' }); requestAnimationFrame(() => { queueScrollRef.current?.dispatchEvent(new Event('scroll', { bubbles: false })); }); }, [queueOpen, state.queueIndex]); const { track, isPlaying } = state; const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; return (
{IS_LINUX ? ( {track?.title ?? 'Psysonic Mini'} ) : ( // macOS/Windows already render a native titlebar with the window // title + close button; we just need a flexible spacer so the // action buttons sit right. )} {/* macOS + Windows already provide Close via the native titlebar — skip the duplicate so the in-app titlebar stays minimal. */} {IS_LINUX && ( )}
{track?.coverArt ? ( ) : (
)}
{track?.title ?? '—'}
{track?.artist && (
{track.artist}
)} {track?.album && (
{track.album}
)} {track?.year && (
{track.year}
)}
{volumeOpen && createPortal(
{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)); }} >
, document.body, )}
{queueOpen && ( { if (!isReorderDrag || !queueScrollRef.current) return; const items = queueScrollRef.current.querySelectorAll('[data-mq-idx]'); for (let i = 0; i < items.length; i++) { const r = items[i].getBoundingClientRect(); if (e.clientY >= r.top && e.clientY <= r.bottom) { const before = e.clientY < r.top + r.height / 2; const idx = parseInt(items[i].dataset.mqIdx!, 10); const t = { idx, before }; dropTargetRef.current = t; setDropTarget(t); return; } } dropTargetRef.current = null; setDropTarget(null); }} > {state.queue.length === 0 ? (
{t('miniPlayer.emptyQueue')}
) : ( state.queue.map((t, i) => { let dragStyle: React.CSSProperties = {}; if (isReorderDrag && psyDragFromIdxRef.current === i) { dragStyle = { opacity: 0.4 }; } else if (isReorderDrag && dropTarget?.idx === i) { dragStyle = dropTarget.before ? { boxShadow: 'inset 0 2px 0 var(--accent)' } : { boxShadow: 'inset 0 -2px 0 var(--accent)' }; } return ( ); }) )}
)}
{fmt(currentTime)}
{fmt(duration)}
{ctxMenu && ( setCtxMenu(null)} /> )}
); }