Files
psysonic/src/components/MiniPlayer.tsx
T
cucadmuh 45e0e1206f fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server

When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.

* fix(playback): switch to queue server when opening Now Playing

Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.

* docs(credits): mention Now Playing in PR #717 contribution line

* fix(playback): route scrobble and queue sync to queue server

Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
2026-05-15 16:08:41 +03:00

214 lines
7.5 KiB
TypeScript

import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { useEffect, useMemo, 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 './MiniContextMenu';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
import {
COLLAPSED_SIZE, EXPANDED_SIZE, COLLAPSED_MIN, EXPANDED_MIN,
EXPANDED_H_KEY, QUEUE_OPEN_KEY,
readStoredExpandedHeight, readQueueOpen, initialSnapshot,
} from '../utils/componentHelpers/miniPlayerHelpers';
import { MiniTitlebar } from './miniPlayer/MiniTitlebar';
import { MiniMeta } from './miniPlayer/MiniMeta';
import { MiniControls } from './miniPlayer/MiniControls';
import { MiniToolbar } from './miniPlayer/MiniToolbar';
import { MiniQueue } from './miniPlayer/MiniQueue';
import { useMiniVolumePopover } from '../hooks/useMiniVolumePopover';
import { useMiniQueueDrag } from '../hooks/useMiniQueueDrag';
import { useMiniSync } from '../hooks/useMiniSync';
import { useMiniWindowSetup } from '../hooks/useMiniWindowSetup';
import { useMiniKeyboardShortcuts } from '../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 {
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 {}
};
const closeMini = async () => {
try { await invoke('close_mini_player'); } catch {}
};
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 {}
}
}
setQueueOpen(next);
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
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 {}
};
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 { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(track?.coverArt, 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}
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>
);
}