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
+60
View File
@@ -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);
}, []);
}
+107
View File
@@ -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<HTMLDivElement | null>;
queueScrollRef: React.RefObject<HTMLDivElement | null>;
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<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]);
// 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,
};
}
+48
View File
@@ -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<MiniSyncPayload>('mini:sync', (e) => onSync(e.payload));
const unProgress = listen<ProgressPayload>('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
}, []);
}
+74
View File
@@ -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<React.CSSProperties>({});
const volumeBtnRef = useRef<HTMLButtonElement>(null);
const volumePopRef = useRef<HTMLDivElement>(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 };
}
+52
View File
@@ -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]);
}