refactor(mini-player): H6 — split MiniPlayer.tsx 820 → 218 LOC across 11 files (#669)

* 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.
This commit is contained in:
Frank Stellmacher
2026-05-13 23:07:45 +02:00
committed by GitHub
parent 463d3e0c5b
commit 383bbbd75f
12 changed files with 926 additions and 689 deletions
@@ -0,0 +1,37 @@
import { Pause, Play, SkipBack, SkipForward } from 'lucide-react';
import { fmt } from '../../utils/miniPlayerHelpers';
import type { MiniControlAction } from '../../utils/miniPlayerBridge';
interface Props {
isPlaying: boolean;
currentTime: number;
duration: number;
progress: number;
control: (action: MiniControlAction) => void;
}
export function MiniControls({ isPlaying, currentTime, duration, progress, control }: Props) {
return (
<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>
);
}
+41
View File
@@ -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 (
<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>
);
}
+121
View File
@@ -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<HTMLDivElement | null>;
queueScrollRef: React.RefObject<HTMLDivElement | null>;
isReorderDrag: boolean;
psyDragFromIdxRef: React.MutableRefObject<number | null>;
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 (
<OverlayScrollArea
wrapRef={miniQueueWrapRef}
viewportRef={queueScrollRef}
className="mini-queue-wrap"
viewportClassName="mini-queue"
measureDeps={[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 target = { idx, before };
dropTargetRef.current = target;
setDropTarget(target);
return;
}
}
dropTargetRef.current = null;
setDropTarget(null);
}}
>
{state.queue.length === 0 ? (
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : (
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 (
<button
key={`${track.id}-${i}`}
data-mq-idx={i}
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
onClick={() => jumpTo(i)}
onContextMenu={(e) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY, track, 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: track.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">{track.title}</div>
<div className="mini-queue__artist">{track.artist}</div>
</div>
</button>
);
})
)}
</OverlayScrollArea>
);
}
@@ -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 (
<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>
{trackTitle ?? '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>
);
}
+149
View File
@@ -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<HTMLButtonElement | null>;
volumePopRef: React.RefObject<HTMLDivElement | null>;
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 (
<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>
);
}