diff --git a/src/app/spotify/spotify-content.tsx b/src/app/spotify/spotify-content.tsx index 8a66ba6..173cd0c 100644 --- a/src/app/spotify/spotify-content.tsx +++ b/src/app/spotify/spotify-content.tsx @@ -145,6 +145,7 @@ export function SpotifyContent(): React.ReactElement { + ) : null} @@ -191,9 +192,36 @@ function formatMs(ms: number | null): string { return `${m}:${s.toString().padStart(2, '0')}`; } +/** + * Where the track is now, between status polls. + * + * librespot reports a position only on an event (play, pause, seek), and the + * page polls every few seconds; a clock that only moved then would stutter. + * While playing, the position is the last reported one plus the time since it + * was reported, capped at the track length. Paused, it is what was reported. + */ +function useTrackPosition(status: SpotifyStatus): number | null { + const np = status.nowPlaying; + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (status.state !== 'playing') return; + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, [status.state]); + if (!np || np.positionMs === null) return null; + if (status.state !== 'playing') return np.positionMs; + const reportedAt = Date.parse(np.updatedAt); + const elapsed = Number.isFinite(reportedAt) ? Math.max(0, now - reportedAt) : 0; + const pos = np.positionMs + elapsed; + return np.durationMs ? Math.min(pos, np.durationMs) : pos; +} + function NowPlaying({ status }: { status: SpotifyStatus }): React.ReactElement | null { const np = status.nowPlaying; + const position = useTrackPosition(status); if (!np || !np.name) return null; + const pct = + np.durationMs && position !== null ? Math.min(100, (position / np.durationMs) * 100) : null; return (

@@ -205,14 +233,69 @@ function NowPlaying({ status }: { status: SpotifyStatus }): React.ReactElement | {np.album ? ` · ${np.album}` : ''}

{np.durationMs ? ( -

- {formatMs(np.positionMs)} / {formatMs(np.durationMs)} -

+
+
+
+
+

+ {formatMs(position)} + {formatMs(np.durationMs)} +

+
) : null}
); } +/** + * What the device has played this session, newest first, the current track on + * top. Spotify does not tell a Connect device what is coming next -- the queue + * lives in the app that is casting -- so this is the playlist we can show: the + * one that has already happened. + */ +function Played({ status }: { status: SpotifyStatus }): React.ReactElement | null { + const history = status.history ?? []; + if (history.length === 0) return null; + const currentId = status.nowPlaying?.trackId ?? null; + return ( +
+

Played on this device

+
    + {history.map((t, i) => { + const current = currentId !== null && t.trackId === currentId && i === 0; + return ( +
  1. + + + {current ? '▶ ' : ''} + {t.name ?? 'Unknown track'} + + + {t.artists.join(', ')} + {t.album ? ` · ${t.album}` : ''} + + + {formatMs(t.durationMs)} +
  2. + ); + })} +
+
+ ); +} + function Player({ status }: { status: SpotifyStatus }): React.ReactElement { const audioRef = useRef(null); const [listening, setListening] = useState(false); diff --git a/src/lib/spotify/librespot.test.ts b/src/lib/spotify/librespot.test.ts index 3f304e8..78c1ada 100644 --- a/src/lib/spotify/librespot.test.ts +++ b/src/lib/spotify/librespot.test.ts @@ -39,6 +39,10 @@ describe('buildFfmpegCommand', () => { const cmd = buildFfmpegCommand('/state/hls'); expect(cmd.startsWith('ffmpeg ')).toBe(true); expect(cmd).toContain('-f s16le -ar 44100 -ac 2 -i pipe:0'); + // The clock: without it the pipe is drained at full speed, librespot sees + // the track end in seconds, and Spotify skips to the next song. + expect(cmd).toContain(' -re -f s16le'); + expect(cmd.indexOf('-re')).toBeLessThan(cmd.indexOf('-i pipe:0')); expect(cmd).toContain('-c:a aac'); expect(cmd).toContain('append_list'); expect(cmd).toContain('omit_endlist'); @@ -173,6 +177,31 @@ describe('onevent hook', () => { expect(next.positionMs).toBe(0); }); + it('keeps a played list, newest first, one entry per run of the same track', () => { + fire({ PLAYER_EVENT: 'track_changed', TRACK_ID: 'a', NAME: 'First', ARTISTS: 'X', DURATION_MS: '1000' }); + fire({ PLAYER_EVENT: 'playing', TRACK_ID: 'a', POSITION_MS: '10' }); + fire({ PLAYER_EVENT: 'track_changed', TRACK_ID: 'b', NAME: 'Second', ARTISTS: 'Y', DURATION_MS: '2000' }); + // The same track reported again (a seek back, a replay) is not a new row. + const after = fire({ PLAYER_EVENT: 'track_changed', TRACK_ID: 'b', NAME: 'Second', ARTISTS: 'Y' }); + const history = after.history as Array>; + expect(history.map((t) => t.name)).toEqual(['Second', 'First']); + expect(history[1].durationMs).toBe(1000); + expect(typeof history[0].startedAt).toBe('string'); + // Playback events leave the list alone. + const later = fire({ PLAYER_EVENT: 'paused', TRACK_ID: 'b', POSITION_MS: '500' }); + expect((later.history as unknown[]).length).toBe(2); + }); + + it('caps the played list', () => { + for (let i = 0; i < 60; i++) { + fire({ PLAYER_EVENT: 'track_changed', TRACK_ID: `t${i}`, NAME: `Track ${i}`, ARTISTS: 'Z' }); + } + const last = fire({ PLAYER_EVENT: 'playing', TRACK_ID: 't59', POSITION_MS: '1' }); + const history = last.history as Array>; + expect(history.length).toBe(50); + expect(history[0].name).toBe('Track 59'); + }); + it('does nothing without a target file', () => { execFileSync(process.execPath, [script], { env: { ...process.env, SPOTIFY_EVENT_FILE: '', PLAYER_EVENT: 'playing' } }); expect(() => readFileSync(file)).toThrow(); diff --git a/src/lib/spotify/librespot.ts b/src/lib/spotify/librespot.ts index b7e1d6e..584c575 100644 --- a/src/lib/spotify/librespot.ts +++ b/src/lib/spotify/librespot.ts @@ -46,6 +46,18 @@ export interface SpotifyPairing { code: string; } +/** One track the device has played, as the `--onevent` hook saw it. */ +export interface SpotifyTrack { + trackId: string | null; + uri: string | null; + name: string | null; + artists: string[]; + album: string | null; + durationMs: number | null; + /** When playback of it started, ISO. */ + startedAt: string; +} + export interface SpotifyNowPlaying { event: string; trackId: string | null; @@ -58,11 +70,20 @@ export interface SpotifyNowPlaying { updatedAt: string; } +/** How many played tracks the hook keeps. A listening session, not a library. */ +export const HISTORY_LIMIT = 50; + export interface SpotifyPlayerStatus { state: SpotifyPlayerState; deviceName: string; pairing: SpotifyPairing | null; nowPlaying: SpotifyNowPlaying | null; + /** + * What the device has played this run, newest first. Spotify's queue is not + * available without a developer app, so this is the playlist we can show: the + * one that has already happened. + */ + history: SpotifyTrack[]; /** True once ffmpeg has written a playlist, i.e. there is something to play. */ hasStream: boolean; error: string | null; @@ -103,6 +124,14 @@ export function shellQuote(arg: string): string { * The ffmpeg invocation librespot runs each time the sink opens. Reads raw * S16LE PCM on stdin and keeps a short rolling HLS window. * + * `-re` is the clock. librespot's subprocess backend has none of its own: it + * decodes and writes as fast as the pipe accepts, and a sound card is what + * normally pushes back. Without pacing, ffmpeg drained a three-minute song in a + * few seconds, librespot reported the track finished, and Spotify moved to the + * next one -- the listener heard a few seconds of each. With `-re` ffmpeg reads + * at the sample rate, the pipe fills, librespot's write blocks, and a song takes + * as long as a song takes. + * * `append_list` continues the segment numbering across sink restarts so a * paused-then-resumed stream does not reset the media sequence under hls.js; * `omit_endlist` keeps the playlist live when ffmpeg exits on pause; @@ -115,6 +144,7 @@ export function buildFfmpegCommand(hlsDir: string, ffmpegBinary = 'ffmpeg'): str '-hide_banner', '-loglevel', 'error', '-nostdin', + '-re', '-f', 's16le', '-ar', '44100', '-ac', '2', @@ -180,7 +210,8 @@ const e = process.env; const ev = e.PLAYER_EVENT || ''; let prev = {}; try { prev = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {} -const next = Object.assign({}, prev, { event: ev, updatedAt: new Date().toISOString() }); +const now = new Date().toISOString(); +const next = Object.assign({}, prev, { event: ev, updatedAt: now }); if (ev === 'track_changed') { next.trackId = e.TRACK_ID || null; next.uri = e.URI || null; @@ -189,6 +220,14 @@ if (ev === 'track_changed') { next.album = e.ALBUM || null; next.durationMs = e.DURATION_MS ? Number(e.DURATION_MS) : null; next.positionMs = 0; + // The played list, newest first. A repeat of the same track back to back is + // one entry, not two; a limit keeps the file a session and not a library. + const history = Array.isArray(prev.history) ? prev.history : []; + const entry = { trackId: next.trackId, uri: next.uri, name: next.name, artists: next.artists, album: next.album, durationMs: next.durationMs, startedAt: now }; + if (!(history[0] && history[0].trackId && history[0].trackId === entry.trackId)) { + history.unshift(entry); + } + next.history = history.slice(0, ${HISTORY_LIMIT}); } else if (ev === 'playing' || ev === 'paused' || ev === 'seeked' || ev === 'position_correction') { if (e.POSITION_MS) next.positionMs = Number(e.POSITION_MS); if (e.TRACK_ID) next.trackId = e.TRACK_ID; @@ -503,8 +542,31 @@ class LibrespotPlayer { } } + /** The played list from the events file, newest first; empty when there is none. */ + readHistory(): SpotifyTrack[] { + try { + const raw = JSON.parse(readFileSync(this.eventFile, 'utf8')) as { history?: unknown }; + if (!Array.isArray(raw.history)) return []; + return raw.history + .filter((t): t is Record => typeof t === 'object' && t !== null) + .map((t) => ({ + trackId: typeof t.trackId === 'string' ? t.trackId : null, + uri: typeof t.uri === 'string' ? t.uri : null, + name: typeof t.name === 'string' ? t.name : null, + artists: Array.isArray(t.artists) ? t.artists.filter((a): a is string => typeof a === 'string') : [], + album: typeof t.album === 'string' ? t.album : null, + durationMs: typeof t.durationMs === 'number' ? t.durationMs : null, + startedAt: typeof t.startedAt === 'string' ? t.startedAt : '', + })) + .slice(0, HISTORY_LIMIT); + } catch { + return []; + } + } + status(): SpotifyPlayerStatus { const nowPlaying = this.readNowPlaying(); + const history = this.readHistory(); const hasStream = existsSync(join(this.hlsDir, 'index.m3u8')); let state: SpotifyPlayerState; if (!this.running) { @@ -519,6 +581,7 @@ class LibrespotPlayer { deviceName: this.deviceName, pairing: state === 'pairing' && this.pairing?.url && this.pairing.code ? this.pairing : null, nowPlaying: state === 'playing' || state === 'paused' ? nowPlaying : null, + history, hasStream, error: this.lastError, }; @@ -559,6 +622,7 @@ export class SpotifyPlayerManager { deviceName: defaultDeviceName(), pairing: null, nowPlaying: null, + history: [], hasStream: false, error: null, };