@hashsome/integration.music-assistant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,423 @@
1
+ import { BaseIntegration, UnknownEntityError, } from '@hashsome/core';
2
+ import { parseUri, SHELVES, toBrowseItem } from './browse.js';
3
+ import { toMediaPlayer } from './mapper.js';
4
+ import { onPlayback, onQueue, positionOf } from './position.js';
5
+ const isRecord = (value) => typeof value === 'object' && value !== null;
6
+ const isMaPlayer = (value) => isRecord(value) && typeof value.player_id === 'string';
7
+ /** Maps a model command onto a Music Assistant `players/cmd/*` command, validating its arguments. */
8
+ function commandFor(name, args) {
9
+ switch (name) {
10
+ case 'play':
11
+ return { command: 'players/cmd/play' };
12
+ case 'pause':
13
+ return { command: 'players/cmd/pause' };
14
+ case 'togglePlay':
15
+ return { command: 'players/cmd/play_pause' };
16
+ case 'next':
17
+ return { command: 'players/cmd/next' };
18
+ case 'previous':
19
+ return { command: 'players/cmd/previous' };
20
+ case 'setVolume': {
21
+ const volume = args?.volume;
22
+ if (typeof volume !== 'number' || !Number.isFinite(volume)) {
23
+ throw new Error('setVolume needs a numeric volume between 0 and 1');
24
+ }
25
+ return {
26
+ command: 'players/cmd/volume_set',
27
+ args: { volume_level: Math.round(Math.min(1, Math.max(0, volume)) * 100) },
28
+ };
29
+ }
30
+ case 'setMuted':
31
+ if (typeof args?.muted !== 'boolean') {
32
+ throw new Error('setMuted needs a boolean "muted"');
33
+ }
34
+ return { command: 'players/cmd/volume_mute', args: { muted: args.muted } };
35
+ case 'setShuffle':
36
+ if (typeof args?.shuffle !== 'boolean') {
37
+ throw new Error('setShuffle needs a boolean "shuffle"');
38
+ }
39
+ return {
40
+ command: 'player_queues/shuffle',
41
+ args: { shuffle_enabled: args.shuffle },
42
+ idKey: 'queue_id',
43
+ };
44
+ case 'seek': {
45
+ const position = args?.position;
46
+ if (typeof position !== 'number' || !Number.isFinite(position)) {
47
+ throw new Error('seek needs a numeric position in seconds');
48
+ }
49
+ // Queues are addressed by their own id, which is the player's.
50
+ return {
51
+ command: 'player_queues/seek',
52
+ args: { position: Math.max(0, Math.round(position)) },
53
+ idKey: 'queue_id',
54
+ };
55
+ }
56
+ case 'playMedia': {
57
+ const mode = args?.mode ?? 'play';
58
+ if (typeof args?.item !== 'string' || args.item === '') {
59
+ throw new Error('playMedia needs an item id');
60
+ }
61
+ if (!['play', 'replace', 'next', 'add'].includes(String(mode))) {
62
+ throw new Error('"mode" must be play, replace, next or add');
63
+ }
64
+ return {
65
+ command: 'player_queues/play_media',
66
+ args: { media: args.item, option: mode },
67
+ idKey: 'queue_id',
68
+ };
69
+ }
70
+ default:
71
+ throw new Error(`A media player has no "${name}" command`);
72
+ }
73
+ }
74
+ function wsUrl(url) {
75
+ const parsed = new URL(url);
76
+ parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
77
+ parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}/ws`;
78
+ return parsed.toString();
79
+ }
80
+ const samePosition = (a, b) => a.item === b.item &&
81
+ a.elapsed === b.elapsed &&
82
+ a.resume === b.resume &&
83
+ a.held === b.held &&
84
+ a.inherited === b.inherited;
85
+ /** Which track a queue is on, as something comparable: its address when it has one, else its name or place. */
86
+ function itemOf(queue) {
87
+ // The track's own address. The queue's id for the item is not kept when a stopped queue is
88
+ // resumed, so it would make a resume look like a different track.
89
+ const current = queue.current_item;
90
+ if (isRecord(current)) {
91
+ const media = current.media_item;
92
+ if (isRecord(media) && typeof media.uri === 'string') {
93
+ return media.uri;
94
+ }
95
+ if (typeof current.name === 'string') {
96
+ return current.name;
97
+ }
98
+ if (typeof current.queue_item_id === 'string') {
99
+ return current.queue_item_id;
100
+ }
101
+ }
102
+ return typeof queue.current_index === 'number' ? `#${queue.current_index}` : undefined;
103
+ }
104
+ export class MusicAssistantIntegration extends BaseIntegration {
105
+ id;
106
+ #options;
107
+ #socket;
108
+ #nextMessageId = 1;
109
+ /** The last raw player and what its queue says, so either can change without the other. A queue
110
+ * has the player's id. */
111
+ #players = new Map();
112
+ #queues = new Map();
113
+ #pending = new Map();
114
+ #stopped = true;
115
+ #retry = 0;
116
+ #timer;
117
+ /** True once the initial handshake has succeeded at least once — only then does a later close
118
+ * count as a "drop" worth auto-reconnecting; a close during the *first* attempt is a connect()
119
+ * failure that the runtime's own startup retry loop owns (see `connect()`'s doc comment). */
120
+ #everConnected = false;
121
+ constructor(options) {
122
+ super();
123
+ this.id = options.id ?? 'ma';
124
+ this.#options = options;
125
+ }
126
+ /** Resolves once authenticated and the initial player list has been fetched. Later drops are
127
+ * reconnected internally with backoff (mirrors how the runtime treats every integration: it
128
+ * only calls `connect()` once, at startup). */
129
+ connect() {
130
+ if (!this.#stopped) {
131
+ return Promise.resolve();
132
+ }
133
+ this.#stopped = false;
134
+ this.setStatus('connecting');
135
+ return this.#open();
136
+ }
137
+ disconnect() {
138
+ this.#stopped = true;
139
+ clearTimeout(this.#timer);
140
+ for (const { reject } of this.#pending.values()) {
141
+ reject(new Error('Disconnected'));
142
+ }
143
+ this.#pending.clear();
144
+ this.#socket?.close();
145
+ this.#socket = undefined;
146
+ this.setStatus('disconnected');
147
+ }
148
+ async command(entityId, name, args) {
149
+ if (!this.getEntity(entityId)) {
150
+ throw new UnknownEntityError(this.id, entityId);
151
+ }
152
+ const { command, args: commandArgs, idKey = 'player_id' } = commandFor(name, args);
153
+ await this.#send(command, { [idKey]: entityId, ...commandArgs });
154
+ }
155
+ /** One level of the library, or a search across it, through Music Assistant's own API. */
156
+ async browse(entityId, query) {
157
+ if (!this.getEntity(entityId)) {
158
+ throw new UnknownEntityError(this.id, entityId);
159
+ }
160
+ if (query.search !== undefined) {
161
+ return this.#search(query.search);
162
+ }
163
+ if (query.path === undefined) {
164
+ return { items: SHELVES.map((shelf) => ({ ...shelf, playable: false, expandable: true })) };
165
+ }
166
+ const shelf = SHELVES.find((candidate) => candidate.id === query.path);
167
+ const found = shelf ? await this.#shelfItems(shelf.id) : await this.#childItems(query.path);
168
+ return { ...(shelf ? { title: shelf.title } : {}), items: found };
169
+ }
170
+ async #shelfItems(shelfId) {
171
+ const library = {
172
+ 'shelf:recent': ['music/recently_played_items', { limit: 25 }],
173
+ 'shelf:playlists': ['music/playlists/library_items', { limit: 200, order_by: 'name' }],
174
+ 'shelf:albums': ['music/albums/library_items', { limit: 200, order_by: 'name' }],
175
+ 'shelf:artists': ['music/artists/library_items', { limit: 200, order_by: 'name' }],
176
+ 'shelf:radio': ['music/radios/library_items', { limit: 200, order_by: 'name' }],
177
+ };
178
+ const [command, args] = library[shelfId] ?? [];
179
+ return command ? this.#items(await this.#send(command, args ?? {})) : [];
180
+ }
181
+ /** What is inside an album, a playlist or an artist. */
182
+ async #childItems(uri) {
183
+ const { provider, type, id } = parseUri(uri);
184
+ const command = {
185
+ album: 'music/albums/album_tracks',
186
+ playlist: 'music/playlists/playlist_tracks',
187
+ artist: 'music/artists/artist_albums',
188
+ }[type];
189
+ if (!command) {
190
+ throw new Error(`"${uri}" has nothing inside it`);
191
+ }
192
+ return this.#items(await this.#send(command, { item_id: id, provider_instance_id_or_domain: provider }));
193
+ }
194
+ async #search(text) {
195
+ const results = await this.#send('music/search', {
196
+ search_query: text,
197
+ media_types: ['album', 'artist', 'track', 'playlist', 'radio'],
198
+ limit: 8,
199
+ });
200
+ const groups = isRecord(results)
201
+ ? [results.albums, results.artists, results.tracks, results.playlists, results.radio]
202
+ : [];
203
+ return { title: `Results for “${text}”`, items: groups.flatMap((group) => this.#items(group)) };
204
+ }
205
+ #items(list) {
206
+ return Array.isArray(list)
207
+ ? list.flatMap((item) => {
208
+ const mapped = isRecord(item) ? toBrowseItem(item) : undefined;
209
+ return mapped ? [mapped] : [];
210
+ })
211
+ : [];
212
+ }
213
+ async #open() {
214
+ const socket = (this.#options.createSocket ?? ((url) => new WebSocket(url)))(wsUrl(this.#options.url));
215
+ this.#socket = socket;
216
+ socket.addEventListener('message', (event) => {
217
+ this.#handle(String(event.data));
218
+ });
219
+ const closed = new Promise((_resolve, reject) => {
220
+ socket.addEventListener('close', () => {
221
+ if (this.#socket !== socket) {
222
+ return;
223
+ }
224
+ this.#socket = undefined;
225
+ for (const { reject: rejectPending } of this.#pending.values()) {
226
+ rejectPending(new Error('Connection lost'));
227
+ }
228
+ this.#pending.clear();
229
+ reject(new Error('Music Assistant connection closed'));
230
+ // A close during the first handshake is this attempt failing (status is already
231
+ // 'error', set below) — only a drop after a prior success should move to 'disconnected'
232
+ // and trigger our own reconnect; a first-attempt failure is retried by the caller.
233
+ if (this.#everConnected) {
234
+ this.setStatus('disconnected');
235
+ if (!this.#stopped) {
236
+ this.#scheduleReconnect();
237
+ }
238
+ }
239
+ });
240
+ });
241
+ const opened = new Promise((resolve, reject) => {
242
+ socket.addEventListener('open', () => resolve());
243
+ socket.addEventListener('error', () => reject(new Error('Music Assistant connection failed')));
244
+ });
245
+ try {
246
+ await Promise.race([opened, closed]);
247
+ await Promise.race([this.#authenticateAndLoad(), closed]);
248
+ this.#everConnected = true;
249
+ this.#retry = 0;
250
+ this.setStatus('connected');
251
+ }
252
+ catch (error) {
253
+ this.setStatus('error');
254
+ socket.close();
255
+ throw error;
256
+ }
257
+ }
258
+ #scheduleReconnect() {
259
+ const min = this.#options.reconnectMinMs ?? 500;
260
+ const max = this.#options.reconnectMaxMs ?? 10_000;
261
+ const delay = Math.min(max, min * 2 ** this.#retry++);
262
+ this.#timer = setTimeout(() => {
263
+ if (this.#stopped) {
264
+ return;
265
+ }
266
+ this.setStatus('connecting');
267
+ this.#open().catch(() => this.#scheduleReconnect());
268
+ }, delay);
269
+ }
270
+ async #authenticateAndLoad() {
271
+ await this.#send('auth', { token: this.#options.token });
272
+ const players = await this.#send('players/all', {});
273
+ const next = new Map();
274
+ this.#players.clear();
275
+ if (Array.isArray(players)) {
276
+ for (const player of players) {
277
+ if (isMaPlayer(player)) {
278
+ this.#players.set(player.player_id, player);
279
+ next.set(player.player_id, this.#entityFor(player));
280
+ }
281
+ }
282
+ }
283
+ this.replaceEntities(next);
284
+ // Shuffle lives on the queues. They are asked for after the players are known, and without
285
+ // holding the connection up: a server that does not answer just leaves shuffle unreported.
286
+ this.#send('player_queues/all', {}).then((queues) => {
287
+ if (Array.isArray(queues)) {
288
+ queues.forEach((queue) => this.#applyQueue(queue));
289
+ }
290
+ }, () => { });
291
+ }
292
+ /** What a player looks like with its queue's shuffle setting and position copied onto it. The
293
+ * position is the queue's, not the player's: when playback is resumed Music Assistant starts a
294
+ * new stream at the saved spot, and the player's own counter then starts from 0 again, while
295
+ * the queue keeps counting through the track. */
296
+ #entityFor(player) {
297
+ const queue = this.#queues.get(player.player_id);
298
+ if (!queue) {
299
+ // A stopped player's own counter is back at the start: until the queue says more, no position.
300
+ return toMediaPlayer(player.playback_state === 'idle'
301
+ ? { ...player, elapsed_time: null, elapsed_time_last_updated: null }
302
+ : player);
303
+ }
304
+ const position = positionOf(queue.position, player.playback_state);
305
+ return toMediaPlayer({
306
+ ...player,
307
+ ...(queue.shuffle !== undefined ? { shuffle_enabled: queue.shuffle } : {}),
308
+ ...(position !== undefined
309
+ ? { elapsed_time: position, elapsed_time_last_updated: queue.position.elapsedAt }
310
+ : {}),
311
+ });
312
+ }
313
+ #applyQueue(queue) {
314
+ if (!isRecord(queue) || typeof queue.queue_id !== 'string') {
315
+ return;
316
+ }
317
+ const known = this.#queues.get(queue.queue_id) ?? { position: {} };
318
+ const player = this.#players.get(queue.queue_id);
319
+ const message = {
320
+ item: itemOf(queue),
321
+ elapsed: typeof queue.elapsed_time === 'number' ? queue.elapsed_time : undefined,
322
+ resume: typeof queue.resume_pos === 'number' ? queue.resume_pos : undefined,
323
+ };
324
+ // The player's state says whether it is stopped; the queue's own `state` is not kept in step.
325
+ const playback = player?.playback_state ?? (typeof queue.state === 'string' ? queue.state : undefined);
326
+ const next = {
327
+ ...known,
328
+ ...(typeof queue.shuffle_enabled === 'boolean' ? { shuffle: queue.shuffle_enabled } : {}),
329
+ position: onQueue(known.position, message, playback, Date.now() / 1000),
330
+ };
331
+ this.#changeQueue(queue.queue_id, known, next);
332
+ }
333
+ /** Keeps a queue's new state and, when something it shows changed, the player's entity. */
334
+ #changeQueue(id, known, next) {
335
+ if (next.shuffle === known.shuffle && samePosition(next.position, known.position)) {
336
+ this.#queues.set(id, next);
337
+ return;
338
+ }
339
+ this.#queues.set(id, next);
340
+ const player = this.#players.get(id);
341
+ if (player) {
342
+ this.setEntity(player.player_id, this.#entityFor(player));
343
+ }
344
+ }
345
+ /** A player changed state: a stop remembers where it got to, a start from a stop begins there. */
346
+ #playerChanged(id, from, to) {
347
+ const known = this.#queues.get(id);
348
+ if (known) {
349
+ this.#queues.set(id, {
350
+ ...known,
351
+ position: onPlayback(known.position, from, to, Date.now() / 1000),
352
+ });
353
+ }
354
+ }
355
+ #send(command, args) {
356
+ const socket = this.#socket;
357
+ if (!socket) {
358
+ return Promise.reject(new Error('Not connected'));
359
+ }
360
+ const messageId = String(this.#nextMessageId++);
361
+ return new Promise((resolve, reject) => {
362
+ this.#pending.set(messageId, { resolve, reject });
363
+ socket.send(JSON.stringify({ message_id: messageId, command, args }));
364
+ });
365
+ }
366
+ #handle(raw) {
367
+ let message;
368
+ try {
369
+ message = JSON.parse(raw);
370
+ }
371
+ catch {
372
+ return;
373
+ }
374
+ if (!isRecord(message)) {
375
+ return;
376
+ }
377
+ if (typeof message.event === 'string') {
378
+ this.#handleEvent(message.event, message.object_id, message.data);
379
+ return;
380
+ }
381
+ if (typeof message.message_id !== 'string') {
382
+ return;
383
+ }
384
+ const pending = this.#pending.get(message.message_id);
385
+ if (!pending) {
386
+ return;
387
+ }
388
+ this.#pending.delete(message.message_id);
389
+ if (typeof message.error_code === 'number') {
390
+ pending.reject(new Error(typeof message.details === 'string'
391
+ ? message.details
392
+ : `Music Assistant error ${message.error_code}`));
393
+ }
394
+ else if ('result' in message) {
395
+ pending.resolve(message.result);
396
+ }
397
+ }
398
+ #handleEvent(event, objectId, data) {
399
+ switch (event) {
400
+ case 'player_added':
401
+ case 'player_updated':
402
+ if (isMaPlayer(data)) {
403
+ this.#playerChanged(data.player_id, this.#players.get(data.player_id)?.playback_state, data.playback_state);
404
+ this.#players.set(data.player_id, data);
405
+ this.setEntity(data.player_id, this.#entityFor(data));
406
+ }
407
+ return;
408
+ case 'queue_added':
409
+ case 'queue_updated':
410
+ this.#applyQueue(data);
411
+ return;
412
+ case 'player_removed':
413
+ if (typeof objectId === 'string') {
414
+ this.#players.delete(objectId);
415
+ this.#queues.delete(objectId);
416
+ this.setEntity(objectId, undefined);
417
+ }
418
+ return;
419
+ default:
420
+ return;
421
+ }
422
+ }
423
+ }
@@ -0,0 +1,26 @@
1
+ import type { EntityInput } from '@hashsome/core';
2
+ export interface MaPlayerMedia {
3
+ title?: string | null;
4
+ artist?: string | null;
5
+ album?: string | null;
6
+ image_url?: string | null;
7
+ duration?: number | null;
8
+ }
9
+ export interface MaPlayer {
10
+ player_id: string;
11
+ display_name?: string | null;
12
+ name?: string | null;
13
+ available?: boolean;
14
+ playback_state?: string;
15
+ volume_level?: number | null;
16
+ volume_muted?: boolean | null;
17
+ current_media?: MaPlayerMedia | null;
18
+ /** The queue's shuffle setting, which Music Assistant keeps on the queue, not on the player: the
19
+ * integration copies it onto the player it belongs to. */
20
+ shuffle_enabled?: boolean | null;
21
+ /** Seconds into the current item, as of `elapsed_time_last_updated` (a UTC epoch in seconds). */
22
+ elapsed_time?: number | null;
23
+ elapsed_time_last_updated?: number | null;
24
+ }
25
+ /** Maps a Music Assistant player to the generic `mediaPlayer` entity (everything but `ref`). */
26
+ export declare function toMediaPlayer(player: MaPlayer): EntityInput;
package/dist/mapper.js ADDED
@@ -0,0 +1,50 @@
1
+ const PLAYBACK = {
2
+ playing: 'playing',
3
+ paused: 'paused',
4
+ idle: 'idle',
5
+ buffering: 'buffering',
6
+ };
7
+ /** Maps a Music Assistant player to the generic `mediaPlayer` entity (everything but `ref`). */
8
+ export function toMediaPlayer(player) {
9
+ const media = player.current_media ?? undefined;
10
+ const available = player.available !== false;
11
+ return {
12
+ kind: 'mediaPlayer',
13
+ name: player.display_name ?? player.name ?? player.player_id,
14
+ availability: available ? 'ready' : 'unavailable',
15
+ playback: available ? (PLAYBACK[player.playback_state ?? ''] ?? 'idle') : 'off',
16
+ muted: player.volume_muted === true,
17
+ ...(typeof player.volume_level === 'number' ? { volume: player.volume_level / 100 } : {}),
18
+ ...(media
19
+ ? {
20
+ media: {
21
+ ...(media.title ? { title: media.title } : {}),
22
+ ...(media.artist ? { artist: media.artist } : {}),
23
+ ...(media.album ? { album: media.album } : {}),
24
+ ...(media.image_url ? { artworkUrl: media.image_url } : {}),
25
+ },
26
+ }
27
+ : {}),
28
+ ...(typeof player.shuffle_enabled === 'boolean' ? { shuffle: player.shuffle_enabled } : {}),
29
+ ...(typeof player.elapsed_time === 'number' &&
30
+ typeof player.elapsed_time_last_updated === 'number'
31
+ ? {
32
+ position: player.elapsed_time,
33
+ positionUpdatedAt: new Date(player.elapsed_time_last_updated * 1000).toISOString(),
34
+ }
35
+ : {}),
36
+ ...(typeof media?.duration === 'number' ? { duration: media.duration } : {}),
37
+ capabilities: {
38
+ volume: typeof player.volume_level === 'number',
39
+ mute: typeof player.volume_muted === 'boolean',
40
+ next: true,
41
+ previous: true,
42
+ browse: true,
43
+ search: true,
44
+ seek: true,
45
+ shuffle: true,
46
+ transfer: false,
47
+ group: false,
48
+ },
49
+ };
50
+ }
package/dist/mock.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { MockIntegration, type EntityInput } from '@hashsome/core';
2
+ export interface MusicAssistantMockOptions {
3
+ /** Integration id. Defaults to `ma`. */
4
+ id?: string;
5
+ /** Extra entities, by local id; they replace a default with the same id. */
6
+ entities?: Record<string, EntityInput>;
7
+ }
8
+ /** A Music Assistant integration that needs no Music Assistant: representative players, mapped the
9
+ * same way real ones are, and commands that change their state. For tests, the gallery and
10
+ * dashboards under development. */
11
+ export declare function createMock(options?: MusicAssistantMockOptions): MockIntegration;
package/dist/mock.js ADDED
@@ -0,0 +1,67 @@
1
+ import { MockIntegration, mockLibrary } from '@hashsome/core';
2
+ import { toMediaPlayer } from './mapper.js';
3
+ /** What Music Assistant reports for a few players: one per state a dashboard has to handle. Raw
4
+ * players, so the mock is built by the same mapping the real integration uses. */
5
+ const players = () => [
6
+ {
7
+ player_id: 'living_room',
8
+ display_name: 'Living room',
9
+ available: true,
10
+ playback_state: 'playing',
11
+ volume_level: 40,
12
+ volume_muted: false,
13
+ current_media: {
14
+ title: 'Blank Space',
15
+ artist: 'More More',
16
+ album: '1989',
17
+ duration: 231,
18
+ image_url: 'https://picsum.photos/seed/aurora/400',
19
+ },
20
+ elapsed_time: 64,
21
+ elapsed_time_last_updated: Date.now() / 1000,
22
+ },
23
+ {
24
+ player_id: 'kitchen',
25
+ display_name: 'Kitchen',
26
+ available: true,
27
+ playback_state: 'paused',
28
+ volume_level: 25,
29
+ volume_muted: true,
30
+ current_media: {
31
+ title: 'Dreams',
32
+ artist: 'Fleetwood Mac',
33
+ duration: 214,
34
+ image_url: 'https://picsum.photos/seed/dreams/400',
35
+ },
36
+ shuffle_enabled: true,
37
+ elapsed_time: 95,
38
+ elapsed_time_last_updated: Date.now() / 1000,
39
+ },
40
+ {
41
+ player_id: 'office',
42
+ display_name: 'Office',
43
+ available: true,
44
+ playback_state: 'idle',
45
+ volume_level: 15,
46
+ volume_muted: false,
47
+ },
48
+ {
49
+ player_id: 'garage',
50
+ display_name: 'Garage',
51
+ available: false,
52
+ },
53
+ ];
54
+ /** A Music Assistant integration that needs no Music Assistant: representative players, mapped the
55
+ * same way real ones are, and commands that change their state. For tests, the gallery and
56
+ * dashboards under development. */
57
+ export function createMock(options = {}) {
58
+ const entities = {};
59
+ for (const player of players()) {
60
+ entities[player.player_id] = toMediaPlayer(player);
61
+ }
62
+ return new MockIntegration({
63
+ id: options.id ?? 'ma',
64
+ library: mockLibrary(),
65
+ entities: { ...entities, ...options.entities },
66
+ });
67
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Where playback is, as the dashboard shows it, for one Music Assistant player and its queue.
3
+ *
4
+ * Music Assistant does not make this easy. It sends no queue updates while a track plays, only on
5
+ * changes, so a position has to be carried forward from the last one. A pause on a player that
6
+ * cannot hold a stream open is really a stop: the queue's counter goes back to the start and only
7
+ * its `resume_pos` says where playback will pick up, and that number is cleared as playback starts.
8
+ * Reading each message as the truth makes the counter jump back and forth around a pause.
9
+ *
10
+ * So this keeps a position of its own, and follows two rules. While stopped, show where it was
11
+ * stopped. Once playing, the queue's own counter is the truth, from its first message on.
12
+ *
13
+ * Everything here is pure: a state in, a message in, a state out.
14
+ */
15
+ export interface Position {
16
+ /** The track the queue is on, to tell a new track from a pause on the same one. */
17
+ item?: string;
18
+ /** Where playback was believed to be, in seconds, as of `elapsedAt` (UTC epoch seconds). */
19
+ elapsed?: number;
20
+ elapsedAt?: number;
21
+ /** Where Music Assistant says it will pick the track up again. Only means anything while stopped. */
22
+ resume?: number;
23
+ /** Where playback was when it stopped: the last position plus the time since it was seen. */
24
+ held?: number;
25
+ /** What the last track's resume spot was, which a new track starts out carrying for a while. */
26
+ inherited?: number;
27
+ }
28
+ /** A queue message: what it says about the track, the counter and the resume position. */
29
+ export interface QueueMessage {
30
+ item?: string | undefined;
31
+ elapsed?: number | undefined;
32
+ resume?: number | undefined;
33
+ }
34
+ export declare function onQueue(position: Position, message: QueueMessage, playback: string | undefined, now: number): Position;
35
+ /** The player's state changed: a stop remembers where it got to, and a start from a stop begins there. */
36
+ export declare function onPlayback(position: Position, from: string | undefined, to: string | undefined, now: number): Position;
37
+ /** What to show: playing, the counter; stopped, the later of where it was held and the resume spot. */
38
+ export declare function positionOf(position: Position, playback: string | undefined): number | undefined;