Song progress bar, and poll right after a track ends
CI / check (push) Canceled after 0s

- 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:
2026-10-06 01:24:20 -04:00
co-authored by Claude Opus 5.5
parent 1f0cdb68f5
commit be0a00a2c1
18 changed files with 325 additions and 7 deletions
+16 -2
View File
@@ -6,7 +6,7 @@
*/
import { and, desc, eq, notInArray } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO } from '@musicshare/shared';
import { ANDROID_MUSIC_APPS, devicePlaybackSchema, registerDeviceSchema, type DeviceDTO, type DevicePlaybackInput } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { randomToken, sha256 } from '../lib/crypto';
@@ -113,10 +113,24 @@ export async function deviceRoutes(app: FastifyInstance) {
await applyPollResult(
device.userId,
'android_media_session',
track ? { track, isPlaying: input.state === 'playing', progressMs: input.positionMs ?? null } : null,
track ? { track, isPlaying: input.state === 'playing', progressMs: reportedPosition(input) } : null,
{ sourceApp },
);
return reply.status(204).send();
},
);
}
/**
* Reports can reach us a while after they were made (WorkManager retries, the
* 4-minute heartbeat resends the last one), so advance a playing position by the
* time since the phone read it. Ignore a timestamp from a badly wrong clock.
*/
export function reportedPosition(input: DevicePlaybackInput, now = Date.now()): number | null {
if (input.positionMs == null) return null;
if (input.state !== 'playing' || !input.positionAt) return input.positionMs;
const age = now - input.positionAt;
if (age < -60_000 || age > 15 * 60_000) return input.positionMs;
const position = input.positionMs + Math.max(0, age);
return input.track?.durationMs ? Math.min(position, input.track.durationMs) : position;
}