@khalido/home 0.1.0 → 0.1.2

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README.md +11 -11
  3. package/dist/config.js +3 -1
  4. package/dist/connectors/cast/index.d.ts +4 -1
  5. package/dist/connectors/cast/index.js +10 -2
  6. package/dist/connectors/cast/schemas.d.ts +2 -0
  7. package/dist/connectors/cast/schemas.js +1 -0
  8. package/dist/connectors/cast/serve.d.ts +8 -1
  9. package/dist/connectors/cast/state.d.ts +4 -0
  10. package/dist/connectors/cast/state.js +12 -0
  11. package/dist/connectors/cast/tls.js +1 -1
  12. package/dist/connectors/cast/writes.d.ts +9 -0
  13. package/dist/connectors/cast/writes.js +42 -6
  14. package/dist/connectors/spotify/changes.d.ts +1 -1
  15. package/dist/connectors/spotify/changes.js +4 -2
  16. package/dist/connectors/spotify/client.d.ts +1 -1
  17. package/dist/connectors/spotify/index.d.ts +3 -1
  18. package/dist/connectors/spotify/index.js +114 -45
  19. package/dist/connectors/up/index.d.ts +4 -2
  20. package/dist/connectors/up/index.js +2 -1
  21. package/dist/connectors/weather/index.js +16 -3
  22. package/dist/discover.d.ts +5 -1
  23. package/dist/discover.js +12 -3
  24. package/dist/energy.js +1 -1
  25. package/dist/finders.d.ts +3 -0
  26. package/dist/finders.js +6 -3
  27. package/dist/lane.js +24 -20
  28. package/docs/cast.md +1 -1
  29. package/docs/cli.md +5 -3
  30. package/docs/config.md +2 -2
  31. package/docs/decisions.md +1 -0
  32. package/docs/google.md +1 -1
  33. package/package.json +2 -2
  34. package/skills/draw-home/README.md +5 -6
  35. package/skills/draw-home/SKILL.md +3 -5
  36. package/src/cli/cast.ts +19 -11
  37. package/src/cli/discover.ts +8 -1
  38. package/src/cli/spotify.ts +2 -1
  39. package/src/config.ts +3 -1
  40. package/src/connectors/cast/README.md +7 -5
  41. package/src/connectors/cast/index.ts +19 -5
  42. package/src/connectors/cast/schemas.ts +1 -0
  43. package/src/connectors/cast/serve.ts +11 -1
  44. package/src/connectors/cast/state.ts +12 -0
  45. package/src/connectors/cast/tls.ts +1 -1
  46. package/src/connectors/cast/writes.ts +58 -9
  47. package/src/connectors/daikin/README.md +1 -1
  48. package/src/connectors/spotify/README.md +23 -8
  49. package/src/connectors/spotify/changes.ts +4 -2
  50. package/src/connectors/spotify/client.ts +1 -1
  51. package/src/connectors/spotify/index.ts +120 -48
  52. package/src/connectors/up/README.md +1 -1
  53. package/src/connectors/up/index.ts +5 -2
  54. package/src/connectors/weather/README.md +1 -1
  55. package/src/connectors/weather/index.ts +19 -3
  56. package/src/discover.ts +14 -6
  57. package/src/energy.ts +1 -1
  58. package/src/finders.ts +8 -5
  59. package/src/lane.ts +23 -20
@@ -19,6 +19,8 @@ export const DEFAULT_MEDIA_RECEIVER = "CC1AD845";
19
19
  export const ANSWER_MS = 10_000;
20
20
  /** How long an announcement waits for its clip to finish, beyond the clip's own length when the device reports it. */
21
21
  export const PLAY_MS = 60_000;
22
+ /** How long a clip gets to start playing. A clip a speaker can't play took 35–44 s to say so (live, 2026-11-09); good ones start within a few seconds. A stated guess. */
23
+ export const START_MS = 15_000;
22
24
 
23
25
  const RECEIVER = "receiver-0";
24
26
  /** Level writes, and mute writes, per device or group in any minute; more wait their turn, the newest winning. A guess: Cast publishes no limit. */
@@ -64,6 +66,12 @@ export type CastActions = {
64
66
  clip: CastClip,
65
67
  options?: { volume?: number; timeoutMs?: number },
66
68
  ): Promise<AnnounceResult>;
69
+ /** `announce` to several targets at once, one result per device, in the order given. A target that can't be reached is its own `played: false`. A group and one of its own members are not told apart. */
70
+ announceAll(
71
+ targets: string[],
72
+ clip: CastClip,
73
+ options?: { volume?: number; timeoutMs?: number },
74
+ ): Promise<(AnnounceResult & { target: string })[]>;
67
75
  };
68
76
 
69
77
  /** A payload as it came, before any schema. */
@@ -115,10 +123,11 @@ function waitFor<T>(
115
123
  clock: Clock,
116
124
  s: CastSession,
117
125
  ms: number | (() => number),
118
- what: string,
126
+ what: string | (() => string),
119
127
  pick: (raw: RawPayload) => T | undefined,
120
128
  signal?: AbortSignal,
121
129
  ): Promise<T> {
130
+ const name = () => (typeof what === "string" ? what : what());
122
131
  return new Promise<T>((resolve, reject) => {
123
132
  const done = new AbortController();
124
133
  const finish = (settle: () => void) =>
@@ -128,11 +137,11 @@ function waitFor<T>(
128
137
  done.abort(),
129
138
  settle());
130
139
  const cancel = () =>
131
- finish(() => reject(new Error(`stopped waiting for ${what}`)));
140
+ finish(() => reject(new Error(`stopped waiting for ${name()}`)));
132
141
  const off = s.listen((raw) => {
133
142
  if (raw === undefined)
134
143
  return finish(() =>
135
- reject(new Error(`the socket closed waiting for ${what}`)),
144
+ reject(new Error(`the socket closed waiting for ${name()}`)),
136
145
  );
137
146
  try {
138
147
  const value = pick(raw);
@@ -152,7 +161,7 @@ function waitFor<T>(
152
161
  )
153
162
  await clock.sleep(left, done.signal);
154
163
  finish(() =>
155
- reject(new Error(`no ${what} in ${Math.round(limit() / 1000)} s`)),
164
+ reject(new Error(`no ${name()} in ${Math.round(limit() / 1000)} s`)),
156
165
  );
157
166
  })();
158
167
  });
@@ -318,18 +327,22 @@ export function castActions(ctx: WriteContext): CastActions {
318
327
  const play = async (
319
328
  s: CastSession,
320
329
  transport: string,
321
- media: object,
330
+ media: { contentId: string; contentType: string; streamType: string },
322
331
  timeoutMs?: number,
323
332
  ): Promise<boolean> => {
324
333
  const loadId = s.nextId();
325
334
  let session: number | "any" | undefined;
326
335
  let durationS = 0;
336
+ let started = false; // it has reached PLAYING: BUFFERING is where a clip it can't play sits
327
337
  const cancel = new AbortController();
328
338
  const finished = waitFor(
329
339
  clock,
330
340
  s,
331
- () => timeoutMs ?? PLAY_MS + durationS * 1000,
332
- "end of the clip",
341
+ () => timeoutMs ?? (started ? PLAY_MS + durationS * 1000 : START_MS),
342
+ () =>
343
+ started
344
+ ? "end of the clip"
345
+ : `start of the clip (${media.contentType}, which this device may not play)`,
333
346
  (raw) => {
334
347
  const r = MediaStatus.safeParse(raw);
335
348
  if (!r.success) return undefined;
@@ -345,9 +358,12 @@ export function castActions(ctx: WriteContext): CastActions {
345
358
  continue;
346
359
  if (typeof m.media?.duration === "number")
347
360
  durationS = m.media.duration;
361
+ if (m.playerState === "PLAYING") started = true;
348
362
  if (m.playerState !== "IDLE" || !m.idleReason) continue;
349
363
  if (m.idleReason === "FINISHED") return true;
350
- throw new Error(`the clip stopped: ${m.idleReason}`);
364
+ throw new Error(
365
+ `the clip stopped: ${m.idleReason}${m.detailedErrorCode === undefined ? "" : ` (detailed error ${m.detailedErrorCode})`}${m.idleReason === "ERROR" ? `; this device may not play ${media.contentType}` : ""}`,
366
+ );
351
367
  }
352
368
  return undefined;
353
369
  },
@@ -390,7 +406,7 @@ export function castActions(ctx: WriteContext): CastActions {
390
406
  );
391
407
  };
392
408
 
393
- return {
409
+ const actions: CastActions = {
394
410
  async setVolume(target, level) {
395
411
  checkLevel(level);
396
412
  await setVolume(target, { level });
@@ -568,5 +584,38 @@ export function castActions(ctx: WriteContext): CastActions {
568
584
  }
569
585
  }
570
586
  },
587
+
588
+ async announceAll(targets, clip, options = {}) {
589
+ // A caller's mistake is one error, not a failure per speaker.
590
+ if (options.volume !== undefined) checkLevel(options.volume);
591
+ if (!("url" in clip) && !ctx.serve)
592
+ throw new Error(
593
+ "a clip of bytes needs serve: serveClip from @khalido/home/cast/serve",
594
+ );
595
+ const id = (t: string) => {
596
+ try {
597
+ return find(t).id;
598
+ } catch {
599
+ return t; // unknown: announce says so, as this target's own failure
600
+ }
601
+ };
602
+ const once = targets.filter(
603
+ (t, i) => targets.findIndex((o) => id(o) === id(t)) === i,
604
+ ); // one device twice would save the first's volume as the one to restore
605
+ return Promise.all(
606
+ once.map(async (target) => ({
607
+ target,
608
+ ...(await actions
609
+ .announce(target, clip, options)
610
+ .catch((error): AnnounceResult => ({
611
+ played: false,
612
+ why: errorText(error),
613
+ restored: [],
614
+ notRestored: [],
615
+ }))),
616
+ })),
617
+ );
618
+ },
571
619
  };
620
+ return actions;
572
621
  }
@@ -4,7 +4,7 @@ Reads one Daikin AC over its local JSON `dsiot` API. `iot_class`: **local_pollin
4
4
 
5
5
  - `client.ts`: `daikinClient({ host, fetch }).read()` returns `DaikinState` (power, mode, the selected mode's setpoint and fan speed, room °C, humidity, outside °C, `anomaly`). `setPower`, `setMode`, `setTarget`, `setFan` build write requests; `client.write(request)` sends one, through a write lane: 1 s apart and at most 20 a minute (guesses), a waiting write merging the next one field by field (`mergeWrites`). Every request times out at 10 s.
6
6
  - `index.ts`: `daikin({ host, fetch, clock })` reads now, then every **120 s** (Home Assistant's `update_interval`). A failed poll is `disconnected` and keeps the last reading with `asOf`; a 404 on `/dsiot/multireq` is a failed read too, saying "not a dsiot Daikin": the address may now be another device's, so discovery looks for the unit. There is no key, so nothing is terminal. Writes go through `daikin().client.write(…)`: one client for the integration's life, at the unit's current address.
7
- - `home status` reads `DAIKIN_HOST`, else the first unit that answers the UDP broadcast. There is no CLI command that writes.
7
+ - `home status` reads `DAIKIN_HOST`, or a home config's address, with discovery by `mac` if the unit moves. There is no CLI command that writes.
8
8
 
9
9
  ## The protocol
10
10
 
@@ -1,6 +1,6 @@
1
1
  # Spotify (Soloist)
2
2
 
3
- What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**. Tested against a fake socket only: no real Soloist has answered this connector yet.
3
+ What a [Spotify Soloist](https://developer.spotify.com/documentation/soloist) speaker is playing (start with its [basic integration](https://developer.spotify.com/documentation/soloist/howtos/basic-integration)), and control over it, over Soloist's local WebSocket. `iot_class`: **local_push**. Reads and most writes seen live on a real Soloist (0.1.1, 2026-11-09); what 0.1.2 changed since is tested against a fake socket only.
4
4
 
5
5
  Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x86_64, Apple Silicon, Windows x86_64). The machine running it is a speaker in every Spotify app on the network; this connector is how a program on the same machine sees and steers it. It doesn't search Spotify or read a library: Soloist plays URIs and reports its own session. Search belongs to the consumer (KO, 2026-11-08; `docs/ideas.md`).
6
6
 
@@ -14,15 +14,17 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
14
14
 
15
15
  **State.** Each frame passes a zod schema, every field optional, and folds into `SpotifyState`: `loggedIn`, `deviceName`, `active`, `status`, `track` (`uri`, `type`, `name`, `artists`, `album`, cover, `durationMs`), `context`, `position` (Soloist's anchor `ms`, `atMs`, `speed`; `positionMs(state, now)` moves it on, not clamped), `volume`, `shuffle`, `repeat`, `upNext` (from `queue_changed`, plus one `get_queue` with `limit: 10` per socket; an unanswered one is quiet, as an empty queue may send nothing). An empty item `uri` is nothing loaded: no `track`. Unknown frame types are skipped. The library adds `aside` while its own `duck`, `interrupt` or stall repair runs, and `stalled`. Odd readings pass through into `anomaly`: an unknown status or repeat, a volume outside 0–100, a negative speed, a position past the track's end, a frame the schema rejects (which leaves the state as it was).
16
16
 
17
- **Writes**, on the integration: `play(linkOrUri?)`, `pause`, `resume`, `next`, `prev`, `seek(ms)`, `setVolume(0–100)`, `queue(track)`, `setShuffle(on)`, `setRepeat("off" | "context" | "track")`. A link is an `open.spotify.com` URL or a `spotify:` URI; `spotify.link` short links are refused.
17
+ **Writes**, on the integration: `activate()`, `play(linkOrUri?)`, `pause`, `resume`, `next`, `prev`, `seek(ms)`, `setVolume(0–100)`, `queue(track)`, `setShuffle(on)`, `setRepeat("off" | "context" | "track")`. A link is an `open.spotify.com` URL or a `spotify:` URI; `spotify.link` short links are refused.
18
18
 
19
19
  - Each resolves `{ state, confirmed }`: after Soloist accepts (`command_result`), it waits up to 2 s for the event that shows _this caller's_ change (the volume sent, `paused` or `playing`, a new track URI, the context or track played, a position near the seek, the shuffle or repeat set, the track in `upNext`), then gives the state. No such event: `confirmed: false`, the state as it stands. A write merged into a later one confirms only if its own change shows; a write of what is already so confirms at once; a drop ends the wait. `next` also confirms on idle (end of queue) or a restart, `prev` on a restart from 5 s or more in.
20
- - Errors are `ConnectorError` with `code`: `offline`, `waiting`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
20
+ - A volume reported within 1 of the request confirms, since Soloist rounds it (phone steps are sixteenths).
21
+ - **While inactive** (`state.active: false`), Soloist accepts `set_volume` and drops it, three times live. `setVolume` is refused unsent with code `inactive`; `duck` runs `fn` over the music with `music: "untouched"`. `activate()` fixes it, but makes a new audio stream, so the volume can jump to the level last saved (62 to 40, live). `play` while inactive goes active first (a `play` alone didn't take live; going active first is untested). `resume` doesn't: it worked inactive, and `interrupt` relies on that.
22
+ - Errors are `ConnectorError` with `code`: `offline`, `waiting`, `inactive`, `refused` (Soloist's `error`), `timeout` (3 s), `invalid` (bad input, nothing sent).
21
23
  - One write lane, 100 ms apart (a guess). `volume`, `seek`, `shuffle`, `repeat` merge to the last; `play`, `pause`, `resume` share a slot, so the last wins; each `next`, `prev`, `queue` is its own. Queries go through the lane too, at most 1 s each, since Soloist's `error` names no command.
22
- - `setRepeat` is two commands in the docs' order (clear the other mode, then set this one); if the second is refused, repeat may be left off.
24
+ - `setRepeat` is two commands in the docs' order (clear the other mode, then set this one), 600 ms apart: back to back, `off` from `track` stayed on `track` live, and 600 ms apart worked. If the second is refused, repeat may be left off.
23
25
  - A failed write the library made itself stays in `detail` until a write works or the socket reconnects.
24
26
 
25
- **`maxVolume`** holds requests (rounded to a whole percent). Within 10 s of idle → playing, a reported volume above it is lowered once and `anomaly` says so until the speaker goes idle: [#23](https://github.com/spotify/soloist/issues/23), the first play on PipeWire starts at 100%. `--initial-volume` is ignored there. Not on resuming from pause, not on connecting to a song already playing, and a person turning it up again keeps it.
27
+ **`maxVolume`** holds requests (rounded to a whole percent). For 10 s after a start signal, a reported volume above it is lowered: a start from idle (to playing or buffering), the first such status after a sign-in, `playing` after a start through buffering, or the speaker going active. Event order differs between runs, so no one pattern is relied on. It waits until the speaker is active, retries until Soloist reports the lower volume, and only then says so in `anomaly`, until the speaker goes idle; if it never shows or Soloist refuses it, `detail` says so. A reported volume within 1 of the cap is left alone. Not on resuming from pause while active, not on connecting to a song already playing, and a person turning it up after the lowering keeps it (before Soloist has reported the lowering, a person's raise looks like the start's own jump and is lowered too). `--initial-volume` is ignored on PipeWire ([#23](https://github.com/spotify/soloist/issues/23)).
26
28
 
27
29
  **`duck(fn, { to })` and `interrupt(fn)`** turn the music down, or pause it, around `fn`, then put it back. Both resolve `{ value, music, why? }`, `music` one of:
28
30
 
@@ -33,7 +35,7 @@ Soloist is Spotify's own headless Spotify Connect client (Linux arm64, armv7, x8
33
35
 
34
36
  The restore runs even if `fn` throws: the error is `fn`'s, and a failed restore goes to `detail`. `duck` restores the earlier volume even above `maxVolume`. One aside at a time: never call one inside another's `fn` (it waits forever), and put two announcements in one `fn`. With Cast: `speaker.duck(() => cast.announce("Kitchen", clip), { to: 20 })`. Cast's own `announce` restores what a Cast device was playing; these are for Soloist's output.
35
37
 
36
- **Reading it.** `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do.
38
+ **Reading it.** `speakerState` takes the Part; `spotifyText` and `positionMs` take its `state`. `speakerState(part)`: `offline`, `connecting`, `waiting`, then Soloist's status (`idle`, `playing`, `paused`, `buffering`, or its own string); signed in with no status yet is `idle`. `spotifyChanges(before, after)`, for `onChange`, gives events in this order: `offline`, `online`, `waiting`, `picked`, `started` (from idle), `resumed` (from paused), `paused`, `stopped`, `track`, `stalled`, `recovered`. Buffering counts as playing. Nothing on the first report, so a restart announces nothing; no volume or queue events. Playback events during an aside carry `own: true`; connection, sign-in and stall events never do. `track` waits for the track's name, which Soloist can send a few seconds after the track (seen live); one that never gets a name never fires. One press on a phone can fire track, resumed, stopped, started and track over 3 s, sometimes with a 0.8 s `waiting`, and the order varies: anything announcing these should wait a few seconds. `state.volume` isn't the level that will play until `active` (it reads `--initial-volume`).
37
39
 
38
40
  **Stalls** ([#5](https://github.com/spotify/soloist/issues/5): playback stops mid-track, often the first after idle, while Soloist still says `playing`). While playing, one `get_state` every 20 s (`stallCheckMs`, code only; 0 turns it off).
39
41
 
@@ -52,18 +54,31 @@ JSON text frames on a plain WebSocket: no path, subprotocol, auth, TLS or origin
52
54
  - On connect: `auth_state`, then `playback_state` only if signed in. A later sign-in is promised only `auth_state`, so the connector asks `get_state`.
53
55
  - If `--ws` can't bind, Soloist runs without it and only logs a warning: `disconnected` while the daemon looks healthy. `--ws ADDR:0` writes the port to the data dir; this connector takes a fixed `url`.
54
56
 
55
- Not built: `activate`/`deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows as a `track`.
57
+ Not built: `deactivate`, `available_actions`. An ad (`entity_type: "ad"`) shows as a `track`.
56
58
 
57
59
  ## Unverified
58
60
 
59
- Everything through this connector; only `soloist ctl` has talked to a real Soloist. In particular:
61
+ Writes through this connector, and what a live read hasn't shown. In particular:
60
62
 
61
63
  - **Stalls:** whether the reported position freezes in one (if not, only an overrun is caught), whether `get_state` re-anchors the position, and whether a seek or a pause and play restarts it.
62
64
  - **Writes:** that each confirming event arrives within 2 s; which events a `play` of an album or playlist sends; `play` of a new URI with no phone around; seek on playback a phone started.
65
+ - **The cap in 0.1.2:** on a real start, and whether a real burst is audible before it lands.
66
+ - **`activate`:** whether `play(uri)` with no phone around works after it.
63
67
  - **The rest:** `get_queue` answered by `queue_changed`; `play` with nothing loaded; whole-number volumes; the order of `auth_state` and `playback_state`; the 100% first play and its lowering; Family-plan members picking the speaker; whether the session survives a Soloist restart; a USB speaker's standby dropping it mid-play.
64
68
 
65
69
  ## Live findings
66
70
 
71
+ **2026-11-09, Raspberry Pi 5, Bun 1.4.4, Soloist 1.3.10.51, PipeWire and WirePlumber 0.5.8, on 0.1.1** (chota-bot's session; writes with KO's OK).
72
+
73
+ - Confirmed: pause, resume, next, prev, seek, shuffle, repeat to context and track, queue, all under 900 ms. `interrupt` restored the music.
74
+ - `set_volume` while inactive: `command_result`, no `volume_changed`, every time. After `activate`, at once. The docs say `command_result` only means accepted.
75
+ - **Why the first volume is what it is:** WirePlumber saves each stream's level by role and restores it, and Soloist adopts it (100 and 56 in two runs, each the last level). Config can't force a fixed start: with `restore-props` off, the default volume is skipped too (`state-stream.lua`). So the cap is the way.
76
+ - A Music-role stream played at a set level (`pw-play --volume 0.38`) leaves that level saved for Spotify's next stream. `pw-play -P '{ state.restore-props = false }'` avoids it.
77
+ - A session survives a Soloist restart: same track, paused, signed in. `deactivate` while already inactive changed nothing visible.
78
+ - Setup that works: speaker knob and sink at 100, Spotify's level only through Soloist, other sounds at their own unsaved levels.
79
+
80
+ **2026-11-09, Raspberry Pi 5, Bun 1.4.4, through this connector** (chota-bot's session, reads only). `home spotify` took 140 ms. In code: connected at 11 ms, second report at 55 ms, 51 MB RSS, a clean exit after abort. Track, volume and `paused` read correctly, with no anomaly. `spotifyChanges` gave nothing on the first report.
81
+
67
82
  **2026-11-08, Raspberry Pi 5, Pi OS Lite Trixie arm64, via `soloist ctl`** (chota-bot's session). `soloist 1.3.10.51 build 1793988108 (20261106)` as a systemd user unit with `-w 127.0.0.1:9091`. It logs the days left on the build (88), the WebSocket listening, then "waiting for login". Builds expire 90 days after they're made (exit code 10); chota-bot's units refresh weekly.
68
83
 
69
84
  - Not logged in until a phone picked the speaker: the normal waiting state. After: logged in, active device.
@@ -62,7 +62,7 @@ const PLAYING = new Set(["playing", "buffering"]);
62
62
  /**
63
63
  * The events between two reports, for `onChange`'s `before` and `after`: `offline` and `online`; `waiting` (signed out) and
64
64
  * `picked` (someone picked the speaker); `started` (from idle), `resumed` (from paused), `paused`, `stopped` (to idle), then
65
- * `track` for a new track, so "started" and the track come as a pair, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
65
+ * `track` for a new track, once it has a name, then `stalled` and `recovered` (Soloist issue #5). Buffering counts as playing. No `before`, or one
66
66
  * with no state (createHome's placeholder before the first report, as at a restart): nothing, since nothing was seen to change. No volume or queue events: read the state.
67
67
  */
68
68
  export function spotifyChanges(
@@ -96,7 +96,9 @@ export function spotifyChanges(
96
96
  if (to === "paused" && from !== "paused") add({ type: "paused" });
97
97
  if (to === "idle" && from !== undefined && from !== "idle")
98
98
  add({ type: "stopped" });
99
- if (a?.track && a.track.uri !== b?.track?.uri)
99
+ // Soloist's first report of a new track can have no name yet (seen live): the event waits for it.
100
+ const named = (p?: SpotifyState) => !!p?.track?.name;
101
+ if (a?.track && named(a) && (a.track.uri !== b?.track?.uri || !named(b)))
100
102
  add({ type: "track", track: a.track });
101
103
  if (a?.stalled && !b?.stalled)
102
104
  events.push({ type: "stalled", stall: a.stalled });
@@ -185,7 +185,7 @@ export type SpotifyState = {
185
185
  /** The playlist, album or show playing from. */
186
186
  context?: { uri: string; type?: string; name?: string };
187
187
  position?: PositionAnchor;
188
- /** 0–100, as Soloist reports it. */
188
+ /** 0–100, as Soloist reports it, rounded. Until `active` it isn't the level that will play (it reads `--initial-volume`, seen live). */
189
189
  volume?: number;
190
190
  shuffle?: boolean;
191
191
  /** off, context or track. */