refactor(miniPlayer): co-locate mini player feature into features/miniPlayer

This commit is contained in:
Psychotoxical
2026-06-29 23:22:00 +02:00
parent 6dd0d4a130
commit e6269c7b85
21 changed files with 59 additions and 51 deletions
@@ -0,0 +1,122 @@
import { star, unstar } from '@/api/subsonicStarRating';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { emit } from '@tauri-apps/api/event';
import { useTranslation } from 'react-i18next';
import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react';
import type { MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
interface Props {
x: number;
y: number;
track: MiniTrackInfo;
index: number;
onClose: () => void;
}
/**
* Slim queue-item context menu for the mini player. The mini lives in its
* own webview, so all queue mutations forward to the main window via Tauri
* events; only the favorite call hits Subsonic directly because it has no
* cross-window state to keep in sync (next mini:sync from main reflects the
* new starred flag).
*/
export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const [starred, setStarred] = useState(!!track.starred);
const [pos, setPos] = useState({ left: x, top: y });
// Clamp the menu inside the mini window's viewport (it pops near the
// cursor and would otherwise overflow at the right/bottom edges of the
// small window).
useEffect(() => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const left = Math.min(x, Math.max(4, vw - r.width - 4));
const top = Math.min(y, Math.max(4, vh - r.height - 4));
setPos({ left, top });
}, [x, y]);
// Dismiss on outside click + Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
const run = (fn: () => void | Promise<void>) => {
Promise.resolve(fn()).finally(onClose);
};
const toggleStar = async () => {
const next = !starred;
setStarred(next);
try {
if (next) await star(track.id, 'song');
else await unstar(track.id, 'song');
} catch {
setStarred(!next);
}
};
return createPortal(
<div
ref={ref}
className="context-menu mini-context-menu"
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div
className="context-menu-item"
style={{ color: 'var(--danger)' }}
onClick={() => run(() => emit('mini:remove', { index }))}
>
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
{track.albumId && (
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
{track.artistId && (
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
)}
<div className="context-menu-item" onClick={() => run(toggleStar)}>
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
<div className="context-menu-divider" />
<div
className="context-menu-item"
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,37 @@
import { Pause, Play, SkipBack, SkipForward } from 'lucide-react';
import { formatTrackTime } from '@/utils/format/formatDuration';
import type { MiniControlAction } from '@/features/miniPlayer/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">{formatTrackTime(currentTime)}</div>
<div className="mini-player__progress-track">
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="mini-player__progress-time">{formatTrackTime(duration)}</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { emit } from '@tauri-apps/api/event';
import CachedImage from '@/ui/CachedImage';
import { OpenArtistRefInline } from '@/components/OpenArtistRefInline';
import type { MiniTrackInfo } from '@/features/miniPlayer/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?.artists && track.artists.length > 0 ? (
<div className="mini-player__artist" title={track.artists.map(a => a.name).filter(Boolean).join(' · ')}>
<OpenArtistRefInline
refs={track.artists}
fallbackName={track.artist}
onGoArtist={id => { void emit('mini:navigate', { to: `/artist/${id}` }); }}
as="none"
linkTag="span"
linkClassName="mini-player__artist-link"
/>
</div>
) : track?.artist ? (
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
) : null}
{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>
);
}
@@ -0,0 +1,85 @@
/**
* `MiniPlayer` characterization (Phase F5c).
*
* Per pick 4a of the v2 plan: renders + click handlers only. The
* cross-webview bridge contract (`mini:ready` / `mini:sync` emit-listen,
* geometry persistence) is covered separately in B-tier phase B5; jsdom
* does not model two webviews, so an in-process test would only fake
* what we're trying to verify.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
}));
import MiniPlayer from './MiniPlayer';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { onInvoke } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('set_window_always_on_top', () => undefined);
onInvoke('set_linux_webkit_smooth_scrolling', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
describe('MiniPlayer — render', () => {
it('mounts without throwing', () => {
expect(() => renderWithProviders(<MiniPlayer />)).not.toThrow();
});
it('renders the always-present titlebar controls (Pin + Open main window)', () => {
// The Close button is Linux-only — gated on `navigator.platform` and
// therefore not asserted here so the test passes on every jsdom env.
const { getByLabelText } = renderWithProviders(<MiniPlayer />);
expect(getByLabelText('Unpin')).toBeInTheDocument(); // initial state: alwaysOnTop=true
expect(getByLabelText('Open main window')).toBeInTheDocument();
});
});
describe('MiniPlayer — click handlers (no bridge)', () => {
it('clicking Open main window does not throw (bridge emit is a no-op in tests)', () => {
const { getByLabelText } = renderWithProviders(<MiniPlayer />);
expect(() => fireEvent.click(getByLabelText('Open main window'))).not.toThrow();
});
// The Close affordance is Linux-only — covered by manual smoke per pick 4a.
it('clicking the Pin button toggles the alwaysOnTop label', () => {
const { getByLabelText, queryByLabelText } = renderWithProviders(<MiniPlayer />);
// Initial state: alwaysOnTop=true → label is "Unpin".
expect(getByLabelText('Unpin')).toBeInTheDocument();
fireEvent.click(getByLabelText('Unpin'));
// Bridge command is mocked away; the local React state still flips,
// so the label moves to "Pin on top".
expect(queryByLabelText('Pin on top')).toBeInTheDocument();
});
});
@@ -0,0 +1,222 @@
import { usePlaybackCoverArt } from '@/hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { useEffect, useRef, useState } from 'react';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/store/playerStore';
import { registerQueueDragHitTest } from '@/contexts/DragDropContext';
import MiniContextMenu from '@/features/miniPlayer/components/MiniContextMenu';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
import {
COLLAPSED_SIZE, EXPANDED_SIZE, COLLAPSED_MIN, EXPANDED_MIN,
EXPANDED_H_KEY, QUEUE_OPEN_KEY,
readStoredExpandedHeight, readQueueOpen, initialSnapshot,
} from '@/features/miniPlayer/utils/miniPlayerHelpers';
import { MiniTitlebar } from '@/features/miniPlayer/components/MiniTitlebar';
import { MiniMeta } from '@/features/miniPlayer/components/MiniMeta';
import { MiniControls } from '@/features/miniPlayer/components/MiniControls';
import { MiniToolbar } from '@/features/miniPlayer/components/MiniToolbar';
import { MiniQueue } from '@/features/miniPlayer/components/MiniQueue';
import { useMiniVolumePopover } from '@/features/miniPlayer/hooks/useMiniVolumePopover';
import { useMiniCrossfadePopover } from '@/features/miniPlayer/hooks/useMiniCrossfadePopover';
import { useMiniQueueDrag } from '@/features/miniPlayer/hooks/useMiniQueueDrag';
import { useMiniSync } from '@/features/miniPlayer/hooks/useMiniSync';
import { useMiniWindowSetup } from '@/features/miniPlayer/hooks/useMiniWindowSetup';
import { useMiniKeyboardShortcuts } from '@/features/miniPlayer/hooks/useMiniKeyboardShortcuts';
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 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 { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef } = useMiniVolumePopover();
const { crossfadeOpen, setCrossfadeOpen, crossfadePopStyle, crossfadeBtnRef, crossfadePopRef } = useMiniCrossfadePopover();
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);
usePlayerStore.setState({ queueServerId: payload.queueServerId ?? null });
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 { /* ignore: best-effort */ }
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch { /* ignore: best-effort */ }
};
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 { /* ignore: best-effort */ }
}
}
setQueueOpen(next);
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch { /* ignore: best-effort */ }
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 { /* ignore: best-effort */ }
};
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<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 miniCoverRef = usePlaybackTrackCoverRef(track ?? undefined);
const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(miniCoverRef, 300);
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="mini-player-shell">
<MiniTitlebar
trackTitle={track?.title}
alwaysOnTop={alwaysOnTop}
toggleOnTop={toggleOnTop}
showMain={showMain}
closeMini={closeMini}
t={t}
/>
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<MiniMeta track={track} miniCoverSrc={miniCoverSrc} miniCoverKey={miniCoverKey} />
<MiniToolbar
state={state}
volume={volume}
volumeOpen={volumeOpen}
setVolumeOpen={setVolumeOpen}
volumeBtnRef={volumeBtnRef}
volumePopRef={volumePopRef}
volumePopStyle={volumePopStyle}
handleVolumeChange={handleVolumeChange}
toggleMute={toggleMute}
crossfadeOpen={crossfadeOpen}
setCrossfadeOpen={setCrossfadeOpen}
crossfadeBtnRef={crossfadeBtnRef}
crossfadePopRef={crossfadePopRef}
crossfadePopStyle={crossfadePopStyle}
queueOpen={queueOpen}
toggleQueue={toggleQueue}
t={t}
/>
{queueOpen && (
<MiniQueue
state={state}
miniQueueWrapRef={miniQueueWrapRef}
queueScrollRef={queueScrollRef}
isReorderDrag={isReorderDrag}
psyDragFromIdxRef={psyDragFromIdxRef}
dropTarget={dropTarget}
setDropTarget={setDropTarget}
dropTargetRef={dropTargetRef}
startDrag={startDrag}
ctxIndex={ctxMenu?.index ?? null}
setCtxMenu={setCtxMenu}
jumpTo={jumpTo}
t={t}
/>
)}
<MiniControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
progress={progress}
control={control}
/>
{ctxMenu && (
<MiniContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
track={ctxMenu.track}
index={ctxMenu.index}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import type { MiniSyncPayload, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
// Stable initial rect so the virtualizer never re-initializes on re-render (an
// inline literal would be a new ref each render → render loop). Replaced by the
// real height on first ResizeObserver measure.
const MINI_QUEUE_INITIAL_RECT = { width: 0, height: 400 };
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) {
// Virtualize so a multi-thousand-track queue keeps the mini window's DOM at
// O(visible rows). Scroll element is the OverlayScrollArea viewport.
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: state.queue.length,
getScrollElement: () => queueScrollRef.current,
estimateSize: () => 40,
overscan: 10,
getItemKey: i => `${state.queue[i].id}:${i}`,
initialRect: MINI_QUEUE_INITIAL_RECT,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
return (
<OverlayScrollArea
wrapRef={miniQueueWrapRef}
viewportRef={queueScrollRef}
className="mini-queue-wrap"
viewportClassName="mini-queue"
measureDeps={[state.queue.length, totalSize]}
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>
) : (
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const i = vi.index;
const track = state.queue[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={vi.key}
data-index={i}
ref={rowVirtualizer.measureElement}
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={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)`, ...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>
);
})}
</div>
)}
</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>
);
}
@@ -0,0 +1,200 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { emit } from '@tauri-apps/api/event';
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { MiniSyncPayload } from '@/features/miniPlayer/utils/miniPlayerBridge';
import { getTransitionMode } from '@/utils/playback/playbackTransition';
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;
crossfadeOpen: boolean;
setCrossfadeOpen: (updater: boolean | ((v: boolean) => boolean)) => void;
crossfadeBtnRef: React.RefObject<HTMLButtonElement | null>;
crossfadePopRef: React.RefObject<HTMLDivElement | null>;
crossfadePopStyle: React.CSSProperties;
queueOpen: boolean;
toggleQueue: () => void;
t: TFunction;
}
export function MiniToolbar({
state, volume, volumeOpen, setVolumeOpen, volumeBtnRef, volumePopRef, volumePopStyle,
handleVolumeChange, toggleMute,
crossfadeOpen, setCrossfadeOpen, crossfadeBtnRef, crossfadePopRef, crossfadePopStyle,
queueOpen, toggleQueue, t,
}: Props) {
const mode = getTransitionMode(state);
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${mode === 'gapless' ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-transition-mode', { value: mode === 'gapless' ? 'none' : 'gapless' }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<MoveRight size={13} />
</button>
<button
ref={crossfadeBtnRef}
type="button"
className={`mini-player__tool${mode === 'crossfade' || crossfadeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-transition-mode', { value: mode === 'crossfade' ? 'none' : 'crossfade' }).catch(() => {})}
onContextMenu={(e) => { e.preventDefault(); setCrossfadeOpen(v => !v); }}
data-tauri-drag-region="false"
data-tooltip={t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
{crossfadeOpen && createPortal(
<div
ref={crossfadePopRef}
className="mini-player__crossfade-popover"
style={crossfadePopStyle}
data-tauri-drag-region="false"
>
<div className="mini-player__crossfade-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="mini-player__crossfade-value">{(state.crossfadeSecs ?? 3).toFixed(1)} s</span>
</div>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={state.crossfadeSecs ?? 3}
onChange={e => {
emit('mini:set-crossfade-secs', { value: parseFloat(e.target.value) }).catch(() => {});
emit('mini:set-transition-mode', { value: 'crossfade' }).catch(() => {});
}}
className="mini-player__crossfade-slider"
aria-label={t('queue.crossfade')}
/>
</div>,
document.body,
)}
<button
type="button"
className={`mini-player__tool${mode === 'autodj' ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-transition-mode', { value: mode === 'autodj' ? 'none' : 'autodj' }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.autoDj')}
aria-label={t('queue.autoDj')}
>
<Blend 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>
);
}
@@ -0,0 +1,73 @@
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 };
}
@@ -0,0 +1,15 @@
import { useMiniAnchoredPopover } from '@/features/miniPlayer/hooks/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,
};
}
@@ -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);
}, []);
}
@@ -0,0 +1,109 @@
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 effects 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,
};
}
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { useWindowVisibility } from '@/hooks/useWindowVisibility';
import type { MiniSyncPayload } from '@/features/miniPlayer/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
}, []);
}
@@ -0,0 +1,15 @@
import { useMiniAnchoredPopover } from '@/features/miniPlayer/hooks/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,
};
}
@@ -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 '@/features/miniPlayer/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]);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Mini player feature — the compact always-on-top mini-player window UI
* (controls, meta, queue, titlebar, toolbar, context menu) plus the main↔mini
* IPC bridge. The webview entry shell (`app/MiniPlayerApp`) stays in `app/` and
* renders this feature's default export.
*/
export { default } from './components/MiniPlayer';
export { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge';
@@ -0,0 +1,298 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { setTransitionMode, type TransitionMode } from '@/utils/playback/playbackTransition';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import type { SubsonicOpenArtistRef } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
export const MINI_WINDOW_LABEL = 'mini';
export interface MiniTrackInfo {
id: string;
title: string;
artist: string;
/** OpenSubsonic performer refs when the main queue carried them. */
artists?: SubsonicOpenArtistRef[];
album: string;
albumId?: string;
artistId?: string;
coverArt?: string;
duration?: number;
starred?: boolean;
year?: number;
}
export interface MiniSyncPayload {
track: MiniTrackInfo | null;
queue: MiniTrackInfo[];
queueIndex: number;
queueServerId: string | null;
isPlaying: boolean;
volume: number;
gaplessEnabled: boolean;
crossfadeEnabled: boolean;
crossfadeSecs: number;
crossfadeTrimSilence: boolean;
infiniteQueueEnabled: boolean;
isMobile: false;
}
export type MiniControlAction =
| 'toggle'
| 'next'
| 'prev'
| 'show-main';
function toMini(t: Track): MiniTrackInfo {
return {
id: t.id,
title: t.title,
artist: t.artist,
artists: Array.isArray(t.artists) && t.artists.length > 0 ? t.artists : undefined,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
coverArt: t.coverArt,
duration: t.duration,
starred: !!t.starred,
year: t.year,
};
}
/** Cap the queue pushed to the mini at ±100 tracks around the playing song — a
* 50k Artist-Radio queue must not serialize in full over IPC on every push. The
* mini stays slice-relative (no component change); control events (jump/reorder/
* remove) are translated back to absolute indices via {@link miniWindowStart}. */
const MINI_QUEUE_HALF = 100;
let miniWindowStart = 0;
function snapshot(): MiniSyncPayload {
const s = usePlayerStore.getState();
const a = useAuthStore.getState();
const idx = s.queueIndex ?? 0;
const start = Math.max(0, idx - MINI_QUEUE_HALF);
// Thin-state: resolve the windowed slice (resolver cache → placeholder).
const windowed = (s.queueItems ?? [])
.slice(start, idx + MINI_QUEUE_HALF + 1)
.map(r => resolveQueueTrack(r));
miniWindowStart = start;
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: windowed.map(toMini),
queueIndex: idx - start, // local position within the windowed slice
queueServerId: s.queueServerId ?? null,
isPlaying: s.isPlaying,
volume: s.volume,
gaplessEnabled: !!a.gaplessEnabled,
crossfadeEnabled: !!a.crossfadeEnabled,
crossfadeSecs: a.crossfadeSecs,
crossfadeTrimSilence: !!a.crossfadeTrimSilence,
infiniteQueueEnabled: !!a.infiniteQueueEnabled,
isMobile: false,
};
}
/**
* Bridge initialised on the main window. Pushes track/state changes to the
* mini window whenever they matter, and handles control events coming back
* from the mini window.
*
* Returns a cleanup function.
*/
export function initMiniPlayerBridgeOnMain(): () => void {
// Only run on the main window
if (getCurrentWindow().label !== 'main') return () => {};
// Push state to the mini window on every relevant store change.
let last = '';
const push = () => {
const payload = snapshot();
const queueIds = payload.queue.map(q => q.id).join(',');
const key = [
payload.track?.id ?? '',
payload.isPlaying,
payload.track?.starred ?? '',
(payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'),
payload.queueIndex,
payload.queueServerId ?? '',
payload.volume,
payload.gaplessEnabled,
payload.crossfadeEnabled,
payload.crossfadeSecs,
payload.crossfadeTrimSilence,
payload.infiniteQueueEnabled,
queueIds,
].join('|');
if (key === last) return;
last = key;
emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {});
};
const unsub = usePlayerStore.subscribe((state, prev) => {
if (state.currentTrack?.id !== prev.currentTrack?.id
|| state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|| state.queueIndex !== prev.queueIndex
|| state.queueItems !== prev.queueItems
|| state.queueServerId !== prev.queueServerId
|| state.volume !== prev.volume) {
push();
}
});
// Toolbar toggles (gapless / crossfade / infinite queue) live in authStore;
// subscribe so changes from the main window propagate to the mini.
const unsubAuth = useAuthStore.subscribe((state, prev) => {
if (state.gaplessEnabled !== prev.gaplessEnabled
|| state.crossfadeEnabled !== prev.crossfadeEnabled
|| state.crossfadeSecs !== prev.crossfadeSecs
|| state.crossfadeTrimSilence !== prev.crossfadeTrimSilence
|| state.infiniteQueueEnabled !== prev.infiniteQueueEnabled) {
push();
}
});
// Push an initial snapshot whenever a new mini window announces itself.
const readyUnlisten = listen('mini:ready', () => {
last = '';
push();
});
// Receive control actions from the mini window.
const controlUnlisten = listen<MiniControlAction>('mini:control', (e) => {
const action = e.payload;
const store = usePlayerStore.getState();
switch (action) {
case 'toggle': store.togglePlay(); break;
case 'next': store.next(true); break;
case 'prev': store.previous(); break;
case 'show-main': {
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
break;
}
}
});
// Jump to a specific queue index. The mini sends a slice-relative index; add
// the window offset from the last push to land on the absolute queue position.
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
const store = usePlayerStore.getState();
const idx = (e.payload?.index ?? -1) + miniWindowStart;
if (idx < 0 || idx >= store.queueItems.length) return;
const ref = store.queueItems[idx];
if (ref) {
// Resolve the target ref; pass undefined so playTrack keeps the canonical
// queue and just jumps to this slot.
store.playTrack(resolveQueueTrack(ref), undefined, true, false, idx);
}
});
// PsyDnD reorder forwarded from the mini queue (slice-relative → absolute).
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
const store = usePlayerStore.getState();
const raw = e.payload ?? { from: -1, to: -1 };
const from = raw.from + miniWindowStart;
const to = raw.to + miniWindowStart;
if (from < 0 || from >= store.queueItems.length) return;
if (to < 0 || to > store.queueItems.length) return;
if (from === to) return;
store.reorderQueue(from, to);
});
// Remove a track at index (context menu → "Remove from queue"; slice-relative).
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
const store = usePlayerStore.getState();
const idx = (e.payload?.index ?? -1) + miniWindowStart;
if (idx < 0 || idx >= store.queueItems.length) return;
store.removeTrack(idx);
});
// Navigate the main app to a route. Used by mini context menu actions
// like "Open Album" / "Go to Artist" — those need the full main UI.
const navigateUnlisten = listen<{ to: string }>('mini:navigate', (e) => {
const to = e.payload?.to;
if (!to) return;
// Surface the main window first so the navigation is visible.
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
// React Router lives in main; route via a custom event the AppShell
// picks up (defined in App.tsx).
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
});
// Volume changes from the mini's vertical slider.
const volumeUnlisten = listen<{ value: number }>('mini:set-volume', (e) => {
const v = e.payload?.value;
if (typeof v !== 'number') return;
usePlayerStore.getState().setVolume(Math.max(0, Math.min(1, v)));
});
// Toolbar actions from the mini.
const shuffleUnlisten = listen('mini:shuffle', () => {
usePlayerStore.getState().shuffleQueue();
});
const undoQueueUnlisten = listen('mini:undo-queue', () => {
usePlayerStore.getState().undoLastQueueEdit();
});
const redoQueueUnlisten = listen('mini:redo-queue', () => {
usePlayerStore.getState().redoLastQueueEdit();
});
// Gapless ↔ Crossfade are mutually exclusive. Bridge handles the exclusion
// so the mini doesn't need to know about both states to act.
const transitionModeUnlisten = listen<{ value: string }>('mini:set-transition-mode', (e) => {
const v = e.payload?.value;
const modes: TransitionMode[] = ['none', 'gapless', 'crossfade', 'autodj'];
if (modes.includes(v as TransitionMode)) setTransitionMode(v as TransitionMode);
});
const crossfadeSecsUnlisten = listen<{ value: number }>('mini:set-crossfade-secs', (e) => {
const v = e.payload?.value;
if (typeof v !== 'number' || !Number.isFinite(v)) return;
useAuthStore.getState().setCrossfadeSecs(Math.max(0.1, Math.min(10, v)));
});
const infiniteQueueUnlisten = listen<{ value: boolean }>('mini:set-infinite-queue', (e) => {
const v = !!e.payload?.value;
useAuthStore.getState().setInfiniteQueueEnabled(v);
});
// Open the SongInfo modal in main for a given track id.
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
const id = e.payload?.id;
if (!id) return;
const w = getCurrentWindow();
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
usePlayerStore.getState().openSongInfo(id);
});
return () => {
unsub();
unsubAuth();
readyUnlisten.then(fn => fn()).catch(() => {});
controlUnlisten.then(fn => fn()).catch(() => {});
jumpUnlisten.then(fn => fn()).catch(() => {});
reorderUnlisten.then(fn => fn()).catch(() => {});
removeUnlisten.then(fn => fn()).catch(() => {});
navigateUnlisten.then(fn => fn()).catch(() => {});
volumeUnlisten.then(fn => fn()).catch(() => {});
shuffleUnlisten.then(fn => fn()).catch(() => {});
undoQueueUnlisten.then(fn => fn()).catch(() => {});
redoQueueUnlisten.then(fn => fn()).catch(() => {});
transitionModeUnlisten.then(fn => fn()).catch(() => {});
crossfadeSecsUnlisten.then(fn => fn()).catch(() => {});
infiniteQueueUnlisten.then(fn => fn()).catch(() => {});
songInfoUnlisten.then(fn => fn()).catch(() => {});
};
}
@@ -0,0 +1,91 @@
import { usePlayerStore } from '@/store/playerStore';
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
import type { MiniSyncPayload, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
import type { Track } from '@/store/playerStoreTypes';
/** Half-width of the mini initial-snapshot queue window (matches the bridge). */
const MINI_SNAPSHOT_HALF = 100;
export const COLLAPSED_SIZE = { w: 340, h: 260 };
export 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.
export const COLLAPSED_MIN = { w: 320, h: 240 };
export 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.
export const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
export 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 { /* ignore: best-effort */ }
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).
export const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
export function readQueueOpen(): boolean {
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
}
export function toMini(t: Track): 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).
*/
export function initialSnapshot(): MiniSyncPayload {
try {
const s = usePlayerStore.getState();
// Thin-state: resolve a window around the index (resolver cache →
// placeholder), remapping queueIndex like the live bridge snapshot.
const idx = s.queueIndex ?? 0;
const start = Math.max(0, idx - MINI_SNAPSHOT_HALF);
const windowed = (s.queueItems ?? [])
.slice(start, idx + MINI_SNAPSHOT_HALF + 1)
.map(r => resolveQueueTrack(r));
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: windowed.map(toMini),
queueIndex: idx - start,
queueServerId: s.queueServerId ?? null,
isPlaying: s.isPlaying,
volume: s.volume ?? 1,
gaplessEnabled: false,
crossfadeEnabled: false,
crossfadeSecs: 3,
crossfadeTrimSilence: false,
infiniteQueueEnabled: false,
isMobile: false,
};
} catch {
return {
track: null, queue: [], queueIndex: 0, queueServerId: null, isPlaying: false,
volume: 1, gaplessEnabled: false, crossfadeEnabled: false,
crossfadeSecs: 3, crossfadeTrimSilence: false,
infiniteQueueEnabled: false, isMobile: false,
};
}
}