From 383bbbd75f70a7ee17dc5a7eb053118c77e2e772 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 23:07:45 +0200 Subject: [PATCH] =?UTF-8?q?refactor(mini-player):=20H6=20=E2=80=94=20split?= =?UTF-8?q?=20MiniPlayer.tsx=20820=20=E2=86=92=20218=20LOC=20across=2011?= =?UTF-8?q?=20files=20(#669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(mini-player): H6 — extract helpers + constants Pure code-move: window-size constants, localStorage keys + read helpers, toMini track shape, initialSnapshot, and fmt(seconds) → utils/miniPlayerHelpers.ts. MiniPlayer.tsx: 820 → 745 LOC. * refactor(mini-player): H6 — extract MiniTitlebar + MiniMeta + MiniControls Three small visual subcomponents move into components/miniPlayer/. MiniPlayer drops the now-unused Pin/PinOff/Maximize2/X/Play/Pause/ SkipBack/SkipForward lucide icons and the CachedImage import. MiniPlayer.tsx: 745 → 665 LOC. * refactor(mini-player): H6 — extract MiniToolbar + useMiniVolumePopover The whole toolbar (volume button + portaled popover, shuffle, gapless/ crossfade/infinite, queue toggle) moves into MiniToolbar.tsx. The volume popover open-state + ref/style positioning + outside-click/Escape close move into the useMiniVolumePopover hook. MiniPlayer.tsx: 665 → 492 LOC. * refactor(mini-player): H6 — extract MiniQueue + useMiniQueueDrag The OverlayScrollArea + queue.map block moves into MiniQueue.tsx. The PsyDnD wiring (drop-inside emits mini:reorder, drop-outside emits mini:remove, reorder math collapsing same-position + adjusting for shift) moves into the useMiniQueueDrag hook. MiniPlayer.tsx: 492 → 346 LOC. * refactor(mini-player): H6 — extract useMiniSync + useMiniWindowSetup + useMiniKeyboardShortcuts Three more hooks pull the remaining side-effect islands out of MiniPlayer: - useMiniSync owns mini:ready emit on mount + focus, plus the mini:sync / audio:progress / audio:ended listeners. The hidden-window visibility ref that gates progress now lives inside the hook. - useMiniWindowSetup bundles three small window-bound effects: Linux WebKitGTK smooth-scroll, the cold-start expanded-size restore when queueOpen=true, and always-on-top reapply on mount + focus. - useMiniKeyboardShortcuts moves the keyboard-shortcut bridge wiring. MiniPlayer.tsx: 346 → 218 LOC. --- src/components/MiniPlayer.tsx | 776 +++------------------ src/components/miniPlayer/MiniControls.tsx | 37 + src/components/miniPlayer/MiniMeta.tsx | 41 ++ src/components/miniPlayer/MiniQueue.tsx | 121 ++++ src/components/miniPlayer/MiniTitlebar.tsx | 68 ++ src/components/miniPlayer/MiniToolbar.tsx | 149 ++++ src/hooks/useMiniKeyboardShortcuts.ts | 60 ++ src/hooks/useMiniQueueDrag.ts | 107 +++ src/hooks/useMiniSync.ts | 48 ++ src/hooks/useMiniVolumePopover.ts | 74 ++ src/hooks/useMiniWindowSetup.ts | 52 ++ src/utils/miniPlayerHelpers.ts | 82 +++ 12 files changed, 926 insertions(+), 689 deletions(-) create mode 100644 src/components/miniPlayer/MiniControls.tsx create mode 100644 src/components/miniPlayer/MiniMeta.tsx create mode 100644 src/components/miniPlayer/MiniQueue.tsx create mode 100644 src/components/miniPlayer/MiniTitlebar.tsx create mode 100644 src/components/miniPlayer/MiniToolbar.tsx create mode 100644 src/hooks/useMiniKeyboardShortcuts.ts create mode 100644 src/hooks/useMiniQueueDrag.ts create mode 100644 src/hooks/useMiniSync.ts create mode 100644 src/hooks/useMiniVolumePopover.ts create mode 100644 src/hooks/useMiniWindowSetup.ts create mode 100644 src/utils/miniPlayerHelpers.ts diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index fba12b96..617f68e5 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,105 +1,26 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; -import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { emit, listen } from '@tauri-apps/api/event'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { emit } 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 { usePlayerStore } from '../store/playerStore'; -import { useAuthStore } from '../store/authStore'; -import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; -import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext'; -import { useWindowVisibility } from '../hooks/useWindowVisibility'; -import { IS_LINUX } from '../utils/platform'; +import { registerQueueDragHitTest } from '../contexts/DragDropContext'; 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')}`; -} +import { + COLLAPSED_SIZE, EXPANDED_SIZE, COLLAPSED_MIN, EXPANDED_MIN, + EXPANDED_H_KEY, QUEUE_OPEN_KEY, + readStoredExpandedHeight, readQueueOpen, initialSnapshot, +} from '../utils/miniPlayerHelpers'; +import { MiniTitlebar } from './miniPlayer/MiniTitlebar'; +import { MiniMeta } from './miniPlayer/MiniMeta'; +import { MiniControls } from './miniPlayer/MiniControls'; +import { MiniToolbar } from './miniPlayer/MiniToolbar'; +import { MiniQueue } from './miniPlayer/MiniQueue'; +import { useMiniVolumePopover } from '../hooks/useMiniVolumePopover'; +import { useMiniQueueDrag } from '../hooks/useMiniQueueDrag'; +import { useMiniSync } from '../hooks/useMiniSync'; +import { useMiniWindowSetup } from '../hooks/useMiniWindowSetup'; +import { useMiniKeyboardShortcuts } from '../hooks/useMiniKeyboardShortcuts'; export default function MiniPlayer() { const { t } = useTranslation(); @@ -112,8 +33,6 @@ export default function MiniPlayer() { 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 miniQueueWrapRef = useRef(null); @@ -127,167 +46,34 @@ export default function MiniPlayer() { }; return registerQueueDragHitTest(hitTest); }, [queueOpen]); - const volumeBtnRef = useRef(null); - const volumePopRef = useRef(null); - const [volumePopStyle, setVolumePopStyle] = useState({}); - const hiddenRef = useRef(false); - const isHidden = useWindowVisibility(); - useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]); + const { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef } = useMiniVolumePopover(); - // ── 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]); + const { + isReorderDrag, psyDragFromIdxRef, dropTarget, setDropTarget, dropTargetRef, startDrag, + } = useMiniQueueDrag({ + queueOpen, + miniQueueWrapRef, + queueScrollRef, + fallbackQueueLen: state.queue.length, + }); // ── 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(); - emit('shortcut:run-action', { - action: 'open-mini-player', - source: 'mini-window', - }).catch(() => {}); - return; - } - - if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyZ' || e.key?.toLowerCase() === 'z')) { - e.preventDefault(); - if (e.shiftKey) { - emit('mini:redo-queue', {}).catch(() => {}); - } else { - emit('mini:undo-queue', {}).catch(() => {}); - } - return; - } - - if (e.key === ' ' || e.code === 'Space') { - e.preventDefault(); - emit('shortcut:run-action', { - action: 'play-pause', - source: 'mini-window', - }).catch(() => {}); - } else if (e.key === 'ArrowRight') { - emit('shortcut:run-action', { - action: 'next', - source: 'mini-window', - }).catch(() => {}); - } else if (e.key === 'ArrowLeft') { - emit('shortcut:run-action', { - action: 'prev', - source: 'mini-window', - }).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); - }; - }, []); + useMiniSync({ + onSync: (payload) => { + setState(payload); + if (payload.track?.duration) setDuration(payload.track.duration); + if (typeof payload.volume === 'number') setVolumeState(payload.volume); + }, + onProgress: (ct, d) => { + setCurrentTime(ct); + if (d > 0) setDuration(d); + }, + onEnded: () => setCurrentTime(0), + }); + useMiniWindowSetup(alwaysOnTop, queueOpen); + useMiniKeyboardShortcuts(); const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {}); @@ -301,70 +87,6 @@ export default function MiniPlayer() { 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); @@ -405,71 +127,6 @@ export default function MiniPlayer() { 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]); - - // Drop outside the mini queue strip → remove (same UX as main QueuePanel). - useEffect(() => { - if (!queueOpen) return; - const onDocPsyDrop = (e: Event) => { - const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail; - if (!d?.data) return; - const cx = d.clientX; - const cy = d.clientY; - if (typeof cx !== 'number' || typeof cy !== 'number') return; - let parsed: { type?: string; index?: number } | null = null; - try { - parsed = JSON.parse(d.data); - } catch { - return; - } - if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return; - const wrap = miniQueueWrapRef.current; - if (!wrap) return; - const r = wrap.getBoundingClientRect(); - const inside = - cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom; - if (inside) return; - psyDragFromIdxRef.current = null; - dropTargetRef.current = null; - setDropTarget(null); - emit('mini:remove', { index: parsed.index }).catch(() => {}); - }; - document.addEventListener('psy-drop', onDocPsyDrop); - return () => document.removeEventListener('psy-drop', onDocPsyDrop); - }, [queueOpen]); - // Auto-scroll the current track into view when the queue expands. useEffect(() => { if (!queueOpen) return; @@ -493,317 +150,58 @@ export default function MiniPlayer() { 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 && ( void; +} + +export function MiniControls({ isPlaying, currentTime, duration, progress, control }: Props) { + return ( +
+
+ + + +
+ +
+
{fmt(currentTime)}
+
+
+
+
{fmt(duration)}
+
+
+ ); +} diff --git a/src/components/miniPlayer/MiniMeta.tsx b/src/components/miniPlayer/MiniMeta.tsx new file mode 100644 index 00000000..3e9701d3 --- /dev/null +++ b/src/components/miniPlayer/MiniMeta.tsx @@ -0,0 +1,41 @@ +import CachedImage from '../CachedImage'; +import type { MiniTrackInfo } from '../../utils/miniPlayerBridge'; + +interface Props { + track: MiniTrackInfo | null; + miniCoverSrc: string; + miniCoverKey: string; +} + +export function MiniMeta({ track, miniCoverSrc, miniCoverKey }: Props) { + return ( +
+
+ {track?.coverArt ? ( + + ) : ( +
+ )} +
+ +
+
+ {track?.title ?? '—'} +
+ {track?.artist && ( +
{track.artist}
+ )} + {track?.album && ( +
{track.album}
+ )} + {track?.year && ( +
{track.year}
+ )} +
+
+ ); +} diff --git a/src/components/miniPlayer/MiniQueue.tsx b/src/components/miniPlayer/MiniQueue.tsx new file mode 100644 index 00000000..6913b12d --- /dev/null +++ b/src/components/miniPlayer/MiniQueue.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import type { TFunction } from 'i18next'; +import OverlayScrollArea from '../OverlayScrollArea'; +import type { MiniSyncPayload, MiniTrackInfo } from '../../utils/miniPlayerBridge'; + +type StartDrag = ( + payload: { data: string; label: string }, + x: number, + y: number, +) => void; + +interface Props { + state: MiniSyncPayload; + miniQueueWrapRef: React.RefObject; + queueScrollRef: React.RefObject; + isReorderDrag: boolean; + psyDragFromIdxRef: React.MutableRefObject; + dropTarget: { idx: number; before: boolean } | null; + setDropTarget: (t: { idx: number; before: boolean } | null) => void; + dropTargetRef: React.MutableRefObject<{ idx: number; before: boolean } | null>; + startDrag: StartDrag; + ctxIndex: number | null; + setCtxMenu: (m: { x: number; y: number; track: MiniTrackInfo; index: number } | null) => void; + jumpTo: (index: number) => void; + t: TFunction; +} + +export function MiniQueue({ + state, miniQueueWrapRef, queueScrollRef, isReorderDrag, psyDragFromIdxRef, + dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu, + jumpTo, t, +}: Props) { + return ( + { + 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 target = { idx, before }; + dropTargetRef.current = target; + setDropTarget(target); + return; + } + } + dropTargetRef.current = null; + setDropTarget(null); + }} + > + {state.queue.length === 0 ? ( +
{t('miniPlayer.emptyQueue')}
+ ) : ( + state.queue.map((track, 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 ( + + ); + }) + )} +
+ ); +} diff --git a/src/components/miniPlayer/MiniTitlebar.tsx b/src/components/miniPlayer/MiniTitlebar.tsx new file mode 100644 index 00000000..1f560b2d --- /dev/null +++ b/src/components/miniPlayer/MiniTitlebar.tsx @@ -0,0 +1,68 @@ +import { Maximize2, Pin, PinOff, X } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { IS_LINUX } from '../../utils/platform'; + +interface Props { + trackTitle: string | undefined; + alwaysOnTop: boolean; + toggleOnTop: () => void; + showMain: () => void; + closeMini: () => void; + t: TFunction; +} + +export function MiniTitlebar({ + trackTitle, alwaysOnTop, toggleOnTop, showMain, closeMini, t, +}: Props) { + return ( +
+ {IS_LINUX ? ( + + {trackTitle ?? '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 && ( + + )} +
+ ); +} diff --git a/src/components/miniPlayer/MiniToolbar.tsx b/src/components/miniPlayer/MiniToolbar.tsx new file mode 100644 index 00000000..89a6e441 --- /dev/null +++ b/src/components/miniPlayer/MiniToolbar.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { emit } from '@tauri-apps/api/event'; +import { Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import type { MiniSyncPayload } from '../../utils/miniPlayerBridge'; + +interface Props { + state: MiniSyncPayload; + volume: number; + volumeOpen: boolean; + setVolumeOpen: (updater: boolean | ((v: boolean) => boolean)) => void; + volumeBtnRef: React.RefObject; + volumePopRef: React.RefObject; + volumePopStyle: React.CSSProperties; + handleVolumeChange: (v: number) => void; + toggleMute: () => void; + queueOpen: boolean; + toggleQueue: () => void; + t: TFunction; +} + +export function MiniToolbar({ + state, volume, volumeOpen, setVolumeOpen, volumeBtnRef, volumePopRef, volumePopStyle, + handleVolumeChange, toggleMute, queueOpen, toggleQueue, t, +}: Props) { + return ( +
+
+ + {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, + )} +
+ + + + + + + + + + + + + + +
+ ); +} diff --git a/src/hooks/useMiniKeyboardShortcuts.ts b/src/hooks/useMiniKeyboardShortcuts.ts new file mode 100644 index 00000000..f52a19a1 --- /dev/null +++ b/src/hooks/useMiniKeyboardShortcuts.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import { emit } from '@tauri-apps/api/event'; +import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore'; + +/** Mini-window keyboard shortcuts. Space/Arrow{Left,Right} run the standard + * play-pause/next/prev shortcut actions (source: 'mini-window' so the bridge + * knows it didn't come from main). Ctrl+Z and Ctrl+Shift+Z emit + * mini:undo-queue / mini:redo-queue. The user-configured 'open-mini-player' + * chord is also honoured so the same shortcut that opens the mini from main + * also closes it from here. All shortcuts ignore inputs/textareas/editable + * content. */ +export function useMiniKeyboardShortcuts() { + 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(); + emit('shortcut:run-action', { + action: 'open-mini-player', + source: 'mini-window', + }).catch(() => {}); + return; + } + + if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyZ' || e.key?.toLowerCase() === 'z')) { + e.preventDefault(); + if (e.shiftKey) { + emit('mini:redo-queue', {}).catch(() => {}); + } else { + emit('mini:undo-queue', {}).catch(() => {}); + } + return; + } + + if (e.key === ' ' || e.code === 'Space') { + e.preventDefault(); + emit('shortcut:run-action', { + action: 'play-pause', + source: 'mini-window', + }).catch(() => {}); + } else if (e.key === 'ArrowRight') { + emit('shortcut:run-action', { + action: 'next', + source: 'mini-window', + }).catch(() => {}); + } else if (e.key === 'ArrowLeft') { + emit('shortcut:run-action', { + action: 'prev', + source: 'mini-window', + }).catch(() => {}); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); +} diff --git a/src/hooks/useMiniQueueDrag.ts b/src/hooks/useMiniQueueDrag.ts new file mode 100644 index 00000000..ed0c99f7 --- /dev/null +++ b/src/hooks/useMiniQueueDrag.ts @@ -0,0 +1,107 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { emit } from '@tauri-apps/api/event'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { usePlayerStore } from '../store/playerStore'; + +interface Args { + queueOpen: boolean; + miniQueueWrapRef: React.RefObject; + queueScrollRef: React.RefObject; + fallbackQueueLen: number; +} + +/** Mini-player queue drag/drop wiring. Mirrors QueuePanel's pattern but with + * no external sources — psy-drop on the scroll viewport emits mini:reorder, + * psy-drop outside the wrap emits mini:remove. The reorder math collapses + * same-position drops and adjusts for index-shift after removing the source. */ +export function useMiniQueueDrag({ + queueOpen, miniQueueWrapRef, queueScrollRef, fallbackQueueLen, +}: Args) { + 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]); + + // psy-drop inside the queue strip → mini:reorder. + // 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 || fallbackQueueLen; + const insertIdx = tgt + ? (tgt.before ? tgt.idx : tgt.idx + 1) + : queueLen; + if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return; + 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, fallbackQueueLen, queueScrollRef]); + + // Drop outside the mini queue strip → mini:remove (same UX as main QueuePanel). + useEffect(() => { + if (!queueOpen) return; + const onDocPsyDrop = (e: Event) => { + const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail; + if (!d?.data) return; + const cx = d.clientX; + const cy = d.clientY; + if (typeof cx !== 'number' || typeof cy !== 'number') return; + let parsed: { type?: string; index?: number } | null = null; + try { + parsed = JSON.parse(d.data); + } catch { + return; + } + if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return; + const wrap = miniQueueWrapRef.current; + if (!wrap) return; + const r = wrap.getBoundingClientRect(); + const inside = + cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom; + if (inside) return; + psyDragFromIdxRef.current = null; + dropTargetRef.current = null; + setDropTarget(null); + emit('mini:remove', { index: parsed.index }).catch(() => {}); + }; + document.addEventListener('psy-drop', onDocPsyDrop); + return () => document.removeEventListener('psy-drop', onDocPsyDrop); + }, [queueOpen, miniQueueWrapRef]); + + return { + isReorderDrag, + psyDragFromIdxRef, + dropTarget, + setDropTarget, + dropTargetRef, + startDrag, + }; +} diff --git a/src/hooks/useMiniSync.ts b/src/hooks/useMiniSync.ts new file mode 100644 index 00000000..593b661c --- /dev/null +++ b/src/hooks/useMiniSync.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { emit, listen } from '@tauri-apps/api/event'; +import { useWindowVisibility } from './useWindowVisibility'; +import type { MiniSyncPayload } from '../utils/miniPlayerBridge'; + +interface ProgressPayload { + current_time: number; + duration: number; +} + +interface Args { + onSync: (s: MiniSyncPayload) => void; + onProgress: (currentTime: number, duration: number) => void; + onEnded: () => void; +} + +/** Bridge wiring between the mini webview and the main window / Rust: + * - emits mini:ready on mount + on focus (Windows pre-creates the mini so the + * mount-time emit can race the main bridge; refocus guarantees re-sync) + * - listens for mini:sync, audio:progress (skipped while hidden), audio:ended + * - cleans up on unmount */ +export function useMiniSync({ onSync, onProgress, onEnded }: Args) { + const isHidden = useWindowVisibility(); + const hiddenRef = useRef(false); + useEffect(() => { hiddenRef.current = isHidden; }, [isHidden]); + + useEffect(() => { + emit('mini:ready', {}).catch(() => {}); + const onFocus = () => { emit('mini:ready', {}).catch(() => {}); }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, []); + + useEffect(() => { + const unSync = listen('mini:sync', (e) => onSync(e.payload)); + const unProgress = listen('audio:progress', (e) => { + if (hiddenRef.current || window.__psyHidden) return; + onProgress(e.payload.current_time, e.payload.duration); + }); + const unEnded = listen('audio:ended', () => onEnded()); + return () => { + unSync.then(fn => fn()).catch(() => {}); + unProgress.then(fn => fn()).catch(() => {}); + unEnded.then(fn => fn()).catch(() => {}); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/src/hooks/useMiniVolumePopover.ts b/src/hooks/useMiniVolumePopover.ts new file mode 100644 index 00000000..031a11f8 --- /dev/null +++ b/src/hooks/useMiniVolumePopover.ts @@ -0,0 +1,74 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +/** Manages the open-state, refs, and fixed positioning of the portaled + * mini-player volume popover. The trigger sits inside a short window, so the + * popover flips above when there's not enough room below. Closes on outside + * click or Escape. */ +export function useMiniVolumePopover() { + const [volumeOpen, setVolumeOpen] = useState(false); + const [volumePopStyle, setVolumePopStyle] = useState({}); + const volumeBtnRef = useRef(null); + const volumePopRef = useRef(null); + + 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]); + + 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]); + + return { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef }; +} diff --git a/src/hooks/useMiniWindowSetup.ts b/src/hooks/useMiniWindowSetup.ts new file mode 100644 index 00000000..609edb22 --- /dev/null +++ b/src/hooks/useMiniWindowSetup.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; +import { IS_LINUX } from '../utils/platform'; +import { + EXPANDED_SIZE, EXPANDED_MIN, readStoredExpandedHeight, +} from '../utils/miniPlayerHelpers'; + +/** Three window-bound setup effects bundled together: + * - Linux WebKitGTK smooth-scroll per-window (re-applies after auth hydrates + * so preloaded/hidden mini matches the Settings toggle). + * - Initial expanded-size restore: Rust always builds the window at the + * collapsed size, so on cold start with queueOpen=true we resize once. + * - Always-on-top reapply on mount and on focus: WMs silently drop the + * constraint after Hide/Show cycles, so we re-assert it whenever the user + * actually brings the window to the foreground. */ +export function useMiniWindowSetup(alwaysOnTop: boolean, initialQueueOpen: boolean) { + useEffect(() => { + if (!IS_LINUX) return; + const apply = () => { + invoke('set_linux_webkit_smooth_scrolling', { + enabled: useAuthStore.getState().linuxWebkitKineticScroll, + }).catch(() => {}); + }; + apply(); + return useAuthStore.persist.onFinishHydration(() => { + apply(); + }); + }, []); + + useEffect(() => { + if (!initialQueueOpen) 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 + }, []); + + 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]); +} diff --git a/src/utils/miniPlayerHelpers.ts b/src/utils/miniPlayerHelpers.ts new file mode 100644 index 00000000..d7f6d3e1 --- /dev/null +++ b/src/utils/miniPlayerHelpers.ts @@ -0,0 +1,82 @@ +import { usePlayerStore } from '../store/playerStore'; +import type { MiniSyncPayload, MiniTrackInfo } from './miniPlayerBridge'; + +export const COLLAPSED_SIZE = { w: 340, h: 260 }; +export 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. +export const COLLAPSED_MIN = { w: 320, h: 240 }; +export 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. +export const EXPANDED_H_KEY = 'psysonic_mini_expanded_h'; +export 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). +export const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open'; +export function readQueueOpen(): boolean { + try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; } +} + +export 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). + */ +export 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, + }; + } +} + +export 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')}`; +}