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:
kami
2026-08-08 23:28:33 +04:00
parent dea08f9c47
commit 93c737ee49
2 changed files with 115 additions and 2 deletions
@@ -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);
});
});
+18 -2
View File
@@ -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);