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
+5 -2
View File
@@ -23,14 +23,17 @@ async function published(userId: string, fn: () => Promise<unknown>): Promise<{
const track = (title: string, extra = {}) => ({ track: { title, artist: 'Daft Punk', ...extra }, isPlaying: true, progressMs: 1000 });
describe('applyPollResult', () => {
it('publishes on a new track and queues link resolution, but not on repeat polls', async () => {
it('publishes on a new track and queues link resolution, but not on repeat polls without a position', async () => {
const { user } = await createUser(app(), 'bob');
const first = await published(user.id, () => applyPollResult(user.id, 'spotify', track('One More Time')));
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({ track: { title: 'One More Time' }, isPlaying: true });
expect(await resolveQueue.getJobCounts('waiting')).toMatchObject({ waiting: 1 });
const repeat = await published(user.id, () => applyPollResult(user.id, 'spotify', track('One More Time')));
// (polls that carry a position do republish, to realign progress bars: see progress.test.ts)
const repeat = await published(user.id, () =>
applyPollResult(user.id, 'spotify', { ...track('One More Time'), progressMs: null }),
);
expect(repeat).toHaveLength(0);
});
+32 -15
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { db, schema } from '../src/db';
import { createRedis, nowPlayingChannel } from '../src/lib/redis';
import { reportedPosition } from '../src/routes/devices';
import { applyPollResult } from '../src/services/nowPlaying';
import { applyPollResult, getNowPlaying } from '../src/services/nowPlaying';
import { nextPollDelay } from '../src/services/scheduler';
import { createUser, useTestApp } from './helpers';
@@ -54,27 +54,44 @@ describe('phone-reported positions', () => {
});
});
describe('seeking', () => {
it('tells watchers when the position jumps, but not as it ticks along', async () => {
describe('realigning progress bars', () => {
it('sends the fresh position on every poll that has one, advanced to send time', async () => {
const { user } = await createUser(app(), 'alice');
await applyPollResult(user.id, 'plex', playing(0));
// Pretend the last poll was 10 seconds ago.
await db
.update(schema.nowPlaying)
.set({ updatedAt: new Date(Date.now() - 10_000) })
.where(eq(schema.nowPlaying.userId, user.id));
const messages: string[] = [];
await sub.subscribe(nowPlayingChannel(user.id));
sub.on('message', (_channel, msg) => messages.push(msg));
await applyPollResult(user.id, 'plex', playing(10_500)); // right where it should be
await new Promise((r) => setTimeout(r, 150));
expect(messages).toHaveLength(0);
await applyPollResult(user.id, 'plex', playing(10_500));
await db
.update(schema.nowPlaying)
.set({ updatedAt: new Date(Date.now() - 3_000) }) // as if that poll was 3s ago
.where(eq(schema.nowPlaying.userId, user.id));
await applyPollResult(user.id, 'plex', playing(120_000)); // skipped ahead
await new Promise((r) => setTimeout(r, 150));
expect(messages).toHaveLength(1);
expect(JSON.parse(messages[0]!)).toMatchObject({ progressMs: 120_000 });
const sent = messages.map((m) => JSON.parse(m).progressMs as number);
expect(sent).toHaveLength(2);
expect(sent[0]).toBeGreaterThanOrEqual(10_500);
expect(sent[0]).toBeLessThan(11_500);
expect(sent[1]).toBeGreaterThanOrEqual(120_000);
expect(sent[1]).toBeLessThan(121_000);
// Fetched later, the position has moved on by the time since the poll.
const [np] = await getNowPlaying([user.id]);
await db.update(schema.nowPlaying).set({ updatedAt: new Date(Date.now() - 4_000) }).where(eq(schema.nowPlaying.userId, user.id));
const [later] = await getNowPlaying([user.id]);
expect(np!.progressMs).toBeGreaterThanOrEqual(120_000);
expect(later!.progressMs).toBeGreaterThanOrEqual(124_000);
});
it("doesn't send anything extra for sources without a position", async () => {
const { user } = await createUser(app(), 'alice');
await applyPollResult(user.id, 'lastfm', playing(null));
const messages: string[] = [];
await sub.subscribe(nowPlayingChannel(user.id));
sub.on('message', (_channel, msg) => messages.push(msg));
await applyPollResult(user.id, 'lastfm', playing(null));
await new Promise((r) => setTimeout(r, 150));
expect(messages).toHaveLength(0);
});
});