- Feed and profile cards show a progress bar with elapsed/total time, only when the source reports a real position (Plex, Jellyfin, the Android reader). It ticks locally between updates. Apple Music's guessed position is no longer reported. - The Android reader advances MediaSession's stale position to now, stamps when it was read (the server corrects for upload delay), and reports seeks. The server pushes an update to watchers when a position jumps. - When a polled source says where in the song someone is, the next poll is scheduled just after the song ends (never sooner than 5s), instead of waiting out the full interval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -49,6 +49,8 @@ export const devicePlaybackSchema = z
|
||||
state: z.enum(['playing', 'paused', 'stopped']),
|
||||
track: reportedTrack.nullish(),
|
||||
positionMs: z.number().int().min(0).nullish(),
|
||||
/** Phone's clock (epoch ms) when positionMs was read; lets the server account for upload delay. */
|
||||
positionAt: z.number().int().positive().nullish(),
|
||||
})
|
||||
.refine((v) => v.state === 'stopped' || v.track, { message: 'track is required unless stopped', path: ['track'] });
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './time';
|
||||
export * from './devices';
|
||||
export * from './links';
|
||||
export * from './qr';
|
||||
export * from './progress';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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('holds still while paused', () => {
|
||||
expect(playbackPosition(np({ isPlaying: false }), at + 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('formats minutes and hours', () => {
|
||||
expect(formatDuration(0)).toBe('0:00');
|
||||
expect(formatDuration(225_000)).toBe('3:45');
|
||||
expect(formatDuration(3_725_000)).toBe('1:02:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { NowPlayingDTO } from './dto';
|
||||
|
||||
export interface PlaybackPosition {
|
||||
positionMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function playbackPosition(
|
||||
np: Pick<NowPlayingDTO, 'progressMs' | 'isPlaying' | 'updatedAt'> & { track: Pick<NowPlayingDTO['track'], 'durationMs'> },
|
||||
now = Date.now(),
|
||||
): 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;
|
||||
return { positionMs: Math.min(Math.max(np.progressMs + elapsed, 0), durationMs), durationMs };
|
||||
}
|
||||
|
||||
/** 225000 -> "3:45", 3725000 -> "1:02:05". */
|
||||
export function formatDuration(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = String(total % 60).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
||||
}
|
||||
Reference in New Issue
Block a user