Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 86 additions & 3 deletions src/app/spotify/spotify-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function SpotifyContent(): React.ReactElement {

<NowPlaying status={status} />
<Player status={status} />
<Played status={status} />
</>
) : null}
</div>
Expand Down Expand Up @@ -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 (
<section className="rounded-lg border border-border-default bg-bg-secondary p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">
Expand All @@ -205,14 +233,69 @@ function NowPlaying({ status }: { status: SpotifyStatus }): React.ReactElement |
{np.album ? ` · ${np.album}` : ''}
</p>
{np.durationMs ? (
<p className="mt-1 text-xs text-text-muted">
{formatMs(np.positionMs)} / {formatMs(np.durationMs)}
</p>
<div className="mt-2">
<div
className="h-1 w-full overflow-hidden rounded bg-bg-tertiary"
role="progressbar"
aria-valuemin={0}
aria-valuemax={np.durationMs}
aria-valuenow={position ?? 0}
aria-label="Track progress"
>
<div className="h-full bg-accent-primary" style={{ width: `${pct ?? 0}%` }} />
</div>
<p className="mt-1 flex justify-between text-xs text-text-muted">
<span>{formatMs(position)}</span>
<span>{formatMs(np.durationMs)}</span>
</p>
</div>
) : null}
</section>
);
}

/**
* 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 (
<section className="rounded-lg border border-border-default bg-bg-secondary p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">Played on this device</p>
<ol className="mt-2 divide-y divide-border-default">
{history.map((t, i) => {
const current = currentId !== null && t.trackId === currentId && i === 0;
return (
<li
key={`${t.trackId ?? t.name ?? 'track'}-${t.startedAt}`}
className={`flex items-center justify-between gap-3 py-2 text-sm ${
current ? 'text-text-primary' : 'text-text-secondary'
}`}
>
<span className="min-w-0">
<span className={`block truncate ${current ? 'font-medium' : ''}`}>
{current ? '▶ ' : ''}
{t.name ?? 'Unknown track'}
</span>
<span className="block truncate text-xs text-text-muted">
{t.artists.join(', ')}
{t.album ? ` · ${t.album}` : ''}
</span>
</span>
<span className="shrink-0 text-xs text-text-muted">{formatMs(t.durationMs)}</span>
</li>
);
})}
</ol>
</section>
);
}

function Player({ status }: { status: SpotifyStatus }): React.ReactElement {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [listening, setListening] = useState(false);
Expand Down
29 changes: 29 additions & 0 deletions src/lib/spotify/librespot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
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();
Expand Down
66 changes: 65 additions & 1 deletion src/lib/spotify/librespot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -115,6 +144,7 @@ export function buildFfmpegCommand(hlsDir: string, ffmpegBinary = 'ffmpeg'): str
'-hide_banner',
'-loglevel', 'error',
'-nostdin',
'-re',
'-f', 's16le',
'-ar', '44100',
'-ac', '2',
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, unknown> => 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) {
Expand All @@ -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,
};
Expand Down Expand Up @@ -559,6 +622,7 @@ export class SpotifyPlayerManager {
deviceName: defaultDeviceName(),
pairing: null,
nowPlaying: null,
history: [],
hasStream: false,
error: null,
};
Expand Down
Loading