mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(playback): co-locate floating-player-bar + fs-idle-fade hooks (and orphaned playback tests) with their features
This commit is contained in:
@@ -12,7 +12,7 @@ import { useCachedUrl } from '@/ui/CachedImage';
|
||||
import { useArtistFanart } from '@/cover/useArtistFanart';
|
||||
import { backdropFromConfig } from '@/cover/artistBackdrop';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { useFsIdleFade } from '@/hooks/useFsIdleFade';
|
||||
import { useFsIdleFade } from '@/features/fullscreenPlayer/hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '@/features/queue';
|
||||
import { WaveformSeek } from '@/features/waveform';
|
||||
import { FsQueueModal } from '@/features/fullscreenPlayer/components/FsQueueModal';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Idle-fade system — flips `isIdle` true after 3 s of no user activity.
|
||||
* Returns the boolean plus a mousemove handler that's throttled to one reset
|
||||
* per ~200 ms so cleanup/start timers don't fire on every mouse pixel.
|
||||
* Also resets on key presses and triggers `onEscape` when the user hits Esc. */
|
||||
export function useFsIdleFade(onEscape: () => void) {
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const resetIdle = useCallback(() => {
|
||||
setIsIdle(false);
|
||||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||||
idleTimer.current = setTimeout(() => setIsIdle(true), 3000);
|
||||
}, []);
|
||||
|
||||
const lastMoveTime = useRef(0);
|
||||
const handleMouseMove = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastMoveTime.current < 200) return;
|
||||
lastMoveTime.current = now;
|
||||
resetIdle();
|
||||
}, [resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
resetIdle();
|
||||
return () => { if (idleTimer.current) clearTimeout(idleTimer.current); };
|
||||
}, [resetIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
resetIdle();
|
||||
if (e.key === 'Escape') onEscape();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onEscape, resetIdle]);
|
||||
|
||||
return { isIdle, handleMouseMove };
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import { PlayerSeekbarSection } from '@/features/playback/components/playerBar/P
|
||||
import { PlayerPlaybackRate } from '@/features/playback/components/playerBar/PlayerPlaybackRate';
|
||||
import { PlayerVolume } from '@/features/playback/components/playerBar/PlayerVolume';
|
||||
import { PlayerOverflowMenu } from '@/features/playback/components/playerBar/PlayerOverflowMenu';
|
||||
import { useFloatingPlayerBar } from '@/hooks/useFloatingPlayerBar';
|
||||
import { useFloatingPlayerBar } from '@/features/playback/hooks/useFloatingPlayerBar';
|
||||
import { useUtilityOverflowMenu } from '@/hooks/useUtilityOverflowMenu';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeFloatingPlayerBarStyle } from '@/features/playback/hooks/computeFloatingPlayerBarStyle';
|
||||
|
||||
describe('computeFloatingPlayerBarStyle', () => {
|
||||
it('centers in the main column and shrink-wraps instead of stretching', () => {
|
||||
const style = computeFloatingPlayerBarStyle(220, 1600, 1920);
|
||||
expect(style.left).toBe(910);
|
||||
expect(style.right).toBe('auto');
|
||||
expect(style.transform).toBe('translateX(-50%)');
|
||||
expect(style.width).toBe('max-content');
|
||||
expect(style.maxWidth).toBe(1332);
|
||||
});
|
||||
|
||||
it('accounts for a hidden queue panel', () => {
|
||||
const style = computeFloatingPlayerBarStyle(220, null, 1920);
|
||||
expect(style.left).toBe(1070);
|
||||
expect(style.maxWidth).toBe(1652);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const HORIZONTAL_MARGIN_PX = 24;
|
||||
|
||||
/** Center the floating bar in the main column; shrink-wrap width instead of stretching. */
|
||||
export function computeFloatingPlayerBarStyle(
|
||||
sidebarRight: number,
|
||||
queueLeft: number | null,
|
||||
viewportWidth: number,
|
||||
): CSSProperties {
|
||||
const contentLeft = sidebarRight;
|
||||
const contentRight = queueLeft ?? viewportWidth;
|
||||
const contentWidth = Math.max(0, contentRight - contentLeft);
|
||||
const maxWidth = Math.max(100, contentWidth - HORIZONTAL_MARGIN_PX * 2);
|
||||
const centerX = contentLeft + contentWidth / 2;
|
||||
|
||||
return {
|
||||
left: centerX,
|
||||
right: 'auto',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'max-content',
|
||||
maxWidth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { computeFloatingPlayerBarStyle } from '@/features/playback/hooks/computeFloatingPlayerBarStyle';
|
||||
|
||||
/** Computes the floating player-bar position based on the current sidebar +
|
||||
* queue panel widths. Returns an inline-style object (left/right/width); only
|
||||
* active when `floatingPlayerBar` is true. Uses a ResizeObserver on both
|
||||
* containers so the bar slides while the sidebar or queue is resized. */
|
||||
export function useFloatingPlayerBar(
|
||||
_playerBarRef: React.RefObject<HTMLElement | null>,
|
||||
floatingPlayerBar: boolean,
|
||||
): React.CSSProperties {
|
||||
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!floatingPlayerBar) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const sidebar = document.querySelector('.sidebar') as HTMLElement | null;
|
||||
const queue = document.querySelector('.queue-panel') as HTMLElement | null;
|
||||
|
||||
const sidebarRight = sidebar ? sidebar.getBoundingClientRect().right : 0;
|
||||
const queueLeft = queue ? queue.getBoundingClientRect().left : null;
|
||||
|
||||
setFloatingStyle(computeFloatingPlayerBarStyle(sidebarRight, queueLeft, window.innerWidth));
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
const observer = new ResizeObserver(updatePosition);
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const queue = document.querySelector('.queue-panel');
|
||||
if (sidebar) observer.observe(sidebar);
|
||||
if (queue) observer.observe(queue);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [floatingPlayerBar]);
|
||||
|
||||
return floatingStyle;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { usePlaybackServerId } from '@/features/playback/hooks/usePlaybackServerId';
|
||||
|
||||
vi.mock('@/utils/server/switchActiveServer', () => ({
|
||||
switchActiveServer: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
describe('usePlaybackServerId', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
servers: [
|
||||
{ id: 'a', name: 'A', url: 'http://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'b', name: 'B', url: 'http://b.test', username: 'u', password: 'p' },
|
||||
],
|
||||
activeServerId: 'a',
|
||||
isLoggedIn: true,
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
queueItems: [{ serverId: 'a', trackId: 't1' }],
|
||||
queueServerId: 'a',
|
||||
queueIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns queue server while playback queue is non-empty', () => {
|
||||
useAuthStore.setState({ activeServerId: 'b' });
|
||||
const { result } = renderHook(() => usePlaybackServerId());
|
||||
expect(result.current).toBe('a');
|
||||
});
|
||||
|
||||
it('updates when the playing slot moves in a mixed-server queue', () => {
|
||||
useAuthStore.setState({ activeServerId: 'a' });
|
||||
usePlayerStore.setState({
|
||||
queueItems: [
|
||||
{ serverId: 'a.test', trackId: 't1' },
|
||||
{ serverId: 'b.test', trackId: 't2' },
|
||||
],
|
||||
queueServerId: 'a.test',
|
||||
queueIndex: 0,
|
||||
});
|
||||
const { result, rerender } = renderHook(() => usePlaybackServerId());
|
||||
expect(result.current).toBe('a');
|
||||
usePlayerStore.setState({ queueIndex: 1 });
|
||||
rerender();
|
||||
expect(result.current).toBe('b');
|
||||
});
|
||||
|
||||
it('does not call switchActiveServer when browsed server changes', async () => {
|
||||
const { switchActiveServer } = await import('@/utils/server/switchActiveServer');
|
||||
vi.mocked(switchActiveServer).mockClear();
|
||||
const { rerender } = renderHook(() => usePlaybackServerId());
|
||||
useAuthStore.setState({ activeServerId: 'b' });
|
||||
rerender();
|
||||
expect(switchActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
_resetTimelineSessionHistoryForTest,
|
||||
isTimelineBootstrapAttempted,
|
||||
} from '@/features/playback/store/timelineSessionHistory';
|
||||
import {
|
||||
_resetTimelineBootstrapInFlightForTest,
|
||||
ensureTimelineBootstrap,
|
||||
} from '@/features/playback/hooks/useTimelinePlayHistory';
|
||||
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryGetRecentPlaySessions: vi.fn(async () => []),
|
||||
TIMELINE_HISTORY_BOOTSTRAP_LIMIT: 50,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/queue/timelineBootstrapReady', () => ({
|
||||
timelineBootstrapIndexReady: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/store/queueTrackResolver', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/features/playback/store/queueTrackResolver')>();
|
||||
return { ...actual, seedQueueResolver: vi.fn() };
|
||||
});
|
||||
|
||||
import { libraryGetRecentPlaySessions } from '@/lib/api/library';
|
||||
import { timelineBootstrapIndexReady } from '@/utils/queue/timelineBootstrapReady';
|
||||
|
||||
describe('ensureTimelineBootstrap', () => {
|
||||
beforeEach(() => {
|
||||
_resetTimelineSessionHistoryForTest();
|
||||
_resetTimelineBootstrapInFlightForTest();
|
||||
vi.mocked(timelineBootstrapIndexReady).mockReset();
|
||||
vi.mocked(libraryGetRecentPlaySessions).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('defers without marking attempted when the index is not ready', async () => {
|
||||
vi.mocked(timelineBootstrapIndexReady).mockResolvedValue(false);
|
||||
await ensureTimelineBootstrap();
|
||||
expect(isTimelineBootstrapAttempted()).toBe(false);
|
||||
expect(libraryGetRecentPlaySessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches once when the index is ready', async () => {
|
||||
vi.mocked(timelineBootstrapIndexReady).mockResolvedValue(true);
|
||||
await ensureTimelineBootstrap();
|
||||
expect(isTimelineBootstrapAttempted()).toBe(true);
|
||||
expect(libraryGetRecentPlaySessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fetch twice in the same session', async () => {
|
||||
vi.mocked(timelineBootstrapIndexReady).mockResolvedValue(true);
|
||||
await ensureTimelineBootstrap();
|
||||
await ensureTimelineBootstrap();
|
||||
expect(libraryGetRecentPlaySessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user