@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.
@@ -0,0 +1,94 @@
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
+ /** Only `idle` is a stop. Playing, buffering, paused and a state not yet known all follow the counter. */
16
+ const following = (playback) => playback !== 'idle';
17
+ /** How far before where it was a resumed stream may start, which is not shown as going back. */
18
+ const REWIND_SECONDS = 2;
19
+ export function onQueue(position, message, playback, now) {
20
+ // A different track has nothing to do with where the last one was, and starts out carrying its
21
+ // resume spot, which is not its own.
22
+ const same = message.item === undefined || position.item === undefined || message.item === position.item;
23
+ const base = same
24
+ ? position
25
+ : position.resume !== undefined
26
+ ? { inherited: position.resume }
27
+ : {};
28
+ // What the message says about the resume spot, less the old track's number.
29
+ const resume = message.resume !== base.inherited ? message.resume : undefined;
30
+ const { inherited, ...own } = base;
31
+ const stopped = !following(playback);
32
+ // Stopped, a resume spot that goes down is Music Assistant clearing it as playback is about to
33
+ // start, and not where it will resume.
34
+ const spot = stopped && resume !== undefined && own.resume !== undefined
35
+ ? Math.max(own.resume, resume)
36
+ : resume;
37
+ const next = {
38
+ ...own,
39
+ ...(message.item !== undefined ? { item: message.item } : {}),
40
+ ...(spot !== undefined ? { resume: spot } : {}),
41
+ // Until the track has a resume spot of its own, the old one is still to be ignored.
42
+ ...(resume === undefined && inherited !== undefined ? { inherited } : {}),
43
+ };
44
+ if (message.elapsed === undefined) {
45
+ return next;
46
+ }
47
+ if (stopped) {
48
+ // Its counter is back at the start: only the first number is of any use.
49
+ return next.elapsed === undefined && next.held === undefined
50
+ ? { ...next, elapsed: message.elapsed, elapsedAt: now }
51
+ : next;
52
+ }
53
+ // Playing: the queue's counter is the truth, apart from the stream starting a moment before the
54
+ // spot it was stopped at, which is not shown as a step back.
55
+ const { held, ...rest } = next;
56
+ const elapsed = held !== undefined && message.elapsed >= held - REWIND_SECONDS
57
+ ? Math.max(message.elapsed, held)
58
+ : message.elapsed;
59
+ return { ...rest, elapsed, elapsedAt: now };
60
+ }
61
+ /** The player's state changed: a stop remembers where it got to, and a start from a stop begins there. */
62
+ export function onPlayback(position, from, to, now) {
63
+ if (from === to) {
64
+ return position;
65
+ }
66
+ if (following(from) && !following(to)) {
67
+ if (position.elapsed === undefined) {
68
+ return position;
69
+ }
70
+ const since = from === 'playing' && position.elapsedAt !== undefined
71
+ ? Math.max(0, now - position.elapsedAt)
72
+ : 0;
73
+ // The resume spot is of the last stop, and the next one brings its own.
74
+ const { resume: _resume, ...rest } = position;
75
+ return { ...rest, held: position.elapsed + since };
76
+ }
77
+ if (!following(from) && following(to)) {
78
+ // Starting from a stop: from where it was held or, if that stop was not seen, the spot Music
79
+ // Assistant says it will resume at. Its first queue message says where it really starts.
80
+ const origin = position.held ?? position.resume ?? position.elapsed;
81
+ return origin === undefined
82
+ ? position
83
+ : { ...position, held: origin, elapsed: origin, elapsedAt: now };
84
+ }
85
+ return position;
86
+ }
87
+ /** What to show: playing, the counter; stopped, the later of where it was held and the resume spot. */
88
+ export function positionOf(position, playback) {
89
+ if (following(playback)) {
90
+ return position.elapsed;
91
+ }
92
+ const spots = [position.held, position.resume].filter((spot) => spot !== undefined);
93
+ return spots.length > 0 ? Math.max(...spots) : position.elapsed;
94
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@hashsome/integration.music-assistant",
3
+ "version": "0.1.0",
4
+ "description": "Music Assistant integration: direct WebSocket client, not via Home Assistant",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@hashsome/core": "0.1.0"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./mock": {
15
+ "types": "./dist/mock.d.ts",
16
+ "default": "./dist/mock.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "license": "GPL-3.0-only",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/dstoyanoff/hashsome.git",
24
+ "directory": "packages/integrations/music-assistant"
25
+ },
26
+ "homepage": "https://github.com/dstoyanoff/hashsome#readme",
27
+ "bugs": "https://github.com/dstoyanoff/hashsome/issues",
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "players": "node scripts/print-players.ts",
38
+ "build": "tsc -b tsconfig.build.json"
39
+ }
40
+ }