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:
@@ -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 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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user