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
+2 -1
View File
@@ -65,6 +65,7 @@ export const appleMusicProvider: PollingProvider = {
const track = toIncomingTrack(latest); const track = toIncomingTrack(latest);
const duration = latest.attributes?.durationInMillis ?? DEFAULT_DURATION_MS; const duration = latest.attributes?.durationInMillis ?? DEFAULT_DURATION_MS;
if (!track || !meta.lastSeenAt || now - meta.lastSeenAt > duration + GRACE_MS) return null; if (!track || !meta.lastSeenAt || now - meta.lastSeenAt > duration + GRACE_MS) return null;
return { track, isPlaying: true, progressMs: now - meta.lastSeenAt }; // Recently-played has no position, and a guess would make a progress bar lie.
return { track, isPlaying: true, progressMs: null };
}, },
}; };
+1
View File
@@ -15,6 +15,7 @@ export interface IncomingTrack {
export interface PollResult { export interface PollResult {
track: IncomingTrack; track: IncomingTrack;
isPlaying: boolean; isPlaying: boolean;
/** Position in the track, only when the source actually reports it (never estimated). */
progressMs: number | null; progressMs: number | null;
} }
+16 -2
View File
@@ -6,7 +6,7 @@
*/ */
import { and, desc, eq, notInArray } from 'drizzle-orm'; import { and, desc, eq, notInArray } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify'; 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 { db, schema } from '../db';
import { requireUser } from '../lib/auth'; import { requireUser } from '../lib/auth';
import { randomToken, sha256 } from '../lib/crypto'; import { randomToken, sha256 } from '../lib/crypto';
@@ -113,10 +113,24 @@ export async function deviceRoutes(app: FastifyInstance) {
await applyPollResult( await applyPollResult(
device.userId, device.userId,
'android_media_session', '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 }, { sourceApp },
); );
return reply.status(204).send(); 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;
}
+10
View File
@@ -94,6 +94,8 @@ export async function publishNowPlaying(userId: string) {
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto)); if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
} }
const SEEK_THRESHOLD_MS = 5_000;
/** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */ /** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */
export async function applyPollResult( export async function applyPollResult(
userId: string, userId: string,
@@ -119,6 +121,13 @@ export async function applyPollResult(
const track = await upsertTrack(result.track); const track = await upsertTrack(result.track);
const changed = !current || current.trackId !== track.id || !current.isPlaying; const changed = !current || current.trackId !== track.id || !current.isPlaying;
// Same song, but not where it should be by now: a seek (or a restart). Tell
// watchers, or their progress bars would drift.
const jumped =
!changed &&
current.progressMs != null &&
result.progressMs != null &&
Math.abs(result.progressMs - (current.progressMs + (now.getTime() - current.updatedAt.getTime()))) > SEEK_THRESHOLD_MS;
const newPlay = const newPlay =
!current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS); !current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS);
const values = { const values = {
@@ -137,6 +146,7 @@ export async function applyPollResult(
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values }); .onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
if (newPlay) await recordListen(userId, track.id, source, now); if (newPlay) await recordListen(userId, track.id, source, now);
if (jumped) await publishNowPlaying(userId);
if (changed) { if (changed) {
await publishNowPlaying(userId); await publishNowPlaying(userId);
const [t] = await db const [t] = await db
+18
View File
@@ -6,6 +6,7 @@
* which is what keeps third-party API usage proportional to real engagement. * which is what keeps third-party API usage proportional to real engagement.
*/ */
import { redis } from '../lib/redis'; import { redis } from '../lib/redis';
import type { PollResult } from '../providers/types';
const DUE_KEY = 'poll:due'; const DUE_KEY = 'poll:due';
const WATCHED_TTL_SECONDS = 90; const WATCHED_TTL_SECONDS = 90;
@@ -31,6 +32,23 @@ export function ensureScheduled(connectionIds: string[]) {
return redis.zadd(DUE_KEY, 'NX', ...connectionIds.flatMap((id) => [now, id])); return redis.zadd(DUE_KEY, 'NX', ...connectionIds.flatMap((id) => [now, id]));
} }
/**
* When the source says exactly where in the song they are, check again just
* after it ends instead of waiting out the full interval: the next song shows
* up on time, and the progress bar doesn't sit at the end.
*/
export function nextPollDelay(intervalMs: number, result: PollResult | null): number {
const duration = result?.track.durationMs;
if (!result?.isPlaying || result.progressMs == null || !duration) return intervalMs;
const remaining = duration - result.progressMs;
if (remaining <= 0) return intervalMs;
return Math.min(intervalMs, Math.max(remaining + TRACK_END_MARGIN_MS, MIN_POLL_GAP_MS));
}
const TRACK_END_MARGIN_MS = 2_000; // let the player move on to the next song first
const MIN_POLL_GAP_MS = 5_000;
export const unschedulePoll = (connectionId: string) => redis.zrem(DUE_KEY, connectionId); export const unschedulePoll = (connectionId: string) => redis.zrem(DUE_KEY, connectionId);
const CLAIM_SCRIPT = ` const CLAIM_SCRIPT = `
+2 -1
View File
@@ -16,6 +16,7 @@ import {
claimDue, claimDue,
ensureScheduled, ensureScheduled,
isWatched, isWatched,
nextPollDelay,
schedulePoll, schedulePoll,
unschedulePoll, unschedulePoll,
} from './services/scheduler'; } from './services/scheduler';
@@ -58,7 +59,7 @@ async function pollConnection(connectionId: string) {
: playing : playing
? PLAYING_UNWATCHED_POLL_SECONDS ? PLAYING_UNWATCHED_POLL_SECONDS
: IDLE_POLL_SECONDS; : IDLE_POLL_SECONDS;
await schedulePoll(conn.id, seconds * 1000); await schedulePoll(conn.id, nextPollDelay(seconds * 1000, result));
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (err instanceof ProviderAuthError) { if (err instanceof ProviderAuthError) {
+80
View File
@@ -0,0 +1,80 @@
import { eq } from 'drizzle-orm';
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 { nextPollDelay } from '../src/services/scheduler';
import { createUser, useTestApp } from './helpers';
const app = useTestApp();
const sub = createRedis();
afterEach(() => sub.unsubscribe());
const playing = (progressMs: number | null, durationMs: number | null = 200_000) => ({
track: { title: 'Windowlicker', artist: 'Aphex Twin', durationMs },
isPlaying: true,
progressMs,
});
describe('polling at the end of a track', () => {
it('polls just after the song ends when that comes before the next regular poll', () => {
expect(nextPollDelay(60_000, playing(180_000))).toBe(22_000); // 20s left + 2s margin
});
it('keeps the regular interval when the song ends later', () => {
expect(nextPollDelay(10_000, playing(60_000))).toBe(10_000);
});
it('never polls more than every 5 seconds', () => {
expect(nextPollDelay(60_000, playing(199_500))).toBe(5_000);
});
it('keeps the interval without a position, a length, or while paused', () => {
expect(nextPollDelay(60_000, playing(null))).toBe(60_000);
expect(nextPollDelay(60_000, playing(1000, null))).toBe(60_000);
expect(nextPollDelay(60_000, { ...playing(180_000), isPlaying: false })).toBe(60_000);
expect(nextPollDelay(60_000, null)).toBe(60_000);
});
});
describe('phone-reported positions', () => {
const now = Date.parse('2026-10-06T12:00:00Z');
const report = (positionAt: number | null, state: 'playing' | 'paused' = 'playing') => ({
app: 'com.spotify.music',
state,
track: { title: 'X', artist: 'Y', durationMs: 200_000 },
positionMs: 30_000,
positionAt,
});
it('advances by the time since the phone read it', () => {
expect(reportedPosition(report(now - 4_000), now)).toBe(34_000);
});
it("doesn't advance while paused, or trust a wildly wrong clock", () => {
expect(reportedPosition(report(now - 4_000, 'paused'), now)).toBe(30_000);
expect(reportedPosition(report(now - 3 * 3600_000), now)).toBe(30_000);
expect(reportedPosition(report(null), now)).toBe(30_000);
});
});
describe('seeking', () => {
it('tells watchers when the position jumps, but not as it ticks along', 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(120_000)); // skipped ahead
await new Promise((r) => setTimeout(r, 150));
expect(messages).toHaveLength(1);
expect(JSON.parse(messages[0]!)).toMatchObject({ progressMs: 120_000 });
});
});
@@ -9,6 +9,7 @@ import android.media.session.MediaSessionManager
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
/** /**
@@ -112,7 +113,7 @@ class MediaListenerService : NotificationListenerService() {
Playback.stopped(lastReported!!.packageName, lastReported!!.appName) Playback.stopped(lastReported!!.packageName, lastReported!!.appName)
else -> return else -> return
} }
if (next.identity == lastReported?.identity) return if (next.identity == lastReported?.identity && !next.jumpedFrom(lastReported!!)) return
lastReported = next lastReported = next
if (prefs.isConfigured) Reporter.report(this, next) if (prefs.isConfigured) Reporter.report(this, next)
} }
@@ -133,6 +134,7 @@ class MediaListenerService : NotificationListenerService() {
else -> PlayState.STOPPED else -> PlayState.STOPPED
} }
if (title.isNullOrBlank() && state != PlayState.STOPPED) return null // between tracks / ads with no metadata if (title.isNullOrBlank() && state != PlayState.STOPPED) return null // between tracks / ads with no metadata
val duration = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 }
return Playback( return Playback(
packageName = controller.packageName, packageName = controller.packageName,
appName = appLabel(controller.packageName), appName = appLabel(controller.packageName),
@@ -140,11 +142,26 @@ class MediaListenerService : NotificationListenerService() {
title = title, title = title,
artist = artist, artist = artist,
album = metadata?.text(MediaMetadata.METADATA_KEY_ALBUM), album = metadata?.text(MediaMetadata.METADATA_KEY_ALBUM),
durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 }, durationMs = duration,
positionMs = playbackState?.position?.takeIf { it >= 0 }, positionMs = playbackState?.let { currentPosition(it, duration) },
capturedAt = System.currentTimeMillis(),
) )
} }
/**
* PlaybackState.position is where playback was at lastPositionUpdateTime, which
* apps only refresh on play/pause/seek. Advance it to now while playing.
*/
private fun currentPosition(ps: PlaybackState, durationMs: Long?): Long? {
val base = ps.position.takeIf { it >= 0 } ?: return null
val advanced = if (ps.state == PlaybackState.STATE_PLAYING && ps.lastPositionUpdateTime > 0) {
base + ((SystemClock.elapsedRealtime() - ps.lastPositionUpdateTime) * ps.playbackSpeed).toLong()
} else {
base
}
return durationMs?.let { advanced.coerceIn(0, it) } ?: advanced.coerceAtLeast(0)
}
private fun MediaMetadata.text(key: String): String? = getString(key)?.trim()?.takeIf { it.isNotEmpty() } private fun MediaMetadata.text(key: String): String? = getString(key)?.trim()?.takeIf { it.isNotEmpty() }
private fun appLabel(packageName: String): String? = try { private fun appLabel(packageName: String): String? = try {
@@ -14,10 +14,21 @@ internal data class Playback(
val album: String?, val album: String?,
val durationMs: Long?, val durationMs: Long?,
val positionMs: Long?, val positionMs: Long?,
/** Wall-clock time positionMs was read, so the server can advance it past upload delays. */
val capturedAt: Long? = null,
) { ) {
/** Fields that matter for "did anything change": not position, which ticks constantly. */ /** Fields that matter for "did anything change": not position, which ticks constantly. */
val identity: String get() = listOf(packageName, state.wire, title, artist).joinToString("\u0000") val identity: String get() = listOf(packageName, state.wire, title, artist).joinToString("\u0000")
/** Same song, but the position moved more than playing would explain: a seek, so report it. */
fun jumpedFrom(previous: Playback): Boolean {
if (state != PlayState.PLAYING || previous.state != PlayState.PLAYING) return false
val now = positionMs ?: return false
val then = previous.positionMs ?: return false
val elapsed = (capturedAt ?: return false) - (previous.capturedAt ?: return false)
return kotlin.math.abs(now - (then + elapsed)) > SEEK_THRESHOLD_MS
}
fun toJson(): JSONObject = JSONObject().apply { fun toJson(): JSONObject = JSONObject().apply {
put("app", packageName) put("app", packageName)
appName?.let { put("appName", it) } appName?.let { put("appName", it) }
@@ -31,6 +42,7 @@ internal data class Playback(
}) })
} }
positionMs?.takeIf { it >= 0 }?.let { put("positionMs", it) } positionMs?.takeIf { it >= 0 }?.let { put("positionMs", it) }
capturedAt?.let { put("positionAt", it) }
} }
fun toMap(): Map<String, Any?> = mapOf( fun toMap(): Map<String, Any?> = mapOf(
@@ -44,6 +56,8 @@ internal data class Playback(
) )
companion object { companion object {
private const val SEEK_THRESHOLD_MS = 5_000L
fun stopped(packageName: String, appName: String?) = fun stopped(packageName: String, appName: String?) =
Playback(packageName, appName, PlayState.STOPPED, null, null, null, null, null) Playback(packageName, appName, PlayState.STOPPED, null, null, null, null, null)
} }
@@ -17,6 +17,7 @@ import { successHaptic, tapHaptic } from '@/lib/haptics';
import { colors, radius, space } from '@/lib/theme'; import { colors, radius, space } from '@/lib/theme';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer'; import { Equalizer } from './Equalizer';
import { ProgressBar } from './ProgressBar';
import { TrackCard } from './TrackCard'; import { TrackCard } from './TrackCard';
export function NowPlayingCard({ export function NowPlayingCard({
@@ -93,6 +94,7 @@ export function NowPlayingCard({
<Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}> <Animated.View key={np.track.id} entering={FadeInDown.duration(380).springify()} exiting={FadeOut.duration(200)}>
<TrackCard track={np.track} preferred={preferred} /> <TrackCard track={np.track} preferred={preferred} />
</Animated.View> </Animated.View>
<ProgressBar np={np} />
</View> </View>
); );
} }
@@ -0,0 +1,51 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared';
import { colors } from '@/lib/theme';
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
export function ProgressBar({ np }: { np: NowPlayingDTO }) {
const [now, setNow] = useState(() => Date.now());
const pos = playbackPosition(np, now);
const visible = np.isPlaying && pos !== null;
const fraction = pos ? pos.positionMs / pos.durationMs : 0;
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [visible]);
const width = useSharedValue(fraction);
useEffect(() => {
// Glide between ticks; jump straight there after a seek or a new song.
const jump = Math.abs(fraction - width.value) > 0.05;
width.set(jump ? fraction : withTiming(fraction, { duration: 1000, easing: Easing.linear }));
}, [fraction, width]);
const fill = useAnimatedStyle(() => ({ width: `${width.value * 100}%` }));
if (!visible || !pos) return null;
return (
<View style={styles.wrap} accessibilityLabel={`${formatDuration(pos.positionMs)} of ${formatDuration(pos.durationMs)}`}>
<View style={styles.track}>
<Animated.View style={[styles.fill, fill]}>
<LinearGradient colors={['#7b2ff7', '#2f8cff']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={StyleSheet.absoluteFill} />
</Animated.View>
</View>
<View style={styles.times}>
<Text style={styles.time}>{formatDuration(pos.positionMs)}</Text>
<Text style={styles.time}>{formatDuration(pos.durationMs)}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrap: { marginTop: 12 },
track: { height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.14)', overflow: 'hidden' },
fill: { height: '100%', borderRadius: 2, overflow: 'hidden' },
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
time: { color: colors.muted, fontSize: 12, fontVariant: ['tabular-nums'] },
});
+5
View File
@@ -187,6 +187,11 @@ input:focus, textarea:focus, select:focus {
.track-body { min-width: 0; flex: 1; } .track-body { min-width: 0; flex: 1; }
.track-title { font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .track-title { font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track-artist { color: var(--text); opacity: 0.82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .track-artist { color: var(--text); opacity: 0.82; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress { margin-top: 0.85rem; }
.progress-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); overflow: hidden; }
.progress-fill { height: 100%; border-radius: inherit; background: var(--grad); transition: width 1s linear; }
.progress-times { display: flex; justify-content: space-between; margin-top: 0.3rem; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
.track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: 0.8rem; } .track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-top: 0.8rem; }
.service-link { .service-link {
font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.2rem 0.55rem; border-radius: 999px;
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared'; import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { Equalizer } from './Equalizer'; import { Equalizer } from './Equalizer';
import { ProgressBar } from './ProgressBar';
import { TrackCard } from './TrackCard'; import { TrackCard } from './TrackCard';
export function NowPlayingCard({ export function NowPlayingCard({
@@ -83,6 +84,7 @@ export function NowPlayingCard({
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
> >
<TrackCard track={np.track} preferred={preferred} /> <TrackCard track={np.track} preferred={preferred} />
<ProgressBar np={np} />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</article> </article>
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect, useState } from 'react';
import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared';
/** Where they are in the song. Renders nothing when the source doesn't report a position. */
export function ProgressBar({ np }: { np: NowPlayingDTO }) {
const [now, setNow] = useState(() => Date.now());
const pos = playbackPosition(np, now);
const ticking = np.isPlaying && pos !== null;
useEffect(() => {
if (!ticking) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [ticking]);
if (!pos || !np.isPlaying) return null;
const pct = (pos.positionMs / pos.durationMs) * 100;
return (
<div className="progress" aria-label={`${formatDuration(pos.positionMs)} of ${formatDuration(pos.durationMs)}`}>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div className="progress-times" aria-hidden>
<span>{formatDuration(pos.positionMs)}</span>
<span>{formatDuration(pos.durationMs)}</span>
</div>
</div>
);
}
+2
View File
@@ -49,6 +49,8 @@ export const devicePlaybackSchema = z
state: z.enum(['playing', 'paused', 'stopped']), state: z.enum(['playing', 'paused', 'stopped']),
track: reportedTrack.nullish(), track: reportedTrack.nullish(),
positionMs: z.number().int().min(0).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'] }); .refine((v) => v.state === 'stopped' || v.track, { message: 'track is required unless stopped', path: ['track'] });
+1
View File
@@ -7,3 +7,4 @@ export * from './time';
export * from './devices'; export * from './devices';
export * from './links'; export * from './links';
export * from './qr'; export * from './qr';
export * from './progress';
+37
View File
@@ -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');
});
});
+31
View File
@@ -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}`;
}