Skip to content

Commit f7997d2

Browse files
tobschrudyberends
andcommitted
fix(inputs/musicassistant): stop zone playback when MA ends the stream
MA has no player pause command, so it pauses and stops by ending the stream. handleJsonMessage() reset only its local stream state and returned, so the wired stop handler never ran and handleInputStreamStop() was dead code: the input stream got cleared but the zone kept playing. Dispatch onStream.stop() on stream/end and debounce the stop by 1s, since a track change ends the stream too but a fresh stream/start follows within milliseconds. The guards on playingState and recentPlayIntent had to go: MA never reports a paused state on this path, so playingState stays true and swallows every stop, and a pause pressed ~6s after play hit the recentPlayIntent boundary exactly. Not on stream/clear: that discards buffered audio for a seek or a stream replacement while playback continues, so stopping there would tear down the zone mid-seek whenever the new audio outlasts the debounce. Closes #307 Co-Authored-By: Rudy Berends <rudy.berends@visolity.nl>
1 parent 787ebfb commit f7997d2

2 files changed

Lines changed: 62 additions & 15 deletions

File tree

src/adapters/inputs/musicassistant/musicAssistantStreamService.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ function toPlayerId(zoneName: string, fallbackId: number): string {
4949
return `lox-${normalized || fallbackId}`;
5050
}
5151

52+
// MA ends the stream both to pause/stop and on a track change. On a track change a
53+
// new stream/start follows almost immediately, so wait this long before treating a
54+
// stream end as a real stop. Long enough to bridge a gapless transition, short enough
55+
// that a pause feels immediate.
56+
const MA_STREAM_STOP_DEBOUNCE_MS = 1000;
57+
5258
// Collapse a player id to its alphanumeric slug for matching across MA's id transforms.
5359
// `lox-audio-player-2` -> `loxaudioplayer2`; MA's universal_player wraps it as
5460
// `up` + slug -> `uploxaudioplayer2`.
@@ -106,6 +112,8 @@ export class MusicAssistantStreamService {
106112
onSwitchAway?: (zoneId: number) => void;
107113
} = {};
108114
private lastPlayIntentAt = new Map<number, number>();
115+
/** Debounced stop-on-stream-end timers, cancelled when a new stream/start arrives. */
116+
private pendingStreamStopTimers = new Map<number, NodeJS.Timeout>();
109117
// Tracks in-flight serviceplay requests so sendspin doesn't double-start playback.
110118
private pendingStreamRequests = new Map<number, number>();
111119
private streamRequestSeq = 0;
@@ -1280,6 +1288,8 @@ export class MusicAssistantStreamService {
12801288
if (!this.inputHandlers?.startPlayback) {
12811289
return;
12821290
}
1291+
// A stream is (re)starting: this was a track change or a resume, not a pause.
1292+
this.cancelPendingStreamStop(zoneId);
12831293
this.lastStreamStartAt.set(zoneId, Date.now());
12841294
const meta = this.lastMetadata.get(zoneId);
12851295
const source: PlaybackSource = {
@@ -1329,26 +1339,45 @@ export class MusicAssistantStreamService {
13291339
this.inputHandlers.startPlayback(zoneId, 'musicassistant', source, metadata);
13301340
}
13311341

1342+
/**
1343+
* MA has no player pause command: it pauses/stops by ending the stream. A track
1344+
* change ends the stream too, but a fresh `stream/start` follows within
1345+
* milliseconds — so debounce and only stop when nothing resumes.
1346+
*
1347+
* Deliberately does NOT gate on `playingState` / `recentPlayIntent`: MA never
1348+
* reports a paused state to us, so `playingState` stays `true` forever and would
1349+
* block every stop, and `recentPlayIntent` would swallow a pause pressed shortly
1350+
* after play. The debounce is the reliable discriminator.
1351+
*/
13321352
private handleInputStreamStop(zoneId: number, playerId: string): void {
13331353
if (!this.inputHandlers?.stopPlayback) {
13341354
return;
13351355
}
1336-
if (this.playingState.get(zoneId) === true) {
1337-
this.log.debug('music assistant stream stop ignored; MA still playing', {
1338-
zoneId,
1339-
playerId,
1340-
});
1341-
return;
1342-
}
1343-
if (this.recentPlayIntent(zoneId, 6000)) {
1344-
this.log.debug('music assistant stream stop ignored; recent play intent', {
1345-
zoneId,
1346-
playerId,
1347-
});
1348-
return;
1356+
this.cancelPendingStreamStop(zoneId);
1357+
const timer = setTimeout(() => {
1358+
this.pendingStreamStopTimers.delete(zoneId);
1359+
this.playingState.set(zoneId, false);
1360+
this.log.info('music assistant input stream stop', { zoneId, playerId });
1361+
this.inputHandlers?.stopPlayback?.(zoneId);
1362+
}, MA_STREAM_STOP_DEBOUNCE_MS);
1363+
if (typeof timer.unref === 'function') {
1364+
timer.unref();
1365+
}
1366+
this.pendingStreamStopTimers.set(zoneId, timer);
1367+
this.log.debug('music assistant stream end; stop scheduled', {
1368+
zoneId,
1369+
playerId,
1370+
delayMs: MA_STREAM_STOP_DEBOUNCE_MS,
1371+
});
1372+
}
1373+
1374+
/** A new stream started (resume or next track) — abort a scheduled stop. */
1375+
private cancelPendingStreamStop(zoneId: number): void {
1376+
const pending = this.pendingStreamStopTimers.get(zoneId);
1377+
if (pending) {
1378+
clearTimeout(pending);
1379+
this.pendingStreamStopTimers.delete(zoneId);
13491380
}
1350-
this.log.info('music assistant input stream stop', { zoneId, playerId });
1351-
this.inputHandlers?.stopPlayback?.(zoneId);
13521381
}
13531382

13541383
private handleInputMetadata(zoneId: number, playerId: string, metadata: PlaybackMetadata): void {

src/adapters/inputs/musicassistant/sendspinClient.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,24 @@ export class SendspinClient {
355355
this.resetStreamState();
356356
this.streamFormat = null;
357357
this.log.info('sendspin stream cleared', { playerId: this.playerId, type: msg.type });
358+
// MA signals pause/stop by ending the stream (there is no player pause command).
359+
// Propagate it so the zone's playback is actually stopped — without this the
360+
// input stream is cleared but the zone keeps playing. handleInputStreamStop
361+
// debounces, because a track change ends the stream too.
362+
//
363+
// Only on stream/end: stream/clear discards buffered audio for a seek or a
364+
// stream replacement while playback continues, so stopping there would tear
365+
// down the zone mid-seek whenever the new audio takes longer than the debounce.
366+
if (msg.type === 'stream/end') {
367+
try {
368+
this.onStream?.stop?.(this.zoneId, this.playerId);
369+
} catch (err) {
370+
this.log.debug('sendspin stream stop dispatch failed', {
371+
playerId: this.playerId,
372+
message: err instanceof Error ? err.message : String(err),
373+
});
374+
}
375+
}
358376
return;
359377
}
360378
if (msg.type === 'metadata') {

0 commit comments

Comments
 (0)