fix(playback): stop the device holding the audio rewinding itself
The owner reports its position every ten seconds, and the server publishes a snapshot to every device on anything that touches the session. The owner was writing those snapshots back into its own store, so each one dragged playback back to its last report. Reloading another tab did it too, since registering a device publishes a snapshot: the phone jumped back to whatever position was on record, which right after a track change is zero, and the song started over. A device that already owns the session now ignores incoming snapshots and stays the authority on its own position. Snapshots still apply when ownership moves to or away from this device, which is what that branch was written for. Moving audio between devices also flushes the real position first, rather than handing over a ten-second-old one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user