Keep progress bars aligned
CI / check (push) Canceled after 0s

The server now sends each fresh position on every poll that has one, and
sends positions advanced to send time; clients count only the time since
they received it. Bars realign every poll and no longer drift by however
far the viewer's clock is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-06 12:48:29 -04:00
co-authored by Claude Opus 5.5
parent be0a00a2c1
commit 13df568348
7 changed files with 93 additions and 67 deletions
+6 -11
View File
@@ -1,30 +1,25 @@
import { describe, expect, it } from 'vitest';
import { formatDuration, playbackPosition } from './progress';
const at = Date.parse('2026-10-06T12:00:00Z');
const np = (over: Partial<{ progressMs: number | null; isPlaying: boolean; durationMs: number | null }> = {}) => ({
progressMs: 'progressMs' in over ? over.progressMs! : 60_000,
isPlaying: over.isPlaying ?? true,
updatedAt: new Date(at).toISOString(),
track: { durationMs: 'durationMs' in over ? over.durationMs! : 200_000 },
});
describe('playbackPosition', () => {
it('advances while playing', () => {
expect(playbackPosition(np(), at + 15_000)).toEqual({ positionMs: 75_000, durationMs: 200_000 });
it('advances by the time since it was received while playing', () => {
expect(playbackPosition(np(), 15_000)).toEqual({ positionMs: 75_000, durationMs: 200_000 });
});
it('holds still while paused', () => {
expect(playbackPosition(np({ isPlaying: false }), at + 15_000)?.positionMs).toBe(60_000);
expect(playbackPosition(np({ isPlaying: false }), 15_000)?.positionMs).toBe(60_000);
});
it('stops at the end of the song', () => {
expect(playbackPosition(np(), at + 10 * 60_000)?.positionMs).toBe(200_000);
expect(playbackPosition(np(), 10 * 60_000)?.positionMs).toBe(200_000);
});
it("is null when the source doesn't report a position or there's no length", () => {
expect(playbackPosition(np({ progressMs: null }))).toBeNull();
expect(playbackPosition(np({ durationMs: null }))).toBeNull();
});
it("doesn't go backwards if the viewer's clock is behind", () => {
expect(playbackPosition(np(), at - 30_000)?.positionMs).toBe(60_000);
expect(playbackPosition(np({ progressMs: null }), 0)).toBeNull();
expect(playbackPosition(np({ durationMs: null }), 0)).toBeNull();
});
});
+7 -7
View File
@@ -6,18 +6,18 @@ export interface PlaybackPosition {
}
/**
* Where in the song someone is right now, or null when the source doesn't say
* (Last.fm, ListenBrainz, Apple Music). While playing, the last reported
* position is advanced by the time since it was reported, so a progress bar can
* tick smoothly between updates.
* Where in the song someone is, or null when the source doesn't say (Last.fm,
* ListenBrainz, Apple Music). The server sends the position as of when it sent
* it; pass the time since this client received it, so the bar ticks smoothly and
* the viewer's clock being off doesn't matter.
*/
export function playbackPosition(
np: Pick<NowPlayingDTO, 'progressMs' | 'isPlaying' | 'updatedAt'> & { track: Pick<NowPlayingDTO['track'], 'durationMs'> },
now = Date.now(),
np: Pick<NowPlayingDTO, 'progressMs' | 'isPlaying'> & { track: Pick<NowPlayingDTO['track'], 'durationMs'> },
msSinceReceived: number,
): PlaybackPosition | null {
const durationMs = np.track.durationMs;
if (np.progressMs == null || !durationMs) return null;
const elapsed = np.isPlaying ? Math.max(0, now - Date.parse(np.updatedAt)) : 0;
const elapsed = np.isPlaying ? Math.max(0, msSinceReceived) : 0;
return { positionMs: Math.min(Math.max(np.progressMs + elapsed, 0), durationMs), durationMs };
}