@khalido/home 0.0.0-stage → 0.1.1
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/.env.example +25 -0
- package/CHANGELOG.md +47 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/clock.d.ts +17 -0
- package/dist/clock.js +43 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.js +392 -0
- package/dist/connector.d.ts +34 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/amber/client.d.ts +61 -0
- package/dist/connectors/amber/client.js +106 -0
- package/dist/connectors/amber/index.d.ts +30 -0
- package/dist/connectors/amber/index.js +94 -0
- package/dist/connectors/cast/codec.d.ts +38 -0
- package/dist/connectors/cast/codec.js +102 -0
- package/dist/connectors/cast/index.d.ts +60 -0
- package/dist/connectors/cast/index.js +428 -0
- package/dist/connectors/cast/schemas.d.ts +167 -0
- package/dist/connectors/cast/schemas.js +87 -0
- package/dist/connectors/cast/serve.d.ts +18 -0
- package/dist/connectors/cast/serve.js +57 -0
- package/dist/connectors/cast/state.d.ts +62 -0
- package/dist/connectors/cast/state.js +133 -0
- package/dist/connectors/cast/tls.d.ts +3 -0
- package/dist/connectors/cast/tls.js +67 -0
- package/dist/connectors/cast/writes.d.ts +80 -0
- package/dist/connectors/cast/writes.js +390 -0
- package/dist/connectors/daikin/client.d.ts +65 -0
- package/dist/connectors/daikin/client.js +221 -0
- package/dist/connectors/daikin/index.d.ts +41 -0
- package/dist/connectors/daikin/index.js +108 -0
- package/dist/connectors/enphase/client.d.ts +65 -0
- package/dist/connectors/enphase/client.js +173 -0
- package/dist/connectors/enphase/index.d.ts +43 -0
- package/dist/connectors/enphase/index.js +174 -0
- package/dist/connectors/enphase/schemas.d.ts +51 -0
- package/dist/connectors/enphase/schemas.js +34 -0
- package/dist/connectors/hue/client.d.ts +48 -0
- package/dist/connectors/hue/client.js +95 -0
- package/dist/connectors/hue/events.d.ts +41 -0
- package/dist/connectors/hue/events.js +156 -0
- package/dist/connectors/hue/index.d.ts +42 -0
- package/dist/connectors/hue/index.js +205 -0
- package/dist/connectors/hue/schemas.d.ts +65 -0
- package/dist/connectors/hue/schemas.js +48 -0
- package/dist/connectors/hue/state.d.ts +43 -0
- package/dist/connectors/hue/state.js +84 -0
- package/dist/connectors/printers/client.d.ts +28 -0
- package/dist/connectors/printers/client.js +74 -0
- package/dist/connectors/printers/codec.d.ts +76 -0
- package/dist/connectors/printers/codec.js +246 -0
- package/dist/connectors/printers/found.d.ts +15 -0
- package/dist/connectors/printers/found.js +35 -0
- package/dist/connectors/printers/index.d.ts +52 -0
- package/dist/connectors/printers/index.js +282 -0
- package/dist/connectors/printers/state.d.ts +60 -0
- package/dist/connectors/printers/state.js +99 -0
- package/dist/connectors/spotify/changes.d.ts +32 -0
- package/dist/connectors/spotify/changes.js +84 -0
- package/dist/connectors/spotify/client.d.ts +199 -0
- package/dist/connectors/spotify/client.js +270 -0
- package/dist/connectors/spotify/index.d.ts +92 -0
- package/dist/connectors/spotify/index.js +754 -0
- package/dist/connectors/up/client.d.ts +183 -0
- package/dist/connectors/up/client.js +308 -0
- package/dist/connectors/up/index.d.ts +91 -0
- package/dist/connectors/up/index.js +213 -0
- package/dist/connectors/up/spend.d.ts +78 -0
- package/dist/connectors/up/spend.js +213 -0
- package/dist/connectors/up/webhook.d.ts +13 -0
- package/dist/connectors/up/webhook.js +32 -0
- package/dist/connectors/weather/blocks.d.ts +51 -0
- package/dist/connectors/weather/blocks.js +106 -0
- package/dist/connectors/weather/bom.d.ts +37 -0
- package/dist/connectors/weather/bom.js +237 -0
- package/dist/connectors/weather/ftp.d.ts +5 -0
- package/dist/connectors/weather/ftp.js +46 -0
- package/dist/connectors/weather/google.d.ts +31 -0
- package/dist/connectors/weather/google.js +364 -0
- package/dist/connectors/weather/index.d.ts +53 -0
- package/dist/connectors/weather/index.js +280 -0
- package/dist/connectors/weather/open-meteo.d.ts +15 -0
- package/dist/connectors/weather/open-meteo.js +285 -0
- package/dist/connectors/weather/rain.d.ts +19 -0
- package/dist/connectors/weather/rain.js +51 -0
- package/dist/connectors/weather/source.d.ts +149 -0
- package/dist/connectors/weather/source.js +77 -0
- package/dist/connectors/weather/sun.d.ts +48 -0
- package/dist/connectors/weather/sun.js +65 -0
- package/dist/discover.d.ts +197 -0
- package/dist/discover.js +452 -0
- package/dist/energy.d.ts +61 -0
- package/dist/energy.js +162 -0
- package/dist/finders-entry.d.ts +1 -0
- package/dist/finders-entry.js +2 -0
- package/dist/finders.d.ts +48 -0
- package/dist/finders.js +176 -0
- package/dist/health.d.ts +28 -0
- package/dist/health.js +35 -0
- package/dist/home.d.ts +81 -0
- package/dist/home.js +114 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/lane.d.ts +17 -0
- package/dist/lane.js +74 -0
- package/dist/locate.d.ts +27 -0
- package/dist/locate.js +59 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +4 -0
- package/dist/manifests.d.ts +5 -0
- package/dist/manifests.js +23 -0
- package/dist/node-fetch.d.ts +9 -0
- package/dist/node-fetch.js +25 -0
- package/dist/poll.d.ts +10 -0
- package/dist/poll.js +33 -0
- package/docs/cast.md +133 -0
- package/docs/cli.md +41 -0
- package/docs/config.md +36 -0
- package/docs/decisions.md +150 -0
- package/docs/devices.md +128 -0
- package/docs/enphase-notes.md +24 -0
- package/docs/google.md +92 -0
- package/docs/ideas.md +69 -0
- package/docs/matter.md +55 -0
- package/docs/performance.md +12 -0
- package/docs/philosophy.md +23 -0
- package/docs/up.md +84 -0
- package/docs/using.md +65 -0
- package/docs/weather-probe.md +63 -0
- package/examples/home.jsonc +74 -0
- package/examples/home.svg +76 -0
- package/home.schema.json +474 -0
- package/llms.txt +32 -0
- package/package.json +143 -4
- package/skills/draw-home/README.md +25 -0
- package/skills/draw-home/SKILL.md +80 -0
- package/skills/use-home/SKILL.md +86 -0
- package/src/cli/cache.ts +29 -0
- package/src/cli/cast.ts +172 -0
- package/src/cli/config.ts +52 -0
- package/src/cli/context.ts +118 -0
- package/src/cli/discover.ts +182 -0
- package/src/cli/enphase.ts +62 -0
- package/src/cli/hue.ts +124 -0
- package/src/cli/locate.ts +47 -0
- package/src/cli/main.ts +182 -0
- package/src/cli/printers.ts +21 -0
- package/src/cli/probe.ts +274 -0
- package/src/cli/spotify.ts +76 -0
- package/src/cli/status.ts +228 -0
- package/src/cli/up.ts +350 -0
- package/src/clock.ts +62 -0
- package/src/config.ts +578 -0
- package/src/connector.ts +77 -0
- package/src/connectors/amber/README.md +40 -0
- package/src/connectors/amber/client.ts +157 -0
- package/src/connectors/amber/index.ts +118 -0
- package/src/connectors/cast/README.md +44 -0
- package/src/connectors/cast/codec.ts +126 -0
- package/src/connectors/cast/index.ts +552 -0
- package/src/connectors/cast/schemas.ts +101 -0
- package/src/connectors/cast/serve.ts +82 -0
- package/src/connectors/cast/state.ts +203 -0
- package/src/connectors/cast/tls.ts +100 -0
- package/src/connectors/cast/writes.ts +572 -0
- package/src/connectors/daikin/README.md +78 -0
- package/src/connectors/daikin/client.ts +295 -0
- package/src/connectors/daikin/index.ts +145 -0
- package/src/connectors/enphase/README.md +64 -0
- package/src/connectors/enphase/client.ts +244 -0
- package/src/connectors/enphase/index.ts +241 -0
- package/src/connectors/enphase/schemas.ts +45 -0
- package/src/connectors/hue/README.md +50 -0
- package/src/connectors/hue/client.ts +157 -0
- package/src/connectors/hue/events.ts +182 -0
- package/src/connectors/hue/index.ts +261 -0
- package/src/connectors/hue/schemas.ts +60 -0
- package/src/connectors/hue/state.ts +124 -0
- package/src/connectors/printers/README.md +46 -0
- package/src/connectors/printers/client.ts +114 -0
- package/src/connectors/printers/codec.ts +292 -0
- package/src/connectors/printers/found.ts +46 -0
- package/src/connectors/printers/index.ts +385 -0
- package/src/connectors/printers/state.ts +182 -0
- package/src/connectors/spotify/README.md +83 -0
- package/src/connectors/spotify/changes.ts +127 -0
- package/src/connectors/spotify/client.ts +387 -0
- package/src/connectors/spotify/index.ts +937 -0
- package/src/connectors/up/README.md +35 -0
- package/src/connectors/up/client.ts +518 -0
- package/src/connectors/up/index.ts +370 -0
- package/src/connectors/up/spend.ts +280 -0
- package/src/connectors/up/webhook.ts +62 -0
- package/src/connectors/weather/README.md +84 -0
- package/src/connectors/weather/blocks.ts +173 -0
- package/src/connectors/weather/bom.ts +344 -0
- package/src/connectors/weather/ftp.ts +48 -0
- package/src/connectors/weather/google.ts +425 -0
- package/src/connectors/weather/index.ts +396 -0
- package/src/connectors/weather/open-meteo.ts +309 -0
- package/src/connectors/weather/rain.ts +73 -0
- package/src/connectors/weather/source.ts +236 -0
- package/src/connectors/weather/sun.ts +114 -0
- package/src/discover.ts +638 -0
- package/src/energy.ts +221 -0
- package/src/finders-entry.ts +2 -0
- package/src/finders.ts +250 -0
- package/src/health.ts +74 -0
- package/src/home.ts +205 -0
- package/src/index.ts +62 -0
- package/src/lane.ts +110 -0
- package/src/locate.ts +85 -0
- package/src/manifest.ts +43 -0
- package/src/manifests.ts +27 -0
- package/src/node-fetch.ts +41 -0
- package/src/poll.ts +33 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { asOf, type Clock, realClock } from "../../clock.ts";
|
|
3
|
+
import { ConnectorError, errorText } from "../../connector.ts";
|
|
4
|
+
import type { Integration, Part, Status } from "../../home.ts";
|
|
5
|
+
import { writeLane } from "../../lane.ts";
|
|
6
|
+
import type { Manifest } from "../../manifest.ts";
|
|
7
|
+
import { spotifyText, stallText } from "./changes.ts";
|
|
8
|
+
import {
|
|
9
|
+
commandFrame,
|
|
10
|
+
fold,
|
|
11
|
+
Frame,
|
|
12
|
+
FRAME_TYPES,
|
|
13
|
+
OPEN,
|
|
14
|
+
oddities,
|
|
15
|
+
positionMs,
|
|
16
|
+
type SocketFactory,
|
|
17
|
+
type SocketLike,
|
|
18
|
+
type SpotifyState,
|
|
19
|
+
type Stall,
|
|
20
|
+
toSpotifyUri,
|
|
21
|
+
} from "./client.ts";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
positionMs,
|
|
25
|
+
type PositionAnchor,
|
|
26
|
+
type SocketFactory,
|
|
27
|
+
type SocketLike,
|
|
28
|
+
type SpotifyState,
|
|
29
|
+
type Stall,
|
|
30
|
+
toSpotifyUri,
|
|
31
|
+
type Track,
|
|
32
|
+
} from "./client.ts";
|
|
33
|
+
export {
|
|
34
|
+
type SpeakerState,
|
|
35
|
+
speakerState,
|
|
36
|
+
type SpotifyEvent,
|
|
37
|
+
spotifyChanges,
|
|
38
|
+
spotifyText,
|
|
39
|
+
} from "./changes.ts";
|
|
40
|
+
|
|
41
|
+
/** Soloist has no default port (`--ws ADDR:PORT` opens it); this library assumes this one, the port in chota-bot's unit. */
|
|
42
|
+
export const DEFAULT_URL = "ws://127.0.0.1:9091";
|
|
43
|
+
|
|
44
|
+
/** A Soloist speaker in a home config. No key: Soloist's API key is the daemon's, never the library's. */
|
|
45
|
+
export const configSchema = z.looseObject({
|
|
46
|
+
connector: z.literal("spotify"),
|
|
47
|
+
url: z
|
|
48
|
+
.url({ protocol: /^wss?$/ })
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
`Soloist's WebSocket, as its --ws flag opens it; ${DEFAULT_URL} when left out. It has no auth: keep it on loopback`,
|
|
52
|
+
),
|
|
53
|
+
maxVolume: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(0)
|
|
57
|
+
.max(100)
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
"the loudest a write may set (0–100), and where a first play that starts above it is lowered to (Soloist issue #23)",
|
|
61
|
+
),
|
|
62
|
+
repairStalls: z
|
|
63
|
+
.boolean()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
"when playback stalls while Soloist says playing (Soloist issue #5), try once per track to restart it: a seek, then pause and play, or a skip when it is stuck past the track's end. Off unless set",
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export type SpotifyConfig = {
|
|
71
|
+
/** Soloist's WebSocket; `DEFAULT_URL` when left out. */
|
|
72
|
+
url?: string;
|
|
73
|
+
/** Volume ceiling 0–100 for writes, and for a first play that starts louder (issue #23). */
|
|
74
|
+
maxVolume?: number;
|
|
75
|
+
/** Try once per track to restart playback that stalled (issue #5): a write the library makes unasked, so off unless set. */
|
|
76
|
+
repairStalls?: boolean;
|
|
77
|
+
/** How often to look for a stall while playing, in ms; `STALL_CHECK_MS` by default, 0 to never look. */
|
|
78
|
+
stallCheckMs?: number;
|
|
79
|
+
/** Opens the socket; the runtime's `WebSocket` by default. Tests pass a fake. */
|
|
80
|
+
connect?: SocketFactory;
|
|
81
|
+
clock?: Clock;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Soloist's repeat modes. */
|
|
85
|
+
export type Repeat = "off" | "context" | "track";
|
|
86
|
+
|
|
87
|
+
/** What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS` ran out without it. */
|
|
88
|
+
export type Applied = { state: SpotifyState; confirmed: boolean };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
|
|
92
|
+
* quiet, or the pause or lowering failed, so `fn` ran over it), `restored` (put back as it was), `kept` (someone changed it
|
|
93
|
+
* meanwhile, or the pause never showed: left as it is) or `failed` (the resume or restore failed or Soloist has not
|
|
94
|
+
* reported it, so the music may be left paused or quiet). `why` says which, except for `restored`.
|
|
95
|
+
*/
|
|
96
|
+
export type Music = "untouched" | "restored" | "kept" | "failed";
|
|
97
|
+
export type Aside<T> = { value: T; music: Music; why?: string };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The writes, on the object `spotify()` returns. Each resolves once Soloist reports the change it asked for, with the state
|
|
101
|
+
* then (`confirmed: true`), or after `CONFIRM_MS` with the state as it stands (`confirmed: false`). Each rejects with a
|
|
102
|
+
* `ConnectorError` whose `code` is `offline`, `waiting` (nobody has picked the speaker), `refused`, `timeout` or `invalid`.
|
|
103
|
+
*/
|
|
104
|
+
export type SpotifyActions = {
|
|
105
|
+
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
106
|
+
play(linkOrUri?: string): Promise<Applied>;
|
|
107
|
+
pause(): Promise<Applied>;
|
|
108
|
+
resume(): Promise<Applied>;
|
|
109
|
+
next(): Promise<Applied>;
|
|
110
|
+
prev(): Promise<Applied>;
|
|
111
|
+
/** Jump to a position in the current track, in ms. */
|
|
112
|
+
seek(ms: number): Promise<Applied>;
|
|
113
|
+
/** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
|
|
114
|
+
setVolume(volume: number): Promise<Applied>;
|
|
115
|
+
/** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
|
|
116
|
+
queue(track: string): Promise<Applied>;
|
|
117
|
+
setShuffle(on: boolean): Promise<Applied>;
|
|
118
|
+
/** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
|
|
119
|
+
setRepeat(mode: Repeat): Promise<Applied>;
|
|
120
|
+
/**
|
|
121
|
+
* Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
|
|
122
|
+
* phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
|
|
123
|
+
* One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
|
|
124
|
+
* forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
|
|
125
|
+
*/
|
|
126
|
+
interrupt<T>(fn: () => Promise<T> | T): Promise<Aside<T>>;
|
|
127
|
+
/**
|
|
128
|
+
* Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
|
|
129
|
+
* For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
|
|
130
|
+
*/
|
|
131
|
+
duck<T>(fn: () => Promise<T> | T, options: { to: number }): Promise<Aside<T>>;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** How long a command waits for Soloist's answer. Loopback answers in milliseconds; a stated guess. */
|
|
135
|
+
const COMMAND_TIMEOUT_MS = 3000;
|
|
136
|
+
/** How long a query holds the lane for its answer: a query Soloist doesn't answer (an empty queue may send nothing) shouldn't hold the writes behind it long. */
|
|
137
|
+
const QUERY_TIMEOUT_MS = 1000;
|
|
138
|
+
/** A socket that has said nothing this long after opening is closed: Soloist sends `auth_state` at once on connect. */
|
|
139
|
+
const HELLO_MS = 10_000;
|
|
140
|
+
/** After an idle → playing change, a volume above the cap is lowered for this long: the volume can come before or after the status (issue #23). */
|
|
141
|
+
const CAP_WINDOW_MS = 10_000;
|
|
142
|
+
/** How long a write waits for Soloist to report the change it asked for. Loopback events arrive in milliseconds; a stated guess. */
|
|
143
|
+
const CONFIRM_MS = 2000;
|
|
144
|
+
/** Between two commands. Soloist publishes no limit; it is on loopback; a stated guess. */
|
|
145
|
+
const WRITE_GAP_MS = 100;
|
|
146
|
+
/** While playing, a stall check this often: one `get_state`. Soloist issue #5 reports stalls 71–150 s into a track; a stated guess. */
|
|
147
|
+
const STALL_CHECK_MS = 20_000;
|
|
148
|
+
/** Still playing the same track this far past its length: stuck ("never moved on", issue #5). */
|
|
149
|
+
const OVERRUN_MS = 15_000;
|
|
150
|
+
/** After a repair's seek, how long before looking whether the position moves again. */
|
|
151
|
+
const REPAIR_WAIT_MS = 5000;
|
|
152
|
+
const MIN_BACKOFF_MS = 1000;
|
|
153
|
+
const MAX_BACKOFF_MS = 60_000;
|
|
154
|
+
|
|
155
|
+
/** Signed out is Soloist's normal state until someone picks the speaker (seen live 2026-11-08); a bad key looks the same (issue #18). */
|
|
156
|
+
const WAITING =
|
|
157
|
+
"waiting for someone to pick it in the Spotify app; if nobody can, check Soloist's API key (Soloist issue #18)";
|
|
158
|
+
|
|
159
|
+
const REPEAT_ORDER: Record<Repeat, [[string, boolean], [string, boolean]]> = {
|
|
160
|
+
off: [
|
|
161
|
+
["set_repeat_track", false],
|
|
162
|
+
["set_repeat_context", false],
|
|
163
|
+
],
|
|
164
|
+
context: [
|
|
165
|
+
["set_repeat_track", false],
|
|
166
|
+
["set_repeat_context", true],
|
|
167
|
+
],
|
|
168
|
+
track: [
|
|
169
|
+
["set_repeat_context", false],
|
|
170
|
+
["set_repeat_track", true],
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A Spotify Soloist speaker as one integration: one WebSocket to Soloist, its `auth_state` and `playback_state` on connect,
|
|
176
|
+
* then its events folded in. A drop reconnects with backoff (1 s doubling to 60 s). Unreachable is `disconnected`;
|
|
177
|
+
* running without a Spotify session is `connected` with `loggedIn: false` and the detail saying so. The writes are on the
|
|
178
|
+
* same object, through one write lane, over the same socket.
|
|
179
|
+
*/
|
|
180
|
+
export function spotify(
|
|
181
|
+
config: SpotifyConfig = {},
|
|
182
|
+
): Integration<SpotifyState, "spotify"> & SpotifyActions {
|
|
183
|
+
const clock = config.clock ?? realClock;
|
|
184
|
+
const url = config.url ?? DEFAULT_URL;
|
|
185
|
+
const cap = config.maxVolume;
|
|
186
|
+
const connect: SocketFactory =
|
|
187
|
+
config.connect ?? ((u) => new WebSocket(u) as unknown as SocketLike);
|
|
188
|
+
const lane = writeLane({ gapMs: WRITE_GAP_MS, clock });
|
|
189
|
+
const checkMs = config.stallCheckMs ?? STALL_CHECK_MS;
|
|
190
|
+
|
|
191
|
+
let state: SpotifyState = { asOf: asOf(clock) };
|
|
192
|
+
let socket: SocketLike | undefined;
|
|
193
|
+
let open = false;
|
|
194
|
+
let heard = false; // an auth_state on this socket: until then the part stays connecting
|
|
195
|
+
let whole = false; // signed in, the first playback_state has come too (or HELLO_MS passed): a one-shot read sees the track
|
|
196
|
+
let down: string | undefined; // why the last socket ended, while there is none open
|
|
197
|
+
let statusKnown = false; // Soloist has told this socket its status: only then is idle → playing an edge
|
|
198
|
+
let queueAsked = false; // get_queue sent on this socket
|
|
199
|
+
let signedIn = false; // signed out → in on this socket: the first status after it counts as a start if it is playing
|
|
200
|
+
let capUntil = -Infinity;
|
|
201
|
+
let capped = false; // the cap has been sent in this window
|
|
202
|
+
let startBuffering = false; // a start from idle is still buffering
|
|
203
|
+
let capNote: string | undefined; // the last first-play lowering, in `anomaly` until the speaker goes idle
|
|
204
|
+
let frameNote: string | undefined; // a frame the schema rejected, or an error nothing was waiting for
|
|
205
|
+
let note: string | undefined; // a write the integration made itself that failed (the cap, interrupt's resume), in `detail`
|
|
206
|
+
let pending:
|
|
207
|
+
| { command: string; answers?: string; finish: (error?: Error) => void }
|
|
208
|
+
| undefined; // the lane sends one at a time
|
|
209
|
+
const waiters = new Set<() => void>();
|
|
210
|
+
let report: ((part: Part<SpotifyState>) => void) | undefined;
|
|
211
|
+
let wake: AbortController | undefined; // cuts a backoff short on refresh
|
|
212
|
+
let reported = false;
|
|
213
|
+
|
|
214
|
+
const publish = () => {
|
|
215
|
+
let status: Status;
|
|
216
|
+
let detail: string | undefined;
|
|
217
|
+
if (!open)
|
|
218
|
+
((status = reported || down ? "disconnected" : "connecting"),
|
|
219
|
+
(detail = down));
|
|
220
|
+
else if (!heard || (state.loggedIn === true && !whole))
|
|
221
|
+
status = "connecting";
|
|
222
|
+
else
|
|
223
|
+
((status = "connected"),
|
|
224
|
+
(detail = state.loggedIn === false ? WAITING : note));
|
|
225
|
+
// Connecting is reported never: before the first report createHome's placeholder says it, and a reconnect stays
|
|
226
|
+
// disconnected until Soloist has answered, so two reports in a row never hide a change behind a half-done connect.
|
|
227
|
+
if (status === "connecting") return;
|
|
228
|
+
const anomaly =
|
|
229
|
+
[capNote, stallText(state.stalled), oddities(state), frameNote]
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join("; ") || undefined;
|
|
232
|
+
frameNote = undefined;
|
|
233
|
+
reported = true;
|
|
234
|
+
report?.({ status, detail, anomaly, state });
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const offline = () =>
|
|
238
|
+
new ConnectorError(`Soloist is not connected at ${url}`, {
|
|
239
|
+
code: "offline",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Send one command and wait for its answer: a control's `command_result`, or for a query the frame it `answers`
|
|
244
|
+
* (`get_state` → `playback_state`). An `error` carries no command, so it is the one in flight's: every command, queries
|
|
245
|
+
* too, goes through the lane, one at a time.
|
|
246
|
+
*/
|
|
247
|
+
const command = (
|
|
248
|
+
name: string,
|
|
249
|
+
fields: Record<string, unknown> = {},
|
|
250
|
+
answers?: string,
|
|
251
|
+
) =>
|
|
252
|
+
new Promise<void>((resolve, reject) => {
|
|
253
|
+
const s = socket;
|
|
254
|
+
if (!s || !open || s.readyState !== OPEN) return reject(offline());
|
|
255
|
+
if (state.loggedIn === false && !answers)
|
|
256
|
+
return reject(new ConnectorError(WAITING, { code: "waiting" }));
|
|
257
|
+
const stop = new AbortController();
|
|
258
|
+
const p = {
|
|
259
|
+
command: name,
|
|
260
|
+
answers,
|
|
261
|
+
finish: (error?: Error) => {
|
|
262
|
+
if (pending !== p) return;
|
|
263
|
+
pending = undefined;
|
|
264
|
+
stop.abort();
|
|
265
|
+
if (error) return reject(error);
|
|
266
|
+
if (!answers) note = undefined; // a write that worked: an earlier failed one is no longer news
|
|
267
|
+
resolve();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
pending = p;
|
|
271
|
+
s.send(commandFrame(name, fields));
|
|
272
|
+
const ms = answers ? QUERY_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
|
|
273
|
+
void clock
|
|
274
|
+
.sleep(ms, stop.signal)
|
|
275
|
+
.then(
|
|
276
|
+
() =>
|
|
277
|
+
stop.signal.aborted ||
|
|
278
|
+
p.finish(
|
|
279
|
+
new ConnectorError(
|
|
280
|
+
`Soloist did not answer ${name} in ${ms / 1000} s`,
|
|
281
|
+
{ code: "timeout" },
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/** A command, and for a write how to tell it took: `took(now, before, at)`, `before` the state and `at` the time it was sent. */
|
|
288
|
+
type Body = {
|
|
289
|
+
command: string;
|
|
290
|
+
fields?: Record<string, unknown>;
|
|
291
|
+
answers?: string;
|
|
292
|
+
then?: { command: string; fields: Record<string, unknown> };
|
|
293
|
+
took?: (now: SpotifyState, before: SpotifyState, at: number) => boolean;
|
|
294
|
+
};
|
|
295
|
+
const send = async (body: Body) => {
|
|
296
|
+
const before = state;
|
|
297
|
+
const at = clock.now();
|
|
298
|
+
await command(body.command, body.fields, body.answers);
|
|
299
|
+
if (body.then) await command(body.then.command, body.then.fields); // repeat's second half, still this write's turn
|
|
300
|
+
return { before, at };
|
|
301
|
+
};
|
|
302
|
+
let once = 0; // skips and queued tracks never merge: two nexts are two skips
|
|
303
|
+
const write = (slot: string, body: Body) => lane.send(slot, body, send);
|
|
304
|
+
/**
|
|
305
|
+
* A write through the lane, resolved once the state shows that this caller's own change took, or `CONFIRM_MS` after
|
|
306
|
+
* Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
|
|
307
|
+
* confirms). A drop ends the wait early, unconfirmed.
|
|
308
|
+
*/
|
|
309
|
+
const change = async (slot: string, body: Body): Promise<Applied> => {
|
|
310
|
+
const sent = await write(slot, body);
|
|
311
|
+
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
312
|
+
await waitFor(() => took() || !open, CONFIRM_MS);
|
|
313
|
+
return { state, confirmed: took() };
|
|
314
|
+
};
|
|
315
|
+
/** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
|
|
316
|
+
const restarted = (s: SpotifyState, b: SpotifyState, at: number) =>
|
|
317
|
+
s.position !== b.position &&
|
|
318
|
+
(s.position?.ms ?? Infinity) < 5000 &&
|
|
319
|
+
(positionMs(b, at) ?? 0) >= 5000;
|
|
320
|
+
const newTrack = (s: SpotifyState, b: SpotifyState) =>
|
|
321
|
+
!!s.track && s.track.uri !== b.track?.uri;
|
|
322
|
+
const invalid = (message: string) =>
|
|
323
|
+
new ConnectorError(message, { code: "invalid" });
|
|
324
|
+
const volumeTo = (v: number) =>
|
|
325
|
+
change("volume", {
|
|
326
|
+
command: "set_volume",
|
|
327
|
+
fields: { volume: v },
|
|
328
|
+
took: (s) => s.volume === v,
|
|
329
|
+
});
|
|
330
|
+
/** Ask Soloist for something, through the lane; the same query twice merges. A refusal is an anomaly, a drop is the status's to say. */
|
|
331
|
+
const ANSWERS = {
|
|
332
|
+
get_state: "playback_state",
|
|
333
|
+
get_auth_state: "auth_state",
|
|
334
|
+
get_queue: "queue_changed",
|
|
335
|
+
} as const;
|
|
336
|
+
const query = (
|
|
337
|
+
name: keyof typeof ANSWERS,
|
|
338
|
+
fields?: Record<string, unknown>,
|
|
339
|
+
) =>
|
|
340
|
+
void write(`query-${name}`, {
|
|
341
|
+
command: name,
|
|
342
|
+
fields,
|
|
343
|
+
answers: ANSWERS[name],
|
|
344
|
+
}).catch((error) => {
|
|
345
|
+
// A drop is the status's to say; an unanswered get_queue may just be an empty queue (unverified).
|
|
346
|
+
if (
|
|
347
|
+
error instanceof ConnectorError &&
|
|
348
|
+
(error.code === "offline" ||
|
|
349
|
+
(error.code === "timeout" && name === "get_queue"))
|
|
350
|
+
)
|
|
351
|
+
return;
|
|
352
|
+
frameNote = `${name} failed: ${errorText(error)}`;
|
|
353
|
+
publish();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/** Within the window after a start from idle, a volume above the cap is lowered to it, once (issue #23). */
|
|
357
|
+
const applyCap = () => {
|
|
358
|
+
if (
|
|
359
|
+
cap === undefined ||
|
|
360
|
+
capped ||
|
|
361
|
+
state.volume === undefined ||
|
|
362
|
+
clock.now() > capUntil ||
|
|
363
|
+
state.volume <= cap
|
|
364
|
+
)
|
|
365
|
+
return;
|
|
366
|
+
capped = true;
|
|
367
|
+
capNote = `started at volume ${state.volume}, above maxVolume ${cap}; lowered it (Soloist issue #23)`;
|
|
368
|
+
change("volume", {
|
|
369
|
+
command: "set_volume",
|
|
370
|
+
fields: { volume: cap },
|
|
371
|
+
took: (s) => s.volume === cap,
|
|
372
|
+
}).catch(
|
|
373
|
+
(error) => (
|
|
374
|
+
(note = `lowering the volume to ${cap} failed: ${errorText(error)}`),
|
|
375
|
+
publish()
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const handle = (data: unknown) => {
|
|
381
|
+
let raw: unknown;
|
|
382
|
+
try {
|
|
383
|
+
raw = JSON.parse(String(data));
|
|
384
|
+
} catch {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const type = (raw as { type?: unknown } | null)?.type;
|
|
388
|
+
if (typeof type !== "string" || !FRAME_TYPES.has(type)) return; // anything new: not read here
|
|
389
|
+
const parsed = Frame.safeParse(raw);
|
|
390
|
+
if (!parsed.success)
|
|
391
|
+
return (
|
|
392
|
+
(frameNote = `a ${type} the schema did not recognise`),
|
|
393
|
+
publish()
|
|
394
|
+
);
|
|
395
|
+
const f = parsed.data;
|
|
396
|
+
if (f.type === "command_result")
|
|
397
|
+
return void (
|
|
398
|
+
pending &&
|
|
399
|
+
pending.command === f.command &&
|
|
400
|
+
pending.finish()
|
|
401
|
+
);
|
|
402
|
+
if (f.type === "error") {
|
|
403
|
+
const message = f.message ?? "an error with no message";
|
|
404
|
+
if (pending)
|
|
405
|
+
return pending.finish(
|
|
406
|
+
new ConnectorError(`Soloist refused ${pending.command}: ${message}`, {
|
|
407
|
+
code: "refused",
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
return ((frameNote = `Soloist said: ${message}`), publish());
|
|
411
|
+
}
|
|
412
|
+
const before = state.status;
|
|
413
|
+
const wasSignedIn = state.loggedIn;
|
|
414
|
+
state = fold(state, f, asOf(clock));
|
|
415
|
+
if (f.type === "auth_state") {
|
|
416
|
+
// Signed out there is no playback_state to wait for, and a later sign-in must not send the part back to connecting.
|
|
417
|
+
if (!heard && f.logged_in !== true) whole = true;
|
|
418
|
+
heard = true;
|
|
419
|
+
if (f.logged_in === false) statusKnown = false;
|
|
420
|
+
// A first sign-in (someone picked the speaker) sends only auth_state; the docs don't promise a playback_state after it.
|
|
421
|
+
else if (f.logged_in === true && wasSignedIn === false)
|
|
422
|
+
((signedIn = true), query("get_state"));
|
|
423
|
+
}
|
|
424
|
+
if (f.type === "playback_state") {
|
|
425
|
+
if (!queueAsked) ((queueAsked = true), query("get_queue", { limit: 10 })); // queue_changed pushes changes only, so ask once per socket
|
|
426
|
+
whole = true;
|
|
427
|
+
}
|
|
428
|
+
if (pending?.answers === f.type) pending.finish();
|
|
429
|
+
if (
|
|
430
|
+
(f.type === "playback_state" || f.type === "playback_changed") &&
|
|
431
|
+
f.status !== undefined
|
|
432
|
+
) {
|
|
433
|
+
// A start is idle → playing or buffering, or a sign-in whose first status is either: a phone picked the speaker and played (issue #23).
|
|
434
|
+
// Soloist's jump to 100 comes about 100 ms after `playing` (issue #23), so a start through buffering opens the window again then.
|
|
435
|
+
const started =
|
|
436
|
+
((f.status === "playing" || f.status === "buffering") &&
|
|
437
|
+
(statusKnown ? before === "idle" : signedIn)) ||
|
|
438
|
+
(f.status === "playing" && startBuffering);
|
|
439
|
+
if (started) ((capUntil = clock.now() + CAP_WINDOW_MS), (capped = false));
|
|
440
|
+
startBuffering = f.status === "buffering" && (started || startBuffering);
|
|
441
|
+
signedIn = false;
|
|
442
|
+
if (f.status === "idle") capNote = undefined;
|
|
443
|
+
statusKnown = true;
|
|
444
|
+
}
|
|
445
|
+
stallOver();
|
|
446
|
+
if (state.status !== "playing") seen = undefined; // a pause between two checks must not read as frozen
|
|
447
|
+
applyCap();
|
|
448
|
+
for (const w of [...waiters]) w();
|
|
449
|
+
publish();
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/** One socket's life: resolves with why it ended, and whether it got as far as open. */
|
|
453
|
+
const session = (signal: AbortSignal) =>
|
|
454
|
+
new Promise<{ why: string; opened: boolean }>((resolve) => {
|
|
455
|
+
let s: SocketLike;
|
|
456
|
+
try {
|
|
457
|
+
s = connect(url);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
return resolve({
|
|
460
|
+
why: `could not open ${url}: ${errorText(error)}`,
|
|
461
|
+
opened: false,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
socket = s;
|
|
465
|
+
const quiet = new AbortController();
|
|
466
|
+
let ended = false;
|
|
467
|
+
const end = (why: string) => {
|
|
468
|
+
if (ended) return;
|
|
469
|
+
ended = true;
|
|
470
|
+
quiet.abort();
|
|
471
|
+
signal.removeEventListener("abort", stop);
|
|
472
|
+
if (socket === s) socket = undefined;
|
|
473
|
+
const was = open;
|
|
474
|
+
open = false;
|
|
475
|
+
heard = false;
|
|
476
|
+
whole = false;
|
|
477
|
+
queueAsked = false;
|
|
478
|
+
statusKnown = false;
|
|
479
|
+
signedIn = false;
|
|
480
|
+
startBuffering = false;
|
|
481
|
+
pending?.finish(offline());
|
|
482
|
+
for (const w of [...waiters]) w(); // a write waiting to confirm stops waiting
|
|
483
|
+
seen = undefined;
|
|
484
|
+
resolve({ why: was ? why : `nothing answered at ${url}`, opened: was });
|
|
485
|
+
};
|
|
486
|
+
const stop = () => (end("stopped"), s.close());
|
|
487
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
488
|
+
// An ended socket's late events belong to no session: they must not touch the next socket's state.
|
|
489
|
+
s.onopen = () => {
|
|
490
|
+
if (ended) return;
|
|
491
|
+
open = true;
|
|
492
|
+
down = undefined;
|
|
493
|
+
note = undefined; // a new socket: what failed on the last one is history
|
|
494
|
+
void clock.sleep(HELLO_MS, quiet.signal).then(() => {
|
|
495
|
+
if (quiet.signal.aborted) return;
|
|
496
|
+
if (!heard)
|
|
497
|
+
return (
|
|
498
|
+
end(`Soloist said nothing in ${HELLO_MS / 1000} s`),
|
|
499
|
+
s.close()
|
|
500
|
+
);
|
|
501
|
+
if (!whole) ((whole = true), publish()); // signed in, no playback_state: connected with what there is
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
s.onmessage = (ev) => void (ended || handle(ev.data));
|
|
505
|
+
s.onerror = () => {}; // onclose follows and says it
|
|
506
|
+
s.onclose = () => end("Soloist closed the socket");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
/** Until `ok()` or `ms`, whichever first; each state change looks again. */
|
|
510
|
+
const waitFor = async (ok: () => boolean, ms: number) => {
|
|
511
|
+
if (ok()) return;
|
|
512
|
+
const done = new AbortController();
|
|
513
|
+
const check = () => ok() && done.abort();
|
|
514
|
+
waiters.add(check);
|
|
515
|
+
await clock.sleep(ms, done.signal);
|
|
516
|
+
waiters.delete(check);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/** Not connected or not playing: the reason `duck` and `interrupt` just run `fn`. */
|
|
520
|
+
const notPlaying = () =>
|
|
521
|
+
!open
|
|
522
|
+
? "Soloist is not connected"
|
|
523
|
+
: state.status === "playing" || state.status === "buffering"
|
|
524
|
+
? undefined
|
|
525
|
+
: "it was not playing";
|
|
526
|
+
/** A write the integration made itself that failed: in `detail`, and the reason returned. */
|
|
527
|
+
const failed = (why: string) => ((note = why), publish(), why);
|
|
528
|
+
let asides: Promise<unknown> = Promise.resolve();
|
|
529
|
+
/** `state.aside` set or cleared, so `spotifyChanges` can mark what happens meanwhile as the library's own. */
|
|
530
|
+
const marked = (kind?: "duck" | "interrupt" | "repair") => {
|
|
531
|
+
const { aside: _, ...rest } = state;
|
|
532
|
+
state = kind ? { ...rest, aside: kind } : rest;
|
|
533
|
+
stallOver(); // a repair's skip changed the track while it ran
|
|
534
|
+
publish();
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* Run `fn` between `enter` and `leave`, one aside at a time. `enter` returns why it changed nothing (then `fn` just runs);
|
|
538
|
+
* `leave` puts things back, or says why not, and runs even if `fn` throws.
|
|
539
|
+
*/
|
|
540
|
+
const aside = <T>(
|
|
541
|
+
kind: "duck" | "interrupt",
|
|
542
|
+
fn: () => Promise<T> | T,
|
|
543
|
+
enter: () => Promise<string | undefined>,
|
|
544
|
+
leave: () => Promise<Left>,
|
|
545
|
+
) => {
|
|
546
|
+
const run = async (): Promise<Aside<T>> => {
|
|
547
|
+
if (open) marked(kind);
|
|
548
|
+
try {
|
|
549
|
+
return await within();
|
|
550
|
+
} finally {
|
|
551
|
+
if (state.aside) marked();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
const within = async (): Promise<Aside<T>> => {
|
|
555
|
+
const skipped = await enter();
|
|
556
|
+
if (skipped)
|
|
557
|
+
return { value: await fn(), music: "untouched", why: skipped };
|
|
558
|
+
let value: T;
|
|
559
|
+
try {
|
|
560
|
+
value = await fn();
|
|
561
|
+
} catch (error) {
|
|
562
|
+
await leave(); // its outcome is in `detail` when it fails; the error is fn's
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
565
|
+
return { value, ...(await leave()) };
|
|
566
|
+
};
|
|
567
|
+
const p = asides.then(run, run);
|
|
568
|
+
asides = p.catch(() => {});
|
|
569
|
+
return p;
|
|
570
|
+
};
|
|
571
|
+
type Left = Omit<Aside<never>, "value">;
|
|
572
|
+
const putBack = (r: Applied, what: string): Left =>
|
|
573
|
+
r.confirmed
|
|
574
|
+
? { music: "restored" }
|
|
575
|
+
: {
|
|
576
|
+
music: "failed",
|
|
577
|
+
why: failed(`sent the ${what}; Soloist has not reported it`),
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const actions: SpotifyActions = {
|
|
581
|
+
async play(linkOrUri) {
|
|
582
|
+
if (linkOrUri === undefined)
|
|
583
|
+
return change("transport", {
|
|
584
|
+
command: "play",
|
|
585
|
+
took: (s) => s.status === "playing",
|
|
586
|
+
});
|
|
587
|
+
const uri = toSpotifyUri(linkOrUri);
|
|
588
|
+
if (!uri) throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
|
|
589
|
+
return change("transport", {
|
|
590
|
+
command: "play",
|
|
591
|
+
fields: { uri },
|
|
592
|
+
took: (s) =>
|
|
593
|
+
s.status === "playing" &&
|
|
594
|
+
(s.track?.uri === uri || s.context?.uri === uri),
|
|
595
|
+
});
|
|
596
|
+
},
|
|
597
|
+
pause: () =>
|
|
598
|
+
change("transport", {
|
|
599
|
+
command: "pause",
|
|
600
|
+
took: (s) => s.status === "paused",
|
|
601
|
+
}),
|
|
602
|
+
resume: () =>
|
|
603
|
+
change("transport", {
|
|
604
|
+
command: "play",
|
|
605
|
+
took: (s) => s.status === "playing",
|
|
606
|
+
}),
|
|
607
|
+
// The end of the queue goes idle; repeat-track restarts it.
|
|
608
|
+
next: () =>
|
|
609
|
+
change(`once-${++once}`, {
|
|
610
|
+
command: "skip_next",
|
|
611
|
+
took: (s, b, at) =>
|
|
612
|
+
newTrack(s, b) ||
|
|
613
|
+
(s.status === "idle" && b.status !== "idle") ||
|
|
614
|
+
restarted(s, b, at),
|
|
615
|
+
}),
|
|
616
|
+
prev: () =>
|
|
617
|
+
change(`once-${++once}`, {
|
|
618
|
+
command: "skip_prev",
|
|
619
|
+
took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
|
|
620
|
+
}),
|
|
621
|
+
async seek(ms) {
|
|
622
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
623
|
+
throw invalid(`a position in ms, 0 or more, not ${ms}`);
|
|
624
|
+
return change("seek", {
|
|
625
|
+
command: "seek",
|
|
626
|
+
fields: { position_ms: ms },
|
|
627
|
+
took: (s, b) =>
|
|
628
|
+
s.position !== b.position &&
|
|
629
|
+
Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
async setVolume(volume) {
|
|
633
|
+
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
634
|
+
throw invalid(`volume is 0–100, not ${volume}`);
|
|
635
|
+
return volumeTo(
|
|
636
|
+
Math.round(cap === undefined ? volume : Math.min(volume, cap)),
|
|
637
|
+
);
|
|
638
|
+
},
|
|
639
|
+
async queue(track) {
|
|
640
|
+
const uri = toSpotifyUri(track);
|
|
641
|
+
if (!uri?.startsWith("spotify:track:"))
|
|
642
|
+
throw invalid(`only a Spotify track can be queued, not ${track}`);
|
|
643
|
+
return change(`once-${++once}`, {
|
|
644
|
+
command: "add_to_queue",
|
|
645
|
+
fields: { uri },
|
|
646
|
+
took: (s) => !!s.upNext?.some((t) => t.uri === uri),
|
|
647
|
+
});
|
|
648
|
+
},
|
|
649
|
+
setShuffle: (on) =>
|
|
650
|
+
change("shuffle", {
|
|
651
|
+
command: "set_shuffle",
|
|
652
|
+
fields: { enabled: on },
|
|
653
|
+
took: (s) => s.shuffle === on,
|
|
654
|
+
}),
|
|
655
|
+
async setRepeat(mode) {
|
|
656
|
+
// developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
|
|
657
|
+
if (!Object.hasOwn(REPEAT_ORDER, mode))
|
|
658
|
+
throw invalid(`repeat is off, context or track, not ${mode}`);
|
|
659
|
+
const [first, second] = REPEAT_ORDER[mode];
|
|
660
|
+
return change("repeat", {
|
|
661
|
+
command: first[0],
|
|
662
|
+
fields: { enabled: first[1] },
|
|
663
|
+
then: { command: second[0], fields: { enabled: second[1] } },
|
|
664
|
+
took: (s) => s.repeat === mode,
|
|
665
|
+
});
|
|
666
|
+
},
|
|
667
|
+
interrupt(fn) {
|
|
668
|
+
let track: string | undefined;
|
|
669
|
+
let shown = false; // the pause showed
|
|
670
|
+
return aside(
|
|
671
|
+
"interrupt",
|
|
672
|
+
fn,
|
|
673
|
+
async () => {
|
|
674
|
+
const no = notPlaying();
|
|
675
|
+
if (no) return no;
|
|
676
|
+
track = state.track?.uri;
|
|
677
|
+
try {
|
|
678
|
+
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
679
|
+
} catch (error) {
|
|
680
|
+
return failed(`pause before interrupt failed: ${errorText(error)}`);
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
async () => {
|
|
684
|
+
if (state.status !== "paused")
|
|
685
|
+
return {
|
|
686
|
+
music: "kept",
|
|
687
|
+
why: shown
|
|
688
|
+
? `someone changed it meanwhile: it is ${state.status ?? "in an unknown state"}`
|
|
689
|
+
: "Soloist never reported the pause",
|
|
690
|
+
};
|
|
691
|
+
if (state.track?.uri !== track)
|
|
692
|
+
return { music: "kept", why: "the track changed meanwhile" };
|
|
693
|
+
try {
|
|
694
|
+
return putBack(await actions.resume(), "resume");
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return {
|
|
697
|
+
music: "failed",
|
|
698
|
+
why: failed(`resume after interrupt failed: ${errorText(error)}`),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
);
|
|
703
|
+
},
|
|
704
|
+
duck(fn, { to }) {
|
|
705
|
+
if (!Number.isFinite(to) || to < 0 || to > 100)
|
|
706
|
+
return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
|
|
707
|
+
let earlier = 0;
|
|
708
|
+
let shown = false; // the lowered volume showed
|
|
709
|
+
return aside(
|
|
710
|
+
"duck",
|
|
711
|
+
fn,
|
|
712
|
+
async () => {
|
|
713
|
+
const no = notPlaying();
|
|
714
|
+
if (no) return no;
|
|
715
|
+
if (state.volume === undefined) return "its volume is not known";
|
|
716
|
+
if (state.volume <= to) return `it was already at ${state.volume}`;
|
|
717
|
+
earlier = state.volume;
|
|
718
|
+
try {
|
|
719
|
+
shown = (await volumeTo(Math.round(to))).confirmed;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
return failed(
|
|
722
|
+
`lowering the volume for duck failed: ${errorText(error)}`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
async () => {
|
|
727
|
+
if (state.volume !== Math.round(to))
|
|
728
|
+
return {
|
|
729
|
+
music: "kept",
|
|
730
|
+
why: shown
|
|
731
|
+
? `someone set the volume to ${state.volume} meanwhile`
|
|
732
|
+
: "Soloist never reported the lower volume",
|
|
733
|
+
};
|
|
734
|
+
try {
|
|
735
|
+
return putBack(await volumeTo(earlier), `volume ${earlier}`);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
return {
|
|
738
|
+
music: "failed",
|
|
739
|
+
why: failed(
|
|
740
|
+
`restoring the volume to ${earlier} after duck failed: ${errorText(error)}`,
|
|
741
|
+
),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
);
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
/** The stall set or cleared, through `state` so a snapshot and `spotifyChanges` see it. */
|
|
750
|
+
const stallAs = (stall?: Stall) => {
|
|
751
|
+
const { stalled: _, ...rest } = state;
|
|
752
|
+
state = stall ? { ...rest, stalled: stall } : rest;
|
|
753
|
+
publish();
|
|
754
|
+
};
|
|
755
|
+
/** A stall ends when its track changes or stops being played; not during the repair's own pause or skip. */
|
|
756
|
+
function stallOver() {
|
|
757
|
+
const st = state.stalled;
|
|
758
|
+
if (
|
|
759
|
+
!st ||
|
|
760
|
+
state.aside === "repair" ||
|
|
761
|
+
(state.track?.uri === st.track &&
|
|
762
|
+
(state.status === "playing" || state.status === "buffering"))
|
|
763
|
+
)
|
|
764
|
+
return;
|
|
765
|
+
const { stalled: _, ...rest } = state;
|
|
766
|
+
state = rest;
|
|
767
|
+
}
|
|
768
|
+
let seen: { track: string; ms: number; atMs: number } | undefined; // the last check's reported position
|
|
769
|
+
let repairedFor: string | undefined; // at most one repair per track
|
|
770
|
+
/** Ask Soloist where it is, waiting for the answer (or the query's timeout). */
|
|
771
|
+
const fresh = () =>
|
|
772
|
+
write("query-get_state", {
|
|
773
|
+
command: "get_state",
|
|
774
|
+
answers: "playback_state",
|
|
775
|
+
}).then(
|
|
776
|
+
() => undefined,
|
|
777
|
+
() => undefined,
|
|
778
|
+
);
|
|
779
|
+
const where = () =>
|
|
780
|
+
state.track &&
|
|
781
|
+
state.position?.ms !== undefined &&
|
|
782
|
+
state.position.atMs !== undefined
|
|
783
|
+
? {
|
|
784
|
+
track: state.track.uri,
|
|
785
|
+
ms: state.position.ms,
|
|
786
|
+
atMs: state.position.atMs,
|
|
787
|
+
}
|
|
788
|
+
: undefined;
|
|
789
|
+
/** Two fresh reports of one track, at least `gap` apart by Soloist's own clock, less than a second apart in position. */
|
|
790
|
+
const frozen = (a: typeof seen, b: typeof seen, gap = checkMs / 2) =>
|
|
791
|
+
!!a &&
|
|
792
|
+
!!b &&
|
|
793
|
+
a.track === b.track &&
|
|
794
|
+
b.atMs - a.atMs >= gap &&
|
|
795
|
+
Math.abs(b.ms - a.ms) < 1000;
|
|
796
|
+
const playing = () => open && state.status === "playing" && !state.aside;
|
|
797
|
+
|
|
798
|
+
/** One look while playing: a fresh position, then frozen, overrun, recovered or fine (Soloist issue #5). */
|
|
799
|
+
const checkStall = async () => {
|
|
800
|
+
if (!playing()) return void (seen = undefined);
|
|
801
|
+
await fresh();
|
|
802
|
+
if (!playing()) return void (seen = undefined);
|
|
803
|
+
const now = where();
|
|
804
|
+
const track = state.track;
|
|
805
|
+
if (!track) return void (seen = undefined);
|
|
806
|
+
// Repeat-track loops one track, so running past its length is normal there.
|
|
807
|
+
const over =
|
|
808
|
+
state.repeat !== "track" &&
|
|
809
|
+
track.durationMs !== undefined &&
|
|
810
|
+
(positionMs(state, clock.now()) ?? 0) > track.durationMs + OVERRUN_MS;
|
|
811
|
+
const stuck = frozen(seen, now);
|
|
812
|
+
if (
|
|
813
|
+
now &&
|
|
814
|
+
seen &&
|
|
815
|
+
now.track === seen.track &&
|
|
816
|
+
!stuck &&
|
|
817
|
+
!over &&
|
|
818
|
+
state.stalled?.track === now.track
|
|
819
|
+
)
|
|
820
|
+
stallAs(); // it moves again: recovered
|
|
821
|
+
else if ((stuck || over) && !state.stalled) {
|
|
822
|
+
stallAs({
|
|
823
|
+
track: track.uri,
|
|
824
|
+
ms: now?.ms ?? 0,
|
|
825
|
+
how: over ? "overrun" : "frozen",
|
|
826
|
+
});
|
|
827
|
+
if (config.repairStalls && repairedFor !== track.uri)
|
|
828
|
+
await repair(track.uri, over);
|
|
829
|
+
}
|
|
830
|
+
seen = where();
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
/** Once per track: a skip when stuck past the end; otherwise a seek to where it stuck, then pause and play if it still doesn't move. */
|
|
834
|
+
const repair = (track: string, over: boolean) => {
|
|
835
|
+
repairedFor = track;
|
|
836
|
+
const tried = (what: string) =>
|
|
837
|
+
state.stalled && stallAs({ ...state.stalled, tried: what });
|
|
838
|
+
// Still ours to fix: playing this track, stalled, and no duck, interrupt or person in between.
|
|
839
|
+
const ours = () =>
|
|
840
|
+
open &&
|
|
841
|
+
state.status === "playing" &&
|
|
842
|
+
state.track?.uri === track &&
|
|
843
|
+
state.stalled?.track === track &&
|
|
844
|
+
(!state.aside || state.aside === "repair");
|
|
845
|
+
const run = async () => {
|
|
846
|
+
if (!ours()) return;
|
|
847
|
+
marked("repair");
|
|
848
|
+
try {
|
|
849
|
+
if (over) return void (tried("a skip"), await actions.next());
|
|
850
|
+
await actions.seek(state.stalled!.ms);
|
|
851
|
+
tried("a seek");
|
|
852
|
+
const at = where(); // where the seek left it
|
|
853
|
+
await clock.sleep(REPAIR_WAIT_MS);
|
|
854
|
+
if (!ours()) return;
|
|
855
|
+
await fresh();
|
|
856
|
+
if (!ours() || !frozen(at, where(), REPAIR_WAIT_MS / 2)) return;
|
|
857
|
+
await actions.pause();
|
|
858
|
+
await actions.resume();
|
|
859
|
+
tried("a seek, then pause and play");
|
|
860
|
+
} catch (error) {
|
|
861
|
+
tried(`a repair that failed: ${errorText(error)}`);
|
|
862
|
+
} finally {
|
|
863
|
+
if (state.aside === "repair") marked();
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
// In line with duck and interrupt: one aside at a time, so a repair never runs inside an announcement.
|
|
867
|
+
const p = asides.then(run, run);
|
|
868
|
+
asides = p.catch(() => {});
|
|
869
|
+
return p;
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
return {
|
|
873
|
+
...actions,
|
|
874
|
+
name: "spotify",
|
|
875
|
+
/** Ask Soloist for its state again (`get_state`, or `get_auth_state` without a session); while it is unreachable, reconnect now. */
|
|
876
|
+
refresh() {
|
|
877
|
+
const s = socket;
|
|
878
|
+
if (s && open && s.readyState === OPEN)
|
|
879
|
+
return query(
|
|
880
|
+
state.loggedIn === false || !heard ? "get_auth_state" : "get_state",
|
|
881
|
+
);
|
|
882
|
+
wake?.abort();
|
|
883
|
+
},
|
|
884
|
+
async run(signal, update) {
|
|
885
|
+
report = update;
|
|
886
|
+
if (checkMs > 0)
|
|
887
|
+
void (async () => {
|
|
888
|
+
while (!signal.aborted) {
|
|
889
|
+
await clock.sleep(checkMs, signal);
|
|
890
|
+
if (!signal.aborted)
|
|
891
|
+
await checkStall().catch(
|
|
892
|
+
(error) => (
|
|
893
|
+
(frameNote = `stall check failed: ${errorText(error)}`),
|
|
894
|
+
publish()
|
|
895
|
+
),
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
})();
|
|
899
|
+
let backoff = MIN_BACKOFF_MS;
|
|
900
|
+
while (!signal.aborted) {
|
|
901
|
+
const { why, opened } = await session(signal);
|
|
902
|
+
if (signal.aborted) return;
|
|
903
|
+
if (opened) backoff = MIN_BACKOFF_MS; // it had been up: start over
|
|
904
|
+
down = why;
|
|
905
|
+
publish();
|
|
906
|
+
wake = new AbortController();
|
|
907
|
+
await clock.sleep(backoff, AbortSignal.any([signal, wake.signal]));
|
|
908
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export const manifest: Manifest<SpotifyState> = {
|
|
915
|
+
name: "spotify",
|
|
916
|
+
title: "Spotify (Soloist)",
|
|
917
|
+
iotClass: "local_push",
|
|
918
|
+
access: "Soloist's WebSocket on loopback, no key",
|
|
919
|
+
env: [
|
|
920
|
+
{
|
|
921
|
+
name: "SOLOIST_WS",
|
|
922
|
+
example: DEFAULT_URL,
|
|
923
|
+
what: "Soloist's --ws address as a URL; `home status` skips the speaker without it",
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
name: "SOLOIST_MAX_VOLUME",
|
|
927
|
+
example: "60",
|
|
928
|
+
optional: true,
|
|
929
|
+
what: "the loudest `home spotify volume` sets, and where a first play above it is lowered to",
|
|
930
|
+
},
|
|
931
|
+
],
|
|
932
|
+
/** Nothing until Soloist has said something: a speaker never reached has no line. */
|
|
933
|
+
lines: ({ state }) =>
|
|
934
|
+
state && (state.loggedIn !== undefined || state.status !== undefined)
|
|
935
|
+
? [spotifyText(state)]
|
|
936
|
+
: [],
|
|
937
|
+
};
|