refactor(playback): co-locate floating-player-bar + fs-idle-fade hooks (and orphaned playback tests) with their features

This commit is contained in:
Psychotoxical
2026-06-30 19:55:20 +02:00
parent f03a5f13e8
commit c2461c88d5
8 changed files with 9 additions and 9 deletions
@@ -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);
});
});