diff --git a/apps/api/src/providers/appleMusic.ts b/apps/api/src/providers/appleMusic.ts index 40c6ba3..a2a391c 100644 --- a/apps/api/src/providers/appleMusic.ts +++ b/apps/api/src/providers/appleMusic.ts @@ -65,6 +65,7 @@ export const appleMusicProvider: PollingProvider = { const track = toIncomingTrack(latest); const duration = latest.attributes?.durationInMillis ?? DEFAULT_DURATION_MS; 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 }; }, }; diff --git a/apps/api/src/providers/types.ts b/apps/api/src/providers/types.ts index 03db092..21b760a 100644 --- a/apps/api/src/providers/types.ts +++ b/apps/api/src/providers/types.ts @@ -15,6 +15,7 @@ export interface IncomingTrack { export interface PollResult { track: IncomingTrack; isPlaying: boolean; + /** Position in the track, only when the source actually reports it (never estimated). */ progressMs: number | null; } diff --git a/apps/api/src/routes/devices.ts b/apps/api/src/routes/devices.ts index b07d431..9a5859b 100644 --- a/apps/api/src/routes/devices.ts +++ b/apps/api/src/routes/devices.ts @@ -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; +} diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index 5732231..0d983e0 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -94,6 +94,8 @@ export async function publishNowPlaying(userId: string) { 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. */ export async function applyPollResult( userId: string, @@ -119,6 +121,13 @@ export async function applyPollResult( const track = await upsertTrack(result.track); 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 = !current || current.trackId !== track.id || (!current.isPlaying && now.getTime() - current.updatedAt.getTime() > REPLAY_GAP_MS); const values = { @@ -137,6 +146,7 @@ export async function applyPollResult( .onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values }); if (newPlay) await recordListen(userId, track.id, source, now); + if (jumped) await publishNowPlaying(userId); if (changed) { await publishNowPlaying(userId); const [t] = await db diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index ff179d8..ac82b8d 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -6,6 +6,7 @@ * which is what keeps third-party API usage proportional to real engagement. */ import { redis } from '../lib/redis'; +import type { PollResult } from '../providers/types'; const DUE_KEY = 'poll:due'; 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])); } + +/** + * 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); const CLAIM_SCRIPT = ` diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index cc384e8..1bac219 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -16,6 +16,7 @@ import { claimDue, ensureScheduled, isWatched, + nextPollDelay, schedulePoll, unschedulePoll, } from './services/scheduler'; @@ -58,7 +59,7 @@ async function pollConnection(connectionId: string) { : playing ? PLAYING_UNWATCHED_POLL_SECONDS : IDLE_POLL_SECONDS; - await schedulePoll(conn.id, seconds * 1000); + await schedulePoll(conn.id, nextPollDelay(seconds * 1000, result)); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (err instanceof ProviderAuthError) { diff --git a/apps/api/test/progress.test.ts b/apps/api/test/progress.test.ts new file mode 100644 index 0000000..02d3d35 --- /dev/null +++ b/apps/api/test/progress.test.ts @@ -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 }); + }); +}); diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt index 11cbde0..365790e 100644 --- a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt @@ -9,6 +9,7 @@ import android.media.session.MediaSessionManager import android.media.session.PlaybackState import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.service.notification.NotificationListenerService /** @@ -112,7 +113,7 @@ class MediaListenerService : NotificationListenerService() { Playback.stopped(lastReported!!.packageName, lastReported!!.appName) else -> return } - if (next.identity == lastReported?.identity) return + if (next.identity == lastReported?.identity && !next.jumpedFrom(lastReported!!)) return lastReported = next if (prefs.isConfigured) Reporter.report(this, next) } @@ -133,6 +134,7 @@ class MediaListenerService : NotificationListenerService() { else -> PlayState.STOPPED } 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( packageName = controller.packageName, appName = appLabel(controller.packageName), @@ -140,11 +142,26 @@ class MediaListenerService : NotificationListenerService() { title = title, artist = artist, album = metadata?.text(MediaMetadata.METADATA_KEY_ALBUM), - durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 }, - positionMs = playbackState?.position?.takeIf { it >= 0 }, + durationMs = duration, + 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 appLabel(packageName: String): String? = try { diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt index 191a0b0..03a3e7a 100644 --- a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt @@ -14,10 +14,21 @@ internal data class Playback( val album: String?, val durationMs: 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. */ 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 { put("app", packageName) appName?.let { put("appName", it) } @@ -31,6 +42,7 @@ internal data class Playback( }) } positionMs?.takeIf { it >= 0 }?.let { put("positionMs", it) } + capturedAt?.let { put("positionAt", it) } } fun toMap(): Map = mapOf( @@ -44,6 +56,8 @@ internal data class Playback( ) companion object { + private const val SEEK_THRESHOLD_MS = 5_000L + fun stopped(packageName: String, appName: String?) = Playback(packageName, appName, PlayState.STOPPED, null, null, null, null, null) } diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx index a413126..649c244 100644 --- a/apps/mobile/src/components/NowPlayingCard.tsx +++ b/apps/mobile/src/components/NowPlayingCard.tsx @@ -17,6 +17,7 @@ import { successHaptic, tapHaptic } from '@/lib/haptics'; import { colors, radius, space } from '@/lib/theme'; import { Avatar } from './Avatar'; import { Equalizer } from './Equalizer'; +import { ProgressBar } from './ProgressBar'; import { TrackCard } from './TrackCard'; export function NowPlayingCard({ @@ -93,6 +94,7 @@ export function NowPlayingCard({ + ); } diff --git a/apps/mobile/src/components/ProgressBar.tsx b/apps/mobile/src/components/ProgressBar.tsx new file mode 100644 index 0000000..acdf454 --- /dev/null +++ b/apps/mobile/src/components/ProgressBar.tsx @@ -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 ( + + + + + + + + {formatDuration(pos.positionMs)} + {formatDuration(pos.durationMs)} + + + ); +} + +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'] }, +}); diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 852c9eb..423d88f 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -187,6 +187,11 @@ input:focus, textarea:focus, select:focus { .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-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; } .service-link { font-size: 0.76rem; color: var(--text); text-decoration: none; padding: 0.2rem 0.55rem; border-radius: 999px; diff --git a/apps/web/src/components/NowPlayingCard.tsx b/apps/web/src/components/NowPlayingCard.tsx index 1328e0f..2b0638e 100644 --- a/apps/web/src/components/NowPlayingCard.tsx +++ b/apps/web/src/components/NowPlayingCard.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared'; import { Avatar } from './Avatar'; import { Equalizer } from './Equalizer'; +import { ProgressBar } from './ProgressBar'; import { TrackCard } from './TrackCard'; export function NowPlayingCard({ @@ -83,6 +84,7 @@ export function NowPlayingCard({ transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} > + diff --git a/apps/web/src/components/ProgressBar.tsx b/apps/web/src/components/ProgressBar.tsx new file mode 100644 index 0000000..6850c5a --- /dev/null +++ b/apps/web/src/components/ProgressBar.tsx @@ -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 ( +
+
+
+
+
+ {formatDuration(pos.positionMs)} + {formatDuration(pos.durationMs)} +
+
+ ); +} diff --git a/packages/shared/src/devices.ts b/packages/shared/src/devices.ts index 218cb13..747bd49 100644 --- a/packages/shared/src/devices.ts +++ b/packages/shared/src/devices.ts @@ -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'] }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7c02f8e..38f60f0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,3 +7,4 @@ export * from './time'; export * from './devices'; export * from './links'; export * from './qr'; +export * from './progress'; diff --git a/packages/shared/src/progress.test.ts b/packages/shared/src/progress.test.ts new file mode 100644 index 0000000..e2389cd --- /dev/null +++ b/packages/shared/src/progress.test.ts @@ -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'); + }); +}); diff --git a/packages/shared/src/progress.ts b/packages/shared/src/progress.ts new file mode 100644 index 0000000..f81ecb1 --- /dev/null +++ b/packages/shared/src/progress.ts @@ -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 & { track: Pick }, + 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}`; +}