mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
9606a99efb
Seven domain-eng splits peel ~200 LOC of read endpoints out of `api/subsonic.ts`: - `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`, `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders for the four /rest endpoints we hand to the browser). - `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`, `setRating`, `probeEntityRatingSupport`. `setRating` still triggers the lazy `navidromeBrowse` cache invalidation; the same-folder lazy import path is preserved. - `subsonicSearch.ts` — `search`, `searchSongsPaged`. - `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`, `getNowPlaying`. - `subsonicAlbumInfo.ts` — `getAlbumInfo2`. - `subsonicLyrics.ts` — `getLyricsBySongId`. - `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`. 63 external call sites migrated to direct imports. Four `vi.mock` targets in the store-level tests pointed at `../api/subsonic` and were updated to the new module paths. Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
821 lines
32 KiB
TypeScript
821 lines
32 KiB
TypeScript
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 { 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 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<MiniSyncPayload>(() => 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<number | null>(null);
|
|
const queueScrollRef = useRef<HTMLDivElement>(null);
|
|
const miniQueueWrapRef = useRef<HTMLDivElement>(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 volumeBtnRef = useRef<HTMLButtonElement>(null);
|
|
const volumePopRef = useRef<HTMLDivElement>(null);
|
|
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
|
|
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<number | null>(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();
|
|
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<MiniSyncPayload>('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<ProgressPayload>('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]);
|
|
|
|
// 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;
|
|
const el = queueScrollRef.current?.querySelector<HTMLElement>('.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 (
|
|
<div className="mini-player-shell">
|
|
<div
|
|
className={`mini-player__titlebar${!IS_LINUX ? ' mini-player__titlebar--mac' : ''}`}
|
|
{...(!IS_LINUX ? {} : { 'data-tauri-drag-region': true })}
|
|
>
|
|
{IS_LINUX ? (
|
|
<span className="mini-player__titlebar-title" data-tauri-drag-region>
|
|
{track?.title ?? 'Psysonic Mini'}
|
|
</span>
|
|
) : (
|
|
// 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.
|
|
<span className="mini-player__titlebar-spacer" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
|
|
onClick={toggleOnTop}
|
|
data-tauri-drag-region="false"
|
|
data-tooltip={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
|
|
aria-label={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
|
|
>
|
|
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="mini-player__titlebar-btn"
|
|
onClick={showMain}
|
|
data-tauri-drag-region="false"
|
|
data-tooltip={t('miniPlayer.openMainWindow')}
|
|
aria-label={t('miniPlayer.openMainWindow')}
|
|
>
|
|
<Maximize2 size={13} />
|
|
</button>
|
|
{/* macOS + Windows already provide Close via the native titlebar —
|
|
skip the duplicate so the in-app titlebar stays minimal. */}
|
|
{IS_LINUX && (
|
|
<button
|
|
type="button"
|
|
className="mini-player__titlebar-btn mini-player__titlebar-btn--close"
|
|
onClick={closeMini}
|
|
data-tauri-drag-region="false"
|
|
data-tooltip={t('miniPlayer.close')}
|
|
aria-label={t('miniPlayer.close')}
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
|
|
<div className="mini-player__meta">
|
|
<div className="mini-player__art">
|
|
{track?.coverArt ? (
|
|
<CachedImage
|
|
src={miniCoverSrc}
|
|
cacheKey={miniCoverKey}
|
|
alt={track.album}
|
|
/>
|
|
) : (
|
|
<div className="mini-player__art-fallback" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="mini-player__meta-text" data-tauri-drag-region="false">
|
|
<div className="mini-player__title" title={track?.title}>
|
|
{track?.title ?? '—'}
|
|
</div>
|
|
{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 className="mini-player__toolbar" data-tauri-drag-region="false">
|
|
<div className="mini-player__volume-wrap">
|
|
<button
|
|
ref={volumeBtnRef}
|
|
type="button"
|
|
className={`mini-player__tool${volumeOpen ? ' mini-player__tool--active' : ''}`}
|
|
onClick={() => setVolumeOpen(v => !v)}
|
|
onContextMenu={(e) => { e.preventDefault(); toggleMute(); }}
|
|
data-tauri-drag-region="false"
|
|
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>
|
|
{volumeOpen && createPortal(
|
|
<div
|
|
ref={volumePopRef}
|
|
className="mini-player__volume-popover"
|
|
style={volumePopStyle}
|
|
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>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="mini-player__tool"
|
|
onClick={() => emit('mini:shuffle').catch(() => {})}
|
|
disabled={state.queue.length < 2}
|
|
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')}
|
|
>
|
|
<MoveRight 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')}
|
|
>
|
|
<InfinityIcon 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>
|
|
|
|
{queueOpen && (
|
|
<OverlayScrollArea
|
|
wrapRef={miniQueueWrapRef}
|
|
viewportRef={queueScrollRef}
|
|
className="mini-queue-wrap"
|
|
viewportClassName="mini-queue"
|
|
measureDeps={[queueOpen, state.queue.length]}
|
|
railInset="mini"
|
|
viewportScrollBehaviorAuto={isReorderDrag}
|
|
onMouseMove={(e) => {
|
|
if (!isReorderDrag || !queueScrollRef.current) return;
|
|
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[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 ? (
|
|
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
|
|
) : (
|
|
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 (
|
|
<button
|
|
key={`${t.id}-${i}`}
|
|
data-mq-idx={i}
|
|
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
|
|
onClick={() => jumpTo(i)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (e.button !== 0) return;
|
|
// Don't start drag while a click would also be valid —
|
|
// the threshold check below upgrades to a drag once
|
|
// the pointer leaves the deadband.
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
const onMove = (me: MouseEvent) => {
|
|
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
psyDragFromIdxRef.current = i;
|
|
startDrag(
|
|
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
|
|
me.clientX,
|
|
me.clientY,
|
|
);
|
|
}
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}}
|
|
style={dragStyle}
|
|
>
|
|
<span className="mini-queue__num">{i + 1}</span>
|
|
<div className="mini-queue__meta">
|
|
<div className="mini-queue__title">{t.title}</div>
|
|
<div className="mini-queue__artist">{t.artist}</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</OverlayScrollArea>
|
|
)}
|
|
|
|
<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 && (
|
|
<MiniContextMenu
|
|
x={ctxMenu.x}
|
|
y={ctxMenu.y}
|
|
track={ctxMenu.track}
|
|
index={ctxMenu.index}
|
|
onClose={() => setCtxMenu(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|