diff --git a/frontend/src/hooks/usePlaybackSync.test.tsx b/frontend/src/hooks/usePlaybackSync.test.tsx new file mode 100644 index 0000000..f6587fe --- /dev/null +++ b/frontend/src/hooks/usePlaybackSync.test.tsx @@ -0,0 +1,97 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePlaybackStore } from '../store/usePlaybackStore'; +import type { PlaybackSnapshot, PlaybackSyncEvent } from '../services/playbackSync'; +import { usePlaybackSync } from './usePlaybackSync'; + +const THIS_DEVICE = '11111111-1111-1111-1111-111111111111'; +const OTHER_DEVICE = '22222222-2222-2222-2222-222222222222'; + +let emit: ((event: PlaybackSyncEvent) => void) | null = null; + +vi.mock('../services/playbackSync', () => ({ + storedDeviceId: () => null, + playbackSyncService: { + register: vi.fn(async () => ({ + id: THIS_DEVICE, + name: 'Test device', + lastSeenAt: new Date(0).toISOString(), + online: true, + isOwner: false, + })), + reportState: vi.fn(async () => undefined), + transfer: vi.fn(async () => undefined), + release: vi.fn(async () => undefined), + sendCommand: vi.fn(async () => undefined), + openStream: (_deviceId: string, onEvent: (event: PlaybackSyncEvent) => void) => { + emit = onEvent; + return () => { emit = null; }; + }, + }, +})); + +function snapshot(over: Partial = {}): PlaybackSnapshot { + return { + deviceId: THIS_DEVICE, + trackId: null, + queue: [], + queueIndex: 0, + position: 0, + isPlaying: true, + version: 1, + updatedAt: new Date(0).toISOString(), + ...over, + }; +} + +async function mountOwning() { + const view = renderHook(() => usePlaybackSync()); + await waitFor(() => expect(emit).not.toBeNull()); + // First snapshot: this device takes the session over at 30s. + await act(async () => { + emit!({ type: 'state', state: snapshot({ position: 30, version: 1 }), devices: [] }); + }); + await waitFor(() => expect(view.result.current.isOwner).toBe(true)); + return view; +} + +describe('usePlaybackSync', () => { + beforeEach(() => { + emit = null; + usePlaybackStore.setState({ position: 0, isPlaying: false, queue: [], currentTrack: null }); + }); + + it('takes the reported position when it first gains the session', async () => { + await mountOwning(); + expect(usePlaybackStore.getState().position).toBe(30); + }); + + it('ignores snapshots echoing its own stale position while it owns the audio', async () => { + await mountOwning(); + + // Playback has moved on locally; the server still holds the last report. + act(() => usePlaybackStore.getState().setPosition(48)); + await act(async () => { + emit!({ type: 'state', state: snapshot({ position: 30, version: 2 }), devices: [] }); + }); + + expect(usePlaybackStore.getState().position).toBe(48); + }); + + it('pauses and follows along once another device takes the session', async () => { + const view = await mountOwning(); + act(() => usePlaybackStore.setState({ isPlaying: true, position: 48 })); + + await act(async () => { + emit!({ + type: 'state', + state: snapshot({ deviceId: OTHER_DEVICE, position: 55, version: 3 }), + devices: [], + }); + }); + + expect(usePlaybackStore.getState().isPlaying).toBe(false); + expect(usePlaybackStore.getState().position).toBe(55); + expect(view.result.current.hasRemoteOwner).toBe(true); + }); +}); diff --git a/frontend/src/hooks/usePlaybackSync.ts b/frontend/src/hooks/usePlaybackSync.ts index cbe4f59..b2ad2bb 100644 --- a/frontend/src/hooks/usePlaybackSync.ts +++ b/frontend/src/hooks/usePlaybackSync.ts @@ -71,10 +71,20 @@ export function usePlaybackSync(): PlaybackSyncApi { const applySnapshot = useCallback(async (state: PlaybackSnapshot) => { if (state.version <= lastVersion.current) return; lastVersion.current = state.version; - setOwnerId(state.deviceId); const store = usePlaybackStore.getState(); const iOwnIt = state.deviceId !== null && state.deviceId === deviceIdRef.current; + // Captured before setOwnerId, which only reaches the ref on the next render. + const alreadyOwnedIt = ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current; + setOwnerId(state.deviceId); + + // The server publishes a snapshot for anything that touches the session, + // including another device merely registering on page load. For the device + // already holding the audio those snapshots carry nothing new: the position + // in them is this device's own last report, up to POSITION_REPORT_MS old. + // Applying it would drag playback backwards, so the owner ignores them and + // stays the authority on its own position. + if (iOwnIt && alreadyOwnedIt) return; applyingRemote.current = true; try { @@ -203,8 +213,14 @@ export function usePlaybackSync(): PlaybackSyncApi { }, []); const transferTo = useCallback(async (target: string) => { + // The target resumes from the last position on record, so flush the real + // one first — otherwise handing the audio over rewinds it by up to + // POSITION_REPORT_MS. + if (ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current) { + await reportNow(); + } await playbackSyncService.transfer(target); - }, []); + }, [reportNow]); const sendCommand = useCallback(async (command: PlaybackCommand) => { await playbackSyncService.sendCommand(command);