mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(miniPlayer): co-locate mini player feature into features/miniPlayer
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Shared open-state, refs, and fixed positioning for a portaled mini-player
|
||||
* popover anchored to a toolbar button. 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. Volume + crossfade popovers share this. */
|
||||
export function useMiniAnchoredPopover(popWidth: number, popHeight: number) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePopStyle = () => {
|
||||
if (!btnRef.current) return;
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < popHeight && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.left + rect.width / 2 - popWidth / 2, 6),
|
||||
window.innerWidth - popWidth - 6,
|
||||
);
|
||||
setPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: popWidth,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePopStyle();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onReposition = () => updatePopStyle();
|
||||
window.addEventListener('resize', onReposition);
|
||||
window.addEventListener('scroll', onReposition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onReposition);
|
||||
window.removeEventListener('scroll', onReposition, true);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (!btnRef.current?.contains(target) && !popRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return { open, setOpen, popStyle, btnRef, popRef };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMiniAnchoredPopover } from './useMiniAnchoredPopover';
|
||||
|
||||
/** Open-state, refs, and fixed positioning of the portaled mini-player
|
||||
* crossfade settings popover (seconds slider + trim-silence toggle). Opened by
|
||||
* right-click on the crossfade toolbar button. */
|
||||
export function useMiniCrossfadePopover() {
|
||||
const { open, setOpen, popStyle, btnRef, popRef } = useMiniAnchoredPopover(190, 120);
|
||||
return {
|
||||
crossfadeOpen: open,
|
||||
setCrossfadeOpen: setOpen,
|
||||
crossfadePopStyle: popStyle,
|
||||
crossfadeBtnRef: btnRef,
|
||||
crossfadePopRef: popRef,
|
||||
};
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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);
|
||||
}, []);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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: { type?: string; index?: number };
|
||||
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().queueItems.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 };
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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
|
||||
}, []);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMiniAnchoredPopover } from './useMiniAnchoredPopover';
|
||||
|
||||
/** Open-state, refs, and fixed positioning of the portaled mini-player volume
|
||||
* popover. Thin wrapper over {@link useMiniAnchoredPopover} that preserves the
|
||||
* `volume*` field names its consumer expects. */
|
||||
export function useMiniVolumePopover() {
|
||||
const { open, setOpen, popStyle, btnRef, popRef } = useMiniAnchoredPopover(40, 150);
|
||||
return {
|
||||
volumeOpen: open,
|
||||
setVolumeOpen: setOpen,
|
||||
volumePopStyle: popStyle,
|
||||
volumeBtnRef: btnRef,
|
||||
volumePopRef: popRef,
|
||||
};
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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/componentHelpers/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]);
|
||||
}
|
||||
Reference in New Issue
Block a user