import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; 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 { registerQueueDragHitTest } from '../contexts/DragDropContext'; import MiniContextMenu from './MiniContextMenu'; import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge'; import { COLLAPSED_SIZE, EXPANDED_SIZE, COLLAPSED_MIN, EXPANDED_MIN, EXPANDED_H_KEY, QUEUE_OPEN_KEY, readStoredExpandedHeight, readQueueOpen, initialSnapshot, } from '../utils/componentHelpers/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(); 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 queueScrollRef = useRef(null); const miniQueueWrapRef = useRef(null); useEffect(() => { if (!queueOpen) return; const hitTest = (cx: number, cy: number) => { const el = miniQueueWrapRef.current; if (!el) return false; const r = el.getBoundingClientRect(); return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom; }; return registerQueueDragHitTest(hitTest); }, [queueOpen]); const { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef } = useMiniVolumePopover(); 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); 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(() => {}); 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); }; 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(() => {}); // 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 miniCoverSrc = useMemo( () => (track?.coverArt ? buildCoverArtUrl(track.coverArt, 300) : ''), [track?.coverArt], ); const miniCoverKey = useMemo( () => (track?.coverArt ? coverArtCacheKey(track.coverArt, 300) : ''), [track?.coverArt], ); const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; return (
{queueOpen && ( )} {ctxMenu && ( setCtxMenu(null)} /> )}
); }