@basmilius/apple-devices 0.9.18 → 0.10.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.
Files changed (3) hide show
  1. package/dist/index.d.mts +1200 -16
  2. package/dist/index.mjs +1805 -107
  3. package/package.json +5 -5
package/dist/index.mjs CHANGED
@@ -1,89 +1,138 @@
1
1
  import * as AirPlay from "@basmilius/apple-airplay";
2
- import { DataStreamMessage, Proto, Protocol } from "@basmilius/apple-airplay";
2
+ import { AirPlayFeature, DataStreamMessage, Proto, Protocol } from "@basmilius/apple-airplay";
3
3
  import { EventEmitter } from "node:events";
4
- import { waitFor } from "@basmilius/apple-common";
5
- import { Protocol as Protocol$1, convertAttentionState } from "@basmilius/apple-companion-link";
4
+ import { CommandError, CredentialsError, waitFor } from "@basmilius/apple-common";
5
+ import { MediaControlFlag, Protocol as Protocol$1, convertAttentionState } from "@basmilius/apple-companion-link";
6
+ import { NSKeyedArchiver, Plist } from "@basmilius/apple-encoding";
6
7
 
7
8
  //#region src/airplay/player.ts
9
+ /** Offset in seconds between the Cocoa epoch (2001-01-01) and the Unix epoch (1970-01-01). */
8
10
  const COCOA_EPOCH_OFFSET = 978307200;
11
+ /** Default player identifier used by the Apple TV when no specific player is active. */
9
12
  const DEFAULT_PLAYER_ID = "MediaRemote-DefaultPlayer";
13
+ /**
14
+ * Extrapolates the current elapsed time based on a snapshot timestamp and playback rate.
15
+ * Compensates for the time passed since the timestamp was recorded, scaled by the playback rate.
16
+ *
17
+ * @param elapsed - The elapsed time at the moment of the snapshot, in seconds.
18
+ * @param cocoaTimestamp - The Cocoa epoch timestamp when the snapshot was taken.
19
+ * @param rate - The playback rate (e.g. 1.0 for normal speed, 0 for paused).
20
+ * @returns The extrapolated elapsed time in seconds, clamped to a minimum of 0.
21
+ */
10
22
  const extrapolateElapsed = (elapsed, cocoaTimestamp, rate) => {
11
23
  if (!rate) return elapsed;
12
24
  const timestampUnix = cocoaTimestamp + COCOA_EPOCH_OFFSET;
13
25
  const delta = (Date.now() / 1e3 - timestampUnix) * rate;
14
26
  return Math.max(0, elapsed + delta);
15
27
  };
28
+ /**
29
+ * Represents a single media player within an app on the Apple TV.
30
+ * Each app (Client) can have multiple players (e.g. picture-in-picture).
31
+ * Tracks now-playing metadata, playback state, and provides elapsed time extrapolation
32
+ * based on Cocoa timestamps and playback rate.
33
+ */
16
34
  var Player = class {
35
+ /** Unique identifier for this player (e.g. a player path). */
17
36
  get identifier() {
18
37
  return this.#identifier;
19
38
  }
39
+ /** Human-readable display name for this player. */
20
40
  get displayName() {
21
41
  return this.#displayName;
22
42
  }
43
+ /** Whether this is the default fallback player (MediaRemote-DefaultPlayer). */
23
44
  get isDefaultPlayer() {
24
45
  return this.#identifier === DEFAULT_PLAYER_ID;
25
46
  }
47
+ /** Raw now-playing info from the Apple TV, or null if unavailable. */
26
48
  get nowPlayingInfo() {
27
49
  return this.#nowPlayingInfo;
28
50
  }
51
+ /** Current playback queue, or null if unavailable. */
29
52
  get playbackQueue() {
30
53
  return this.#playbackQueue;
31
54
  }
55
+ /**
56
+ * Effective playback state. Corrects for the edge case where the Apple TV
57
+ * reports Playing but the playback rate is 0 (effectively paused).
58
+ */
32
59
  get playbackState() {
33
60
  if (this.#playbackState === Proto.PlaybackState_Enum.Playing && this.playbackRate === 0) return Proto.PlaybackState_Enum.Paused;
34
61
  return this.#playbackState;
35
62
  }
63
+ /** The raw playback state as reported by the Apple TV, without corrections. */
36
64
  get rawPlaybackState() {
37
65
  return this.#playbackState;
38
66
  }
67
+ /** Timestamp of the last playback state update, used to discard stale updates. */
39
68
  get playbackStateTimestamp() {
40
69
  return this.#playbackStateTimestamp;
41
70
  }
71
+ /** List of commands supported by this player. */
42
72
  get supportedCommands() {
43
73
  return this.#supportedCommands;
44
74
  }
75
+ /** Current track title from NowPlayingInfo or content item metadata. */
45
76
  get title() {
46
77
  return this.#nowPlayingInfo?.title || this.currentItemMetadata?.title || "";
47
78
  }
79
+ /** Current track artist from NowPlayingInfo or content item metadata. */
48
80
  get artist() {
49
81
  return this.#nowPlayingInfo?.artist || this.currentItemMetadata?.trackArtistName || "";
50
82
  }
83
+ /** Current track album from NowPlayingInfo or content item metadata. */
51
84
  get album() {
52
85
  return this.#nowPlayingInfo?.album || this.currentItemMetadata?.albumName || "";
53
86
  }
87
+ /** Genre of the current content item. */
54
88
  get genre() {
55
89
  return this.currentItemMetadata?.genre || "";
56
90
  }
91
+ /** Series name for TV show content. */
57
92
  get seriesName() {
58
93
  return this.currentItemMetadata?.seriesName || "";
59
94
  }
95
+ /** Season number for TV show content, or 0 if not applicable. */
60
96
  get seasonNumber() {
61
97
  return this.currentItemMetadata?.seasonNumber || 0;
62
98
  }
99
+ /** Episode number for TV show content, or 0 if not applicable. */
63
100
  get episodeNumber() {
64
101
  return this.currentItemMetadata?.episodeNumber || 0;
65
102
  }
103
+ /** Media type of the current content item (music, video, etc.). */
66
104
  get mediaType() {
67
105
  return this.currentItemMetadata?.mediaType ?? Proto.ContentItemMetadata_MediaType.UnknownMediaType;
68
106
  }
107
+ /** Unique content identifier for the current item (e.g. iTunes store ID). */
69
108
  get contentIdentifier() {
70
109
  return this.currentItemMetadata?.contentIdentifier || "";
71
110
  }
111
+ /** Duration of the current track in seconds, from NowPlayingInfo or metadata. */
72
112
  get duration() {
73
113
  return this.#nowPlayingInfo?.duration || this.currentItemMetadata?.duration || 0;
74
114
  }
115
+ /** Current playback rate (1.0 = normal, 0 = paused, 2.0 = double speed). */
75
116
  get playbackRate() {
76
117
  return this.#nowPlayingInfo?.playbackRate ?? this.currentItemMetadata?.playbackRate ?? 0;
77
118
  }
119
+ /** Whether the player is currently playing (based on effective playback state). */
78
120
  get isPlaying() {
79
- return this.#playbackState === Proto.PlaybackState_Enum.Playing;
121
+ return this.playbackState === Proto.PlaybackState_Enum.Playing;
80
122
  }
123
+ /** Current shuffle mode, derived from the ChangeShuffleMode command info. */
81
124
  get shuffleMode() {
82
125
  return this.#supportedCommands.find((c) => c.command === Proto.Command.ChangeShuffleMode)?.shuffleMode ?? Proto.ShuffleMode_Enum.Unknown;
83
126
  }
127
+ /** Current repeat mode, derived from the ChangeRepeatMode command info. */
84
128
  get repeatMode() {
85
129
  return this.#supportedCommands.find((c) => c.command === Proto.Command.ChangeRepeatMode)?.repeatMode ?? Proto.RepeatMode_Enum.Unknown;
86
130
  }
131
+ /**
132
+ * Extrapolated elapsed time in seconds. Uses the most recent timestamp
133
+ * from either NowPlayingInfo or content item metadata, accounting for
134
+ * playback rate to provide a real-time estimate.
135
+ */
87
136
  get elapsedTime() {
88
137
  const npi = this.#nowPlayingInfo;
89
138
  const meta = this.currentItemMetadata;
@@ -97,13 +146,19 @@ var Player = class {
97
146
  if (metaValid) return extrapolateElapsed(meta.elapsedTime, meta.elapsedTimeTimestamp, meta.playbackRate);
98
147
  return npi?.elapsedTime || meta?.elapsedTime || 0;
99
148
  }
149
+ /** The currently playing content item from the playback queue, or null. */
100
150
  get currentItem() {
101
151
  if (!this.#playbackQueue || this.#playbackQueue.contentItems.length === 0) return null;
102
152
  return this.#playbackQueue.contentItems[this.#playbackQueue.location] ?? this.#playbackQueue.contentItems[0] ?? null;
103
153
  }
154
+ /** Metadata of the current content item, or null if no item is playing. */
104
155
  get currentItemMetadata() {
105
156
  return this.currentItem?.metadata ?? null;
106
157
  }
158
+ /**
159
+ * Unique identifier for the current artwork, used for change detection.
160
+ * Returns null if no artwork evidence exists.
161
+ */
107
162
  get artworkId() {
108
163
  const metadata = this.currentItemMetadata;
109
164
  if (!metadata) return null;
@@ -112,6 +167,14 @@ var Player = class {
112
167
  if (metadata.contentIdentifier) return metadata.contentIdentifier;
113
168
  return this.currentItem?.identifier ?? null;
114
169
  }
170
+ /**
171
+ * Resolves the best available artwork URL for the current item.
172
+ * Checks metadata artworkURL, remote artworks, and iTunes template URLs in order.
173
+ *
174
+ * @param width - Desired artwork width in pixels (used for template URLs).
175
+ * @param height - Desired artwork height in pixels (-1 for automatic).
176
+ * @returns The artwork URL, or null if no artwork URL is available.
177
+ */
115
178
  artworkUrl(width = 600, height = -1) {
116
179
  const metadata = this.currentItemMetadata;
117
180
  if (metadata?.artworkURL) return metadata.artworkURL;
@@ -123,6 +186,7 @@ var Player = class {
123
186
  } catch {}
124
187
  return null;
125
188
  }
189
+ /** Raw artwork data (image bytes) for the current item, or null if not embedded. */
126
190
  get currentItemArtwork() {
127
191
  const item = this.currentItem;
128
192
  if (!item) return null;
@@ -130,9 +194,11 @@ var Player = class {
130
194
  if (item.dataArtworks.length > 0 && item.dataArtworks[0].imageData?.byteLength > 0) return item.dataArtworks[0].imageData;
131
195
  return null;
132
196
  }
197
+ /** Convenience getter for the artwork URL at default dimensions (600px). */
133
198
  get currentItemArtworkUrl() {
134
199
  return this.artworkUrl();
135
200
  }
201
+ /** Lyrics for the current content item, or null if unavailable. */
136
202
  get currentItemLyrics() {
137
203
  return this.currentItem?.lyrics ?? null;
138
204
  }
@@ -143,38 +209,84 @@ var Player = class {
143
209
  #playbackState;
144
210
  #playbackStateTimestamp = 0;
145
211
  #supportedCommands = [];
212
+ /**
213
+ * Creates a new Player instance.
214
+ *
215
+ * @param identifier - Unique player identifier.
216
+ * @param displayName - Human-readable display name.
217
+ */
146
218
  constructor(identifier, displayName) {
147
219
  this.#identifier = identifier;
148
220
  this.#displayName = displayName;
149
221
  this.#playbackState = Proto.PlaybackState_Enum.Unknown;
150
222
  }
223
+ /**
224
+ * Finds a command by its command type in the supported commands list.
225
+ *
226
+ * @param command - The command to look up.
227
+ * @returns The command info, or null if not found.
228
+ */
151
229
  findCommand(command) {
152
230
  return this.#supportedCommands.find((c) => c.command === command) ?? null;
153
231
  }
232
+ /**
233
+ * Checks whether a command is supported and enabled.
234
+ *
235
+ * @param command - The command to check.
236
+ * @returns True if the command is in the supported list and enabled.
237
+ */
154
238
  isCommandSupported(command) {
155
239
  const info = this.findCommand(command);
156
240
  return info != null && info.enabled !== false;
157
241
  }
242
+ /**
243
+ * Updates the now-playing info for this player.
244
+ *
245
+ * @param nowPlayingInfo - The new now-playing info from the Apple TV.
246
+ */
158
247
  setNowPlayingInfo(nowPlayingInfo) {
159
248
  this.#nowPlayingInfo = nowPlayingInfo;
160
249
  }
250
+ /**
251
+ * Updates the playback queue for this player.
252
+ *
253
+ * @param playbackQueue - The new playback queue from the Apple TV.
254
+ */
161
255
  setPlaybackQueue(playbackQueue) {
162
256
  this.#playbackQueue = playbackQueue;
163
257
  }
258
+ /**
259
+ * Updates the playback state. Ignores updates with a timestamp older than the current one
260
+ * to prevent stale state from overwriting newer data.
261
+ *
262
+ * @param playbackState - The new playback state.
263
+ * @param playbackStateTimestamp - Timestamp of this state update.
264
+ */
164
265
  setPlaybackState(playbackState, playbackStateTimestamp) {
165
266
  if (playbackStateTimestamp < this.#playbackStateTimestamp) return;
166
267
  this.#playbackState = playbackState;
167
268
  this.#playbackStateTimestamp = playbackStateTimestamp;
168
269
  }
270
+ /**
271
+ * Replaces the list of supported commands for this player.
272
+ *
273
+ * @param supportedCommands - The new list of supported commands.
274
+ */
169
275
  setSupportedCommands(supportedCommands) {
170
276
  this.#supportedCommands = supportedCommands;
171
277
  }
278
+ /**
279
+ * Merges updated content item data into the existing playback queue.
280
+ * Updates metadata, artwork, lyrics, and info fields for the matching item.
281
+ *
282
+ * @param item - The content item with updated fields.
283
+ */
172
284
  updateContentItem(item) {
173
285
  if (!this.#playbackQueue) return;
174
286
  const existing = this.#playbackQueue.contentItems.find((i) => i.identifier === item.identifier);
175
287
  if (!existing) return;
176
288
  if (item.metadata != null && existing.metadata != null) {
177
- for (const [key, value] of Object.entries(item.metadata)) if (value != null) existing.metadata[key] = value;
289
+ for (const [key, value] of Object.entries(item.metadata)) if (value != null && !key.startsWith("$")) existing.metadata[key] = value;
178
290
  } else if (item.metadata != null) existing.metadata = item.metadata;
179
291
  if (item.artworkData != null) existing.artworkData = item.artworkData;
180
292
  if (item.lyrics != null) existing.lyrics = item.lyrics;
@@ -184,31 +296,49 @@ var Player = class {
184
296
 
185
297
  //#endregion
186
298
  //#region src/airplay/client.ts
299
+ /**
300
+ * Represents a now-playing app (client) on the Apple TV.
301
+ * Each client is identified by its bundle identifier and can contain multiple players.
302
+ * Proxies now-playing getters to the active player, merging player-specific and
303
+ * default supported commands.
304
+ */
187
305
  var Client = class {
306
+ /** Bundle identifier of the app (e.g. "com.apple.TVMusic"). */
188
307
  get bundleIdentifier() {
189
308
  return this.#bundleIdentifier;
190
309
  }
310
+ /** Human-readable display name of the app. */
191
311
  get displayName() {
192
312
  return this.#displayName;
193
313
  }
314
+ /** Map of all known players for this client, keyed by player identifier. */
194
315
  get players() {
195
316
  return this.#players;
196
317
  }
318
+ /** The currently active player, or null if none is active. Falls back to the default player. */
197
319
  get activePlayer() {
198
320
  return this.#players.get(this.#activePlayerId ?? "MediaRemote-DefaultPlayer") ?? null;
199
321
  }
322
+ /** Now-playing info from the active player, or null. */
200
323
  get nowPlayingInfo() {
201
324
  return this.activePlayer?.nowPlayingInfo ?? null;
202
325
  }
326
+ /** Playback queue from the active player, or null. */
203
327
  get playbackQueue() {
204
328
  return this.activePlayer?.playbackQueue ?? null;
205
329
  }
330
+ /** Playback state from the active player, or Unknown. */
206
331
  get playbackState() {
207
332
  return this.activePlayer?.playbackState ?? Proto.PlaybackState_Enum.Unknown;
208
333
  }
334
+ /** Timestamp of the last playback state update from the active player. */
209
335
  get playbackStateTimestamp() {
210
336
  return this.activePlayer?.playbackStateTimestamp ?? -1;
211
337
  }
338
+ /**
339
+ * Merged list of supported commands from the active player and client defaults.
340
+ * Player commands take precedence; default commands are appended if not already present.
341
+ */
212
342
  get supportedCommands() {
213
343
  const playerCommands = this.activePlayer?.supportedCommands ?? [];
214
344
  if (playerCommands.length === 0) return this.#defaultSupportedCommands;
@@ -218,69 +348,97 @@ var Client = class {
218
348
  for (const cmd of this.#defaultSupportedCommands) if (!playerCommandSet.has(cmd.command)) merged.push(cmd);
219
349
  return merged;
220
350
  }
351
+ /** Current track title from the active player. */
221
352
  get title() {
222
353
  return this.activePlayer?.title ?? "";
223
354
  }
355
+ /** Current track artist from the active player. */
224
356
  get artist() {
225
357
  return this.activePlayer?.artist ?? "";
226
358
  }
359
+ /** Current track album from the active player. */
227
360
  get album() {
228
361
  return this.activePlayer?.album ?? "";
229
362
  }
363
+ /** Genre of the current content from the active player. */
230
364
  get genre() {
231
365
  return this.activePlayer?.genre ?? "";
232
366
  }
367
+ /** Series name for TV show content from the active player. */
233
368
  get seriesName() {
234
369
  return this.activePlayer?.seriesName ?? "";
235
370
  }
371
+ /** Season number for TV show content from the active player. */
236
372
  get seasonNumber() {
237
373
  return this.activePlayer?.seasonNumber ?? 0;
238
374
  }
375
+ /** Episode number for TV show content from the active player. */
239
376
  get episodeNumber() {
240
377
  return this.activePlayer?.episodeNumber ?? 0;
241
378
  }
379
+ /** Media type of the current content from the active player. */
242
380
  get mediaType() {
243
381
  return this.activePlayer?.mediaType ?? Proto.ContentItemMetadata_MediaType.UnknownMediaType;
244
382
  }
383
+ /** Content identifier of the current item from the active player. */
245
384
  get contentIdentifier() {
246
385
  return this.activePlayer?.contentIdentifier ?? "";
247
386
  }
387
+ /** Duration of the current track in seconds from the active player. */
248
388
  get duration() {
249
389
  return this.activePlayer?.duration ?? 0;
250
390
  }
391
+ /** Playback rate from the active player (1.0 = normal, 0 = paused). */
251
392
  get playbackRate() {
252
393
  return this.activePlayer?.playbackRate ?? 0;
253
394
  }
395
+ /** Whether the active player is currently playing. */
254
396
  get isPlaying() {
255
397
  return this.activePlayer?.isPlaying ?? false;
256
398
  }
399
+ /** Current shuffle mode from the active player. */
257
400
  get shuffleMode() {
258
401
  return this.activePlayer?.shuffleMode ?? Proto.ShuffleMode_Enum.Unknown;
259
402
  }
403
+ /** Current repeat mode from the active player. */
260
404
  get repeatMode() {
261
405
  return this.activePlayer?.repeatMode ?? Proto.RepeatMode_Enum.Unknown;
262
406
  }
407
+ /** Extrapolated elapsed time in seconds from the active player. */
263
408
  get elapsedTime() {
264
409
  return this.activePlayer?.elapsedTime ?? 0;
265
410
  }
411
+ /** Artwork identifier for change detection from the active player. */
266
412
  get artworkId() {
267
413
  return this.activePlayer?.artworkId ?? null;
268
414
  }
415
+ /**
416
+ * Resolves the best available artwork URL from the active player.
417
+ *
418
+ * @param width - Desired artwork width in pixels.
419
+ * @param height - Desired artwork height in pixels (-1 for automatic).
420
+ * @returns The artwork URL, or null if unavailable.
421
+ */
269
422
  artworkUrl(width = 600, height = -1) {
270
423
  return this.activePlayer?.artworkUrl(width, height) ?? null;
271
424
  }
425
+ /** Current content item from the active player's playback queue. */
272
426
  get currentItem() {
273
427
  return this.activePlayer?.currentItem ?? null;
274
428
  }
429
+ /** Metadata of the current content item from the active player. */
275
430
  get currentItemMetadata() {
276
431
  return this.activePlayer?.currentItemMetadata ?? null;
277
432
  }
433
+ /** Raw artwork data (image bytes) from the active player. */
278
434
  get currentItemArtwork() {
279
435
  return this.activePlayer?.currentItemArtwork ?? null;
280
436
  }
437
+ /** Artwork URL at default dimensions from the active player. */
281
438
  get currentItemArtworkUrl() {
282
439
  return this.activePlayer?.currentItemArtworkUrl ?? null;
283
440
  }
441
+ /** Lyrics for the current content item from the active player. */
284
442
  get currentItemLyrics() {
285
443
  return this.activePlayer?.currentItemLyrics ?? null;
286
444
  }
@@ -289,10 +447,23 @@ var Client = class {
289
447
  #players = /* @__PURE__ */ new Map();
290
448
  #activePlayerId = null;
291
449
  #defaultSupportedCommands = [];
450
+ /**
451
+ * Creates a new Client instance.
452
+ *
453
+ * @param bundleIdentifier - Bundle identifier of the app.
454
+ * @param displayName - Human-readable app name.
455
+ */
292
456
  constructor(bundleIdentifier, displayName) {
293
457
  this.#bundleIdentifier = bundleIdentifier;
294
458
  this.#displayName = displayName;
295
459
  }
460
+ /**
461
+ * Gets an existing player or creates a new one if it does not exist.
462
+ *
463
+ * @param identifier - Unique player identifier.
464
+ * @param displayName - Human-readable player name (defaults to identifier).
465
+ * @returns The existing or newly created Player.
466
+ */
296
467
  getOrCreatePlayer(identifier, displayName) {
297
468
  let player = this.#players.get(identifier);
298
469
  if (!player) {
@@ -301,25 +472,61 @@ var Client = class {
301
472
  }
302
473
  return player;
303
474
  }
475
+ /**
476
+ * Sets the active player by identifier.
477
+ *
478
+ * @param identifier - Identifier of the player to activate.
479
+ */
304
480
  setActivePlayer(identifier) {
305
481
  this.#activePlayerId = identifier;
306
482
  }
483
+ /**
484
+ * Removes a player from this client. If the removed player was active,
485
+ * the active player is reset to null (falling back to the default player).
486
+ *
487
+ * @param identifier - Identifier of the player to remove.
488
+ */
307
489
  removePlayer(identifier) {
308
490
  this.#players.delete(identifier);
309
491
  if (this.#activePlayerId === identifier) this.#activePlayerId = null;
310
492
  }
493
+ /**
494
+ * Sets the default supported commands for this client. These are used as
495
+ * fallback when the active player has no commands of its own.
496
+ *
497
+ * @param supportedCommands - The default command list.
498
+ */
311
499
  setDefaultSupportedCommands(supportedCommands) {
312
500
  this.#defaultSupportedCommands = supportedCommands;
313
501
  }
502
+ /**
503
+ * Finds a command by type, checking the active player first,
504
+ * then falling back to the default supported commands.
505
+ *
506
+ * @param command - The command to look up.
507
+ * @returns The command info, or null if not found.
508
+ */
314
509
  findCommand(command) {
315
510
  const playerCmd = this.activePlayer?.findCommand(command) ?? null;
316
511
  if (playerCmd) return playerCmd;
317
512
  return this.#defaultSupportedCommands.find((c) => c.command === command) ?? null;
318
513
  }
514
+ /**
515
+ * Checks whether a command is supported and enabled, checking both
516
+ * the active player and default commands.
517
+ *
518
+ * @param command - The command to check.
519
+ * @returns True if the command is supported and enabled.
520
+ */
319
521
  isCommandSupported(command) {
320
522
  const info = this.findCommand(command);
321
523
  return info != null && info.enabled !== false;
322
524
  }
525
+ /**
526
+ * Updates the display name for this client.
527
+ *
528
+ * @param displayName - The new display name.
529
+ */
323
530
  updateDisplayName(displayName) {
324
531
  this.#displayName = displayName;
325
532
  }
@@ -327,16 +534,32 @@ var Client = class {
327
534
 
328
535
  //#endregion
329
536
  //#region src/airplay/const.ts
537
+ /** Interval in milliseconds between periodic feedback requests to keep the AirPlay session alive. */
330
538
  const FEEDBACK_INTERVAL = 2e3;
539
+ /** Symbol used to access the underlying AirPlay Protocol instance from an AirPlayDevice. */
331
540
  const PROTOCOL = Symbol("com.basmilius.airplay:protocol");
541
+ /** Symbol used to subscribe AirPlayState to DataStream events. */
332
542
  const STATE_SUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:subscribe");
543
+ /** Symbol used to unsubscribe AirPlayState from DataStream events. */
333
544
  const STATE_UNSUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:unsubscribe");
334
545
 
335
546
  //#endregion
336
547
  //#region src/airplay/remote.ts
337
- var SendCommandError = class extends Error {
548
+ /**
549
+ * Error thrown when a SendCommand request fails on the Apple TV side.
550
+ * Contains the specific send error and handler return status for diagnostics.
551
+ */
552
+ var SendCommandError = class extends CommandError {
553
+ /** The send error reported by the Apple TV. */
338
554
  sendError;
555
+ /** The handler return status reported by the Apple TV. */
339
556
  handlerReturnStatus;
557
+ /**
558
+ * Creates a new SendCommandError.
559
+ *
560
+ * @param sendError - The send error code from the Apple TV.
561
+ * @param handlerReturnStatus - The handler return status from the Apple TV.
562
+ */
340
563
  constructor(sendError, handlerReturnStatus) {
341
564
  super(`SendCommand failed: sendError=${Proto.SendError_Enum[sendError]}, handlerReturnStatus=${Proto.HandlerReturnStatus_Enum[handlerReturnStatus]}`);
342
565
  this.name = "SendCommandError";
@@ -344,189 +567,398 @@ var SendCommandError = class extends Error {
344
567
  this.handlerReturnStatus = handlerReturnStatus;
345
568
  }
346
569
  };
570
+ /**
571
+ * Remote control for an AirPlay device.
572
+ * Provides HID-based navigation and media keys (USB usage pages: Generic Desktop 0x01
573
+ * and Consumer 0x0c), SendCommand-based media controls, keyboard/text input,
574
+ * and touch/gesture simulation.
575
+ */
347
576
  var remote_default = class {
577
+ /** @returns The DataStream for sending HID events and commands. */
348
578
  get #dataStream() {
349
579
  return this.#protocol.dataStream;
350
580
  }
581
+ /** @returns The underlying AirPlay Protocol instance. */
351
582
  get #protocol() {
352
583
  return this.#device[PROTOCOL];
353
584
  }
354
585
  #device;
586
+ /**
587
+ * Creates a new Remote controller.
588
+ *
589
+ * @param device - The AirPlay device to control.
590
+ */
355
591
  constructor(device) {
356
592
  this.#device = device;
357
593
  }
594
+ /** Sends an Up navigation key press (Generic Desktop, usage 0x8C). */
358
595
  async up() {
359
596
  await this.pressAndRelease(1, 140);
360
597
  }
598
+ /** Sends a Down navigation key press (Generic Desktop, usage 0x8D). */
361
599
  async down() {
362
600
  await this.pressAndRelease(1, 141);
363
601
  }
602
+ /** Sends a Left navigation key press (Generic Desktop, usage 0x8B). */
364
603
  async left() {
365
604
  await this.pressAndRelease(1, 139);
366
605
  }
606
+ /** Sends a Right navigation key press (Generic Desktop, usage 0x8A). */
367
607
  async right() {
368
608
  await this.pressAndRelease(1, 138);
369
609
  }
610
+ /** Sends a Menu key press (Generic Desktop, usage 0x86). */
370
611
  async menu() {
371
612
  await this.pressAndRelease(1, 134);
372
613
  }
614
+ /** Sends a Select/Enter key press (Generic Desktop, usage 0x89). */
373
615
  async select() {
374
616
  await this.pressAndRelease(1, 137);
375
617
  }
618
+ /** Sends a Home button press (Consumer, usage 0x40). */
376
619
  async home() {
377
620
  await this.pressAndRelease(12, 64);
378
621
  }
622
+ /** Sends a Suspend/Sleep key press to put the device to sleep (Generic Desktop, usage 0x82). */
379
623
  async suspend() {
380
624
  await this.pressAndRelease(1, 130);
381
625
  }
626
+ /** Sends a Wake key press to wake the device (Generic Desktop, usage 0x83). */
382
627
  async wake() {
383
628
  await this.pressAndRelease(1, 131);
384
629
  }
630
+ /** Sends a Play key press (Consumer, usage 0xB0). */
385
631
  async play() {
386
632
  await this.pressAndRelease(12, 176);
387
633
  }
634
+ /** Sends a Pause key press (Consumer, usage 0xB1). */
388
635
  async pause() {
389
636
  await this.pressAndRelease(12, 177);
390
637
  }
638
+ /** Toggles play/pause based on the current playback state. */
391
639
  async playPause() {
392
640
  if (this.#device.state.nowPlayingClient?.isPlaying) await this.pause();
393
641
  else await this.play();
394
642
  }
643
+ /** Sends a Stop key press (Consumer, usage 0xB7). */
644
+ async stop() {
645
+ await this.pressAndRelease(12, 183);
646
+ }
647
+ /** Sends a Next Track key press (Consumer, usage 0xB5). */
395
648
  async next() {
396
649
  await this.pressAndRelease(12, 181);
397
650
  }
651
+ /** Sends a Previous Track key press (Consumer, usage 0xB6). */
398
652
  async previous() {
399
653
  await this.pressAndRelease(12, 182);
400
654
  }
655
+ /** Sends a Volume Up key press (Consumer, usage 0xE9). */
401
656
  async volumeUp() {
402
657
  await this.pressAndRelease(12, 233);
403
658
  }
659
+ /** Sends a Volume Down key press (Consumer, usage 0xEA). */
404
660
  async volumeDown() {
405
661
  await this.pressAndRelease(12, 234);
406
662
  }
663
+ /** Sends a Mute key press (Consumer, usage 0xE2). */
407
664
  async mute() {
408
665
  await this.pressAndRelease(12, 226);
409
666
  }
667
+ /** Sends a Top Menu key press (Consumer, usage 0x60). */
668
+ async topMenu() {
669
+ await this.pressAndRelease(12, 96);
670
+ }
671
+ /** Sends a Channel Up key press (Consumer, usage 0x9C). */
672
+ async channelUp() {
673
+ await this.pressAndRelease(12, 156);
674
+ }
675
+ /** Sends a Channel Down key press (Consumer, usage 0x9D). */
676
+ async channelDown() {
677
+ await this.pressAndRelease(12, 157);
678
+ }
679
+ /** Sends a Play command via the MRP SendCommand protocol. */
410
680
  async commandPlay() {
411
681
  await this.#sendCommand(Proto.Command.Play);
412
682
  }
683
+ /** Sends a Pause command via the MRP SendCommand protocol. */
413
684
  async commandPause() {
414
685
  await this.#sendCommand(Proto.Command.Pause);
415
686
  }
687
+ /** Sends a TogglePlayPause command via the MRP SendCommand protocol. */
416
688
  async commandTogglePlayPause() {
417
689
  await this.#sendCommand(Proto.Command.TogglePlayPause);
418
690
  }
691
+ /** Sends a Stop command via the MRP SendCommand protocol. */
419
692
  async commandStop() {
420
693
  await this.#sendCommand(Proto.Command.Stop);
421
694
  }
695
+ /** Sends a NextTrack command via the MRP SendCommand protocol. */
422
696
  async commandNextTrack() {
423
697
  await this.#sendCommand(Proto.Command.NextTrack);
424
698
  }
699
+ /** Sends a PreviousTrack command via the MRP SendCommand protocol. */
425
700
  async commandPreviousTrack() {
426
701
  await this.#sendCommand(Proto.Command.PreviousTrack);
427
702
  }
703
+ /**
704
+ * Sends a SkipForward command with a configurable interval.
705
+ *
706
+ * @param interval - Seconds to skip forward (defaults to 15).
707
+ */
428
708
  async commandSkipForward(interval = 15) {
429
709
  await this.#sendCommandRaw(DataStreamMessage.sendCommandWithSkipInterval(Proto.Command.SkipForward, interval));
430
710
  }
711
+ /**
712
+ * Sends a SkipBackward command with a configurable interval.
713
+ *
714
+ * @param interval - Seconds to skip backward (defaults to 15).
715
+ */
431
716
  async commandSkipBackward(interval = 15) {
432
717
  await this.#sendCommandRaw(DataStreamMessage.sendCommandWithSkipInterval(Proto.Command.SkipBackward, interval));
433
718
  }
719
+ /**
720
+ * Seeks to an absolute playback position.
721
+ *
722
+ * @param position - The target position in seconds.
723
+ */
434
724
  async commandSeekToPosition(position) {
435
725
  await this.#sendCommandRaw(DataStreamMessage.sendCommandWithPlaybackPosition(Proto.Command.SeekToPlaybackPosition, position));
436
726
  }
727
+ /**
728
+ * Sets the shuffle mode.
729
+ *
730
+ * @param mode - The desired shuffle mode.
731
+ */
437
732
  async commandSetShuffleMode(mode) {
438
733
  await this.#sendCommandRaw(DataStreamMessage.sendCommandWithShuffleMode(Proto.Command.ChangeShuffleMode, mode));
439
734
  }
735
+ /**
736
+ * Sets the repeat mode.
737
+ *
738
+ * @param mode - The desired repeat mode.
739
+ */
440
740
  async commandSetRepeatMode(mode) {
441
741
  await this.#sendCommandRaw(DataStreamMessage.sendCommandWithRepeatMode(Proto.Command.ChangeRepeatMode, mode));
442
742
  }
743
+ /**
744
+ * Changes the playback rate (speed).
745
+ *
746
+ * @param rate - The desired playback rate (e.g. 1.0 for normal, 2.0 for double speed).
747
+ */
443
748
  async commandChangePlaybackRate(rate) {
444
749
  await this.#sendCommandRaw(DataStreamMessage.sendCommandWithPlaybackRate(Proto.Command.ChangePlaybackRate, rate));
445
750
  }
751
+ /** Cycles the shuffle mode to the next value. */
446
752
  async commandAdvanceShuffleMode() {
447
753
  await this.#sendCommand(Proto.Command.AdvanceShuffleMode);
448
754
  }
755
+ /** Cycles the repeat mode to the next value. */
449
756
  async commandAdvanceRepeatMode() {
450
757
  await this.#sendCommand(Proto.Command.AdvanceRepeatMode);
451
758
  }
759
+ /** Begins fast-forwarding playback. */
452
760
  async commandBeginFastForward() {
453
761
  await this.#sendCommand(Proto.Command.BeginFastForward);
454
762
  }
763
+ /** Ends fast-forwarding playback. */
455
764
  async commandEndFastForward() {
456
765
  await this.#sendCommand(Proto.Command.EndFastForward);
457
766
  }
767
+ /** Begins rewinding playback. */
458
768
  async commandBeginRewind() {
459
769
  await this.#sendCommand(Proto.Command.BeginRewind);
460
770
  }
771
+ /** Ends rewinding playback. */
461
772
  async commandEndRewind() {
462
773
  await this.#sendCommand(Proto.Command.EndRewind);
463
774
  }
775
+ /** Skips to the next chapter. */
464
776
  async commandNextChapter() {
465
777
  await this.#sendCommand(Proto.Command.NextChapter);
466
778
  }
779
+ /** Skips to the previous chapter. */
467
780
  async commandPreviousChapter() {
468
781
  await this.#sendCommand(Proto.Command.PreviousChapter);
469
782
  }
783
+ /** Marks the current track as liked. */
470
784
  async commandLikeTrack() {
471
785
  await this.#sendCommand(Proto.Command.LikeTrack);
472
786
  }
787
+ /** Marks the current track as disliked. */
473
788
  async commandDislikeTrack() {
474
789
  await this.#sendCommand(Proto.Command.DislikeTrack);
475
790
  }
791
+ /** Bookmarks the current track. */
476
792
  async commandBookmarkTrack() {
477
793
  await this.#sendCommand(Proto.Command.BookmarkTrack);
478
794
  }
795
+ /** Adds the currently playing item to the user's library. */
479
796
  async commandAddNowPlayingItemToLibrary() {
480
797
  await this.#sendCommand(Proto.Command.AddNowPlayingItemToLibrary);
481
798
  }
799
+ /**
800
+ * Sets the text input field to the given text, replacing any existing content.
801
+ *
802
+ * @param text - The text to set.
803
+ */
804
+ async textSet(text) {
805
+ await this.#dataStream.send(DataStreamMessage.textInput(text, Proto.ActionType_Enum.Set));
806
+ }
807
+ /**
808
+ * Appends text to the current text input field content.
809
+ *
810
+ * @param text - The text to append.
811
+ */
812
+ async textAppend(text) {
813
+ await this.#dataStream.send(DataStreamMessage.textInput(text, Proto.ActionType_Enum.Insert));
814
+ }
815
+ /** Clears the text input field. */
816
+ async textClear() {
817
+ await this.#dataStream.send(DataStreamMessage.textInput("", Proto.ActionType_Enum.ClearAction));
818
+ }
819
+ /** Requests the current keyboard session state from the Apple TV. */
820
+ async getKeyboardSession() {
821
+ await this.#dataStream.send(DataStreamMessage.getKeyboardSession());
822
+ }
823
+ /**
824
+ * Simulates a tap at the given coordinates.
825
+ *
826
+ * @param x - Horizontal position in the virtual touch area.
827
+ * @param y - Vertical position in the virtual touch area.
828
+ * @param finger - Finger index for multi-touch (defaults to 1).
829
+ */
482
830
  async tap(x, y, finger = 1) {
483
831
  await this.#sendTouch(x, y, 1, finger);
484
832
  await waitFor(50);
485
833
  await this.#sendTouch(x, y, 4, finger);
486
834
  }
835
+ /**
836
+ * Simulates an upward swipe gesture.
837
+ *
838
+ * @param duration - Swipe duration in milliseconds (defaults to 200).
839
+ */
487
840
  async swipeUp(duration = 200) {
488
841
  await this.#swipe(200, 400, 200, 100, duration);
489
842
  }
843
+ /**
844
+ * Simulates a downward swipe gesture.
845
+ *
846
+ * @param duration - Swipe duration in milliseconds (defaults to 200).
847
+ */
490
848
  async swipeDown(duration = 200) {
491
849
  await this.#swipe(200, 100, 200, 400, duration);
492
850
  }
851
+ /**
852
+ * Simulates a leftward swipe gesture.
853
+ *
854
+ * @param duration - Swipe duration in milliseconds (defaults to 200).
855
+ */
493
856
  async swipeLeft(duration = 200) {
494
857
  await this.#swipe(400, 200, 100, 200, duration);
495
858
  }
859
+ /**
860
+ * Simulates a rightward swipe gesture.
861
+ *
862
+ * @param duration - Swipe duration in milliseconds (defaults to 200).
863
+ */
496
864
  async swipeRight(duration = 200) {
497
865
  await this.#swipe(100, 200, 400, 200, duration);
498
866
  }
867
+ /**
868
+ * Sends a double press of a HID key (two press-and-release cycles with a 150ms gap).
869
+ *
870
+ * @param usePage - USB HID usage page (1 = Generic Desktop, 12 = Consumer).
871
+ * @param usage - USB HID usage code.
872
+ */
499
873
  async doublePress(usePage, usage) {
500
874
  await this.pressAndRelease(usePage, usage);
501
875
  await waitFor(150);
502
876
  await this.pressAndRelease(usePage, usage);
503
877
  }
878
+ /**
879
+ * Sends a long press of a HID key (hold for a configurable duration).
880
+ *
881
+ * @param usePage - USB HID usage page (1 = Generic Desktop, 12 = Consumer).
882
+ * @param usage - USB HID usage code.
883
+ * @param duration - Hold duration in milliseconds (defaults to 1000).
884
+ */
504
885
  async longPress(usePage, usage, duration = 1e3) {
505
886
  await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, true));
506
887
  await waitFor(duration);
507
888
  await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, false));
508
889
  }
890
+ /**
891
+ * Sends a single press-and-release of a HID key with a 25ms hold.
892
+ *
893
+ * @param usePage - USB HID usage page (1 = Generic Desktop, 12 = Consumer).
894
+ * @param usage - USB HID usage code.
895
+ */
509
896
  async pressAndRelease(usePage, usage) {
510
897
  await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, true));
511
898
  await waitFor(25);
512
899
  await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, false));
513
900
  }
901
+ /**
902
+ * Sends a SendCommand request and checks the result.
903
+ *
904
+ * @param command - The command to send.
905
+ * @param options - Optional command options.
906
+ * @returns The command result message, or undefined if no result was returned.
907
+ * @throws SendCommandError when the Apple TV reports a send error.
908
+ */
514
909
  async #sendCommand(command, options) {
515
910
  const response = await this.#dataStream.exchange(DataStreamMessage.sendCommand(command, options));
516
911
  return this.#checkCommandResult(response);
517
912
  }
913
+ /**
914
+ * Sends a pre-built command message and checks the result.
915
+ *
916
+ * @param message - The pre-built DataStream message to send.
917
+ * @returns The command result message, or undefined if no result was returned.
918
+ * @throws SendCommandError when the Apple TV reports a send error.
919
+ */
518
920
  async #sendCommandRaw(message) {
519
921
  const response = await this.#dataStream.exchange(message);
520
922
  return this.#checkCommandResult(response);
521
923
  }
924
+ /**
925
+ * Validates the response from a SendCommand request and throws on error.
926
+ *
927
+ * @param response - The protocol message response.
928
+ * @returns The decoded result, or undefined if the response has no result extension.
929
+ * @throws SendCommandError when the result indicates a send error.
930
+ */
522
931
  #checkCommandResult(response) {
523
- const result = DataStreamMessage.getExtension(response, Proto.sendCommandResultMessage);
932
+ let result;
933
+ try {
934
+ result = DataStreamMessage.getExtension(response, Proto.sendCommandResultMessage);
935
+ } catch {
936
+ return;
937
+ }
938
+ if (!result) return;
524
939
  if (result.sendError !== Proto.SendError_Enum.NoError) throw new SendCommandError(result.sendError, result.handlerReturnStatus);
525
940
  return result;
526
941
  }
942
+ /**
943
+ * Sends a virtual touch event at the given coordinates.
944
+ *
945
+ * @param x - Horizontal position.
946
+ * @param y - Vertical position.
947
+ * @param phase - Touch phase (1 = Began, 2 = Moved, 4 = Ended).
948
+ * @param finger - Finger index for multi-touch.
949
+ */
527
950
  async #sendTouch(x, y, phase, finger) {
528
951
  await this.#dataStream.exchange(DataStreamMessage.sendVirtualTouchEvent(x, y, phase, finger));
529
952
  }
953
+ /**
954
+ * Performs a swipe gesture by interpolating touch events between start and end coordinates.
955
+ *
956
+ * @param startX - Starting horizontal position.
957
+ * @param startY - Starting vertical position.
958
+ * @param endX - Ending horizontal position.
959
+ * @param endY - Ending vertical position.
960
+ * @param duration - Total swipe duration in milliseconds.
961
+ */
530
962
  async #swipe(startX, startY, endX, endY, duration) {
531
963
  const steps = Math.max(4, Math.floor(duration / 50));
532
964
  const deltaX = (endX - startX) / steps;
@@ -544,47 +976,108 @@ var remote_default = class {
544
976
 
545
977
  //#endregion
546
978
  //#region src/airplay/state.ts
979
+ /**
980
+ * Tracks the complete state of an AirPlay device: clients, players, now-playing,
981
+ * volume, keyboard, output devices, and cluster info.
982
+ * Listens to DataStream protocol messages and emits both low-level (1:1 with protocol)
983
+ * and high-level (deduplicated, resolved) events.
984
+ */
547
985
  var state_default = class extends EventEmitter {
986
+ /** @returns The DataStream for event subscription. */
548
987
  get #dataStream() {
549
988
  return this.#protocol.dataStream;
550
989
  }
990
+ /** @returns The underlying AirPlay Protocol instance. */
551
991
  get #protocol() {
552
992
  return this.#device[PROTOCOL];
553
993
  }
994
+ /** All known clients (apps) keyed by bundle identifier. */
554
995
  get clients() {
555
996
  return this.#clients;
556
997
  }
998
+ /** Whether a keyboard/text input session is currently active on the Apple TV. */
999
+ get isKeyboardActive() {
1000
+ return this.#keyboardState === Proto.KeyboardState_Enum.DidBeginEditing || this.#keyboardState === Proto.KeyboardState_Enum.Editing || this.#keyboardState === Proto.KeyboardState_Enum.TextDidChange;
1001
+ }
1002
+ /** Text editing attributes for the active keyboard session, or null. */
1003
+ get keyboardAttributes() {
1004
+ return this.#keyboardAttributes;
1005
+ }
1006
+ /** Current keyboard state enum value. */
1007
+ get keyboardState() {
1008
+ return this.#keyboardState;
1009
+ }
1010
+ /** The currently active now-playing client, or null if nothing is playing. */
557
1011
  get nowPlayingClient() {
558
1012
  return this.#nowPlayingClientBundleIdentifier ? this.#clients[this.#nowPlayingClientBundleIdentifier] ?? null : null;
559
1013
  }
1014
+ /** UID of the primary output device (used for volume control and multi-room). */
560
1015
  get outputDeviceUID() {
561
1016
  return this.#outputDeviceUID;
562
1017
  }
1018
+ /** List of all output device descriptors in the current AirPlay group. */
563
1019
  get outputDevices() {
564
1020
  return this.#outputDevices;
565
1021
  }
1022
+ /** Cluster identifier for multi-room groups, or null. */
1023
+ get clusterID() {
1024
+ return this.#clusterID;
1025
+ }
1026
+ /** Cluster type code (0 if not clustered). */
1027
+ get clusterType() {
1028
+ return this.#clusterType;
1029
+ }
1030
+ /** Whether this device is aware of multi-room clusters. */
1031
+ get isClusterAware() {
1032
+ return this.#isClusterAware;
1033
+ }
1034
+ /** Whether this device is the leader of its multi-room cluster. */
1035
+ get isClusterLeader() {
1036
+ return this.#isClusterLeader;
1037
+ }
1038
+ /** Current volume level (0.0 - 1.0). */
566
1039
  get volume() {
567
1040
  return this.#volume;
568
1041
  }
1042
+ /** Whether volume control is available on this device. */
569
1043
  get volumeAvailable() {
570
1044
  return this.#volumeAvailable;
571
1045
  }
1046
+ /** Volume capabilities (absolute, relative, both, or none). */
572
1047
  get volumeCapabilities() {
573
1048
  return this.#volumeCapabilities;
574
1049
  }
1050
+ /** Whether the device is currently muted. */
1051
+ get volumeMuted() {
1052
+ return this.#volumeMuted;
1053
+ }
575
1054
  #device;
576
1055
  #clients;
1056
+ #keyboardAttributes;
1057
+ #keyboardState;
577
1058
  #nowPlayingClientBundleIdentifier;
578
1059
  #nowPlayingSnapshot;
579
1060
  #outputDeviceUID;
580
1061
  #outputDevices = [];
1062
+ #clusterID;
1063
+ #clusterType;
1064
+ #isClusterAware;
1065
+ #isClusterLeader;
581
1066
  #volume;
582
1067
  #volumeAvailable;
583
1068
  #volumeCapabilities;
1069
+ #volumeMuted;
1070
+ /**
1071
+ * Creates a new AirPlayState tracker.
1072
+ *
1073
+ * @param device - The AirPlay device to track state for.
1074
+ */
584
1075
  constructor(device) {
585
1076
  super();
586
1077
  this.#device = device;
587
1078
  this.clear();
1079
+ this.onConfigureConnection = this.onConfigureConnection.bind(this);
1080
+ this.onKeyboard = this.onKeyboard.bind(this);
588
1081
  this.onDeviceInfo = this.onDeviceInfo.bind(this);
589
1082
  this.onDeviceInfoUpdate = this.onDeviceInfoUpdate.bind(this);
590
1083
  this.onOriginClientProperties = this.onOriginClientProperties.bind(this);
@@ -592,6 +1085,7 @@ var state_default = class extends EventEmitter {
592
1085
  this.onRemoveClient = this.onRemoveClient.bind(this);
593
1086
  this.onRemovePlayer = this.onRemovePlayer.bind(this);
594
1087
  this.onSendCommandResult = this.onSendCommandResult.bind(this);
1088
+ this.onSendLyricsEvent = this.onSendLyricsEvent.bind(this);
595
1089
  this.onSetArtwork = this.onSetArtwork.bind(this);
596
1090
  this.onSetDefaultSupportedCommands = this.onSetDefaultSupportedCommands.bind(this);
597
1091
  this.onSetNowPlayingClient = this.onSetNowPlayingClient.bind(this);
@@ -605,8 +1099,12 @@ var state_default = class extends EventEmitter {
605
1099
  this.onVolumeControlAvailability = this.onVolumeControlAvailability.bind(this);
606
1100
  this.onVolumeControlCapabilitiesDidChange = this.onVolumeControlCapabilitiesDidChange.bind(this);
607
1101
  this.onVolumeDidChange = this.onVolumeDidChange.bind(this);
1102
+ this.onVolumeMutedDidChange = this.onVolumeMutedDidChange.bind(this);
608
1103
  }
1104
+ /** Subscribes to all DataStream events to track device state. Called internally via symbol. */
609
1105
  [STATE_SUBSCRIBE_SYMBOL]() {
1106
+ this.#dataStream.on("configureConnection", this.onConfigureConnection);
1107
+ this.#dataStream.on("keyboard", this.onKeyboard);
610
1108
  this.#dataStream.on("deviceInfo", this.onDeviceInfo);
611
1109
  this.#dataStream.on("deviceInfoUpdate", this.onDeviceInfoUpdate);
612
1110
  this.#dataStream.on("originClientProperties", this.onOriginClientProperties);
@@ -614,6 +1112,7 @@ var state_default = class extends EventEmitter {
614
1112
  this.#dataStream.on("removeClient", this.onRemoveClient);
615
1113
  this.#dataStream.on("removePlayer", this.onRemovePlayer);
616
1114
  this.#dataStream.on("sendCommandResult", this.onSendCommandResult);
1115
+ this.#dataStream.on("sendLyricsEvent", this.onSendLyricsEvent);
617
1116
  this.#dataStream.on("setArtwork", this.onSetArtwork);
618
1117
  this.#dataStream.on("setDefaultSupportedCommands", this.onSetDefaultSupportedCommands);
619
1118
  this.#dataStream.on("setNowPlayingClient", this.onSetNowPlayingClient);
@@ -627,10 +1126,14 @@ var state_default = class extends EventEmitter {
627
1126
  this.#dataStream.on("volumeControlAvailability", this.onVolumeControlAvailability);
628
1127
  this.#dataStream.on("volumeControlCapabilitiesDidChange", this.onVolumeControlCapabilitiesDidChange);
629
1128
  this.#dataStream.on("volumeDidChange", this.onVolumeDidChange);
1129
+ this.#dataStream.on("volumeMutedDidChange", this.onVolumeMutedDidChange);
630
1130
  }
1131
+ /** Unsubscribes from all DataStream events. Called internally via symbol. */
631
1132
  [STATE_UNSUBSCRIBE_SYMBOL]() {
632
1133
  const dataStream = this.#dataStream;
633
1134
  if (!dataStream) return;
1135
+ dataStream.off("configureConnection", this.onConfigureConnection);
1136
+ dataStream.off("keyboard", this.onKeyboard);
634
1137
  dataStream.off("deviceInfo", this.onDeviceInfo);
635
1138
  dataStream.off("deviceInfoUpdate", this.onDeviceInfoUpdate);
636
1139
  dataStream.off("originClientProperties", this.onOriginClientProperties);
@@ -638,6 +1141,7 @@ var state_default = class extends EventEmitter {
638
1141
  dataStream.off("removeClient", this.onRemoveClient);
639
1142
  dataStream.off("removePlayer", this.onRemovePlayer);
640
1143
  dataStream.off("sendCommandResult", this.onSendCommandResult);
1144
+ dataStream.off("sendLyricsEvent", this.onSendLyricsEvent);
641
1145
  dataStream.off("setArtwork", this.onSetArtwork);
642
1146
  dataStream.off("setDefaultSupportedCommands", this.onSetDefaultSupportedCommands);
643
1147
  dataStream.off("setNowPlayingClient", this.onSetNowPlayingClient);
@@ -651,126 +1155,324 @@ var state_default = class extends EventEmitter {
651
1155
  dataStream.off("volumeControlAvailability", this.onVolumeControlAvailability);
652
1156
  dataStream.off("volumeControlCapabilitiesDidChange", this.onVolumeControlCapabilitiesDidChange);
653
1157
  dataStream.off("volumeDidChange", this.onVolumeDidChange);
1158
+ dataStream.off("volumeMutedDidChange", this.onVolumeMutedDidChange);
654
1159
  }
1160
+ /** Resets all state to initial/default values. Called on connect and reconnect. */
655
1161
  clear() {
656
1162
  this.#clients = {};
1163
+ this.#keyboardAttributes = null;
1164
+ this.#keyboardState = Proto.KeyboardState_Enum.Unknown;
657
1165
  this.#nowPlayingClientBundleIdentifier = null;
658
1166
  this.#nowPlayingSnapshot = null;
659
1167
  this.#outputDeviceUID = null;
660
1168
  this.#outputDevices = [];
1169
+ this.#clusterID = null;
1170
+ this.#clusterType = 0;
1171
+ this.#isClusterAware = false;
1172
+ this.#isClusterLeader = false;
661
1173
  this.#volume = 0;
662
1174
  this.#volumeAvailable = false;
663
1175
  this.#volumeCapabilities = Proto.VolumeCapabilities_Enum.None;
664
- }
1176
+ this.#volumeMuted = false;
1177
+ }
1178
+ /**
1179
+ * Handles a ConfigureConnection message from the Apple TV.
1180
+ *
1181
+ * @param message - The configure connection message.
1182
+ */
1183
+ onConfigureConnection(message) {
1184
+ this.emit("configureConnection", message);
1185
+ }
1186
+ /**
1187
+ * Handles keyboard state changes. Updates internal state and emits 'keyboard'.
1188
+ *
1189
+ * @param message - The keyboard message with state and attributes.
1190
+ */
1191
+ onKeyboard(message) {
1192
+ this.#keyboardState = message.state;
1193
+ this.#keyboardAttributes = message.attributes ?? null;
1194
+ this.emit("keyboard", message);
1195
+ }
1196
+ /**
1197
+ * Handles initial device info. Updates output device UID and cluster info.
1198
+ *
1199
+ * @param message - The device info message.
1200
+ */
665
1201
  onDeviceInfo(message) {
666
- this.#updateOutputDeviceUID(message);
1202
+ this.#updateDeviceInfo(message);
667
1203
  this.emit("deviceInfo", message);
668
1204
  }
1205
+ /**
1206
+ * Handles device info updates (e.g. cluster changes). Updates output device UID and cluster info.
1207
+ *
1208
+ * @param message - The device info update message.
1209
+ */
669
1210
  onDeviceInfoUpdate(message) {
670
- this.#updateOutputDeviceUID(message);
1211
+ this.#updateDeviceInfo(message);
671
1212
  this.emit("deviceInfoUpdate", message);
672
1213
  }
1214
+ /**
1215
+ * Handles origin client properties updates.
1216
+ *
1217
+ * @param message - The origin client properties message.
1218
+ */
673
1219
  onOriginClientProperties(message) {
674
1220
  this.emit("originClientProperties", message);
675
1221
  }
1222
+ /**
1223
+ * Handles player client properties updates.
1224
+ *
1225
+ * @param message - The player client properties message.
1226
+ */
676
1227
  onPlayerClientProperties(message) {
677
1228
  this.emit("playerClientProperties", message);
678
1229
  }
1230
+ /**
1231
+ * Handles removal of a client (app). Clears the now-playing reference if
1232
+ * the removed client was the active one.
1233
+ *
1234
+ * @param message - The remove client message.
1235
+ */
679
1236
  onRemoveClient(message) {
1237
+ if (!message.client?.bundleIdentifier) return;
680
1238
  if (!(message.client.bundleIdentifier in this.#clients)) return;
681
1239
  const wasActive = this.#nowPlayingClientBundleIdentifier === message.client.bundleIdentifier;
682
1240
  delete this.#clients[message.client.bundleIdentifier];
683
1241
  if (wasActive) this.#nowPlayingClientBundleIdentifier = null;
684
1242
  this.emit("removeClient", message);
685
1243
  this.emit("clients", this.#clients);
686
- if (wasActive) this.#emitNowPlayingChangedIfNeeded();
1244
+ if (wasActive) {
1245
+ this.#emitActivePlayerChanged();
1246
+ this.#emitNowPlayingChangedIfNeeded();
1247
+ }
687
1248
  }
1249
+ /**
1250
+ * Handles command result notifications from the Apple TV.
1251
+ *
1252
+ * @param message - The send command result message.
1253
+ */
688
1254
  onSendCommandResult(message) {
689
1255
  this.emit("sendCommandResult", message);
690
1256
  }
1257
+ /**
1258
+ * Handles lyrics events (time-synced lyrics updates).
1259
+ *
1260
+ * @param message - The lyrics event message.
1261
+ */
1262
+ onSendLyricsEvent(message) {
1263
+ if (message.event) this.emit("lyricsEvent", message.event, message.playerPath);
1264
+ }
1265
+ /**
1266
+ * Handles artwork set notifications.
1267
+ *
1268
+ * @param message - The set artwork message.
1269
+ */
691
1270
  onSetArtwork(message) {
692
1271
  this.emit("setArtwork", message);
693
1272
  }
1273
+ /**
1274
+ * Handles default supported commands for a client. These serve as fallback
1275
+ * commands when a player has no commands of its own.
1276
+ *
1277
+ * @param message - The set default supported commands message.
1278
+ */
694
1279
  onSetDefaultSupportedCommands(message) {
695
1280
  if (message.playerPath?.client?.bundleIdentifier && message.supportedCommands) this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName).setDefaultSupportedCommands(message.supportedCommands.supportedCommands);
696
1281
  this.emit("setDefaultSupportedCommands", message);
697
1282
  }
1283
+ /**
1284
+ * Handles the now-playing client changing (e.g. user switches app).
1285
+ * Updates the active client reference and emits change events.
1286
+ *
1287
+ * @param message - The set now-playing client message.
1288
+ */
698
1289
  onSetNowPlayingClient(message) {
1290
+ const oldBundleId = this.#nowPlayingClientBundleIdentifier;
699
1291
  this.#nowPlayingClientBundleIdentifier = message.client?.bundleIdentifier ?? null;
700
1292
  if (message.client?.bundleIdentifier && message.client?.displayName) this.#client(message.client.bundleIdentifier, message.client.displayName);
701
1293
  this.emit("setNowPlayingClient", message);
1294
+ if (oldBundleId !== this.#nowPlayingClientBundleIdentifier) this.#emitActivePlayerChanged();
702
1295
  this.#emitNowPlayingChangedIfNeeded();
703
1296
  }
1297
+ /**
1298
+ * Handles the active player changing within a client (e.g. PiP player becomes active).
1299
+ * Creates the player if needed and sets it as the active player.
1300
+ *
1301
+ * @param message - The set now-playing player message.
1302
+ */
704
1303
  onSetNowPlayingPlayer(message) {
705
1304
  if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) {
706
1305
  const client = this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName);
1306
+ const oldActiveId = client.activePlayer?.identifier;
707
1307
  client.getOrCreatePlayer(message.playerPath.player.identifier, message.playerPath.player.displayName);
708
1308
  client.setActivePlayer(message.playerPath.player.identifier);
1309
+ if (oldActiveId !== message.playerPath.player.identifier) this.#emitActivePlayerChanged();
709
1310
  }
710
1311
  this.emit("setNowPlayingPlayer", message);
711
1312
  this.#emitNowPlayingChangedIfNeeded();
712
1313
  }
1314
+ /**
1315
+ * Handles comprehensive state updates. Processes playback state, now-playing info,
1316
+ * supported commands, and playback queue in a single message.
1317
+ * Emits granular events for each changed aspect.
1318
+ *
1319
+ * @param message - The set state message.
1320
+ */
713
1321
  onSetState(message) {
714
- const bundleIdentifier = message.playerPath.client.bundleIdentifier;
1322
+ const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
1323
+ if (!bundleIdentifier) return;
715
1324
  const client = this.#client(bundleIdentifier, message.displayName);
716
1325
  const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
717
1326
  const player = client.getOrCreatePlayer(playerIdentifier, message.playerPath?.player?.displayName);
1327
+ const isActiveClient = bundleIdentifier === this.#nowPlayingClientBundleIdentifier;
1328
+ if (message.playbackState) {
1329
+ const oldState = player.playbackState;
1330
+ player.setPlaybackState(message.playbackState, message.playbackStateTimestamp);
1331
+ if (isActiveClient && oldState !== player.playbackState) this.emit("playbackStateChanged", client, player, oldState, player.playbackState);
1332
+ }
718
1333
  if (message.nowPlayingInfo) player.setNowPlayingInfo(message.nowPlayingInfo);
719
- if (message.playbackState) player.setPlaybackState(message.playbackState, message.playbackStateTimestamp);
720
- if (message.supportedCommands) player.setSupportedCommands(message.supportedCommands.supportedCommands);
721
- if (message.playbackQueue) player.setPlaybackQueue(message.playbackQueue);
1334
+ if (message.supportedCommands) {
1335
+ player.setSupportedCommands(message.supportedCommands.supportedCommands);
1336
+ if (isActiveClient) this.emit("supportedCommandsChanged", client, player, player.supportedCommands);
1337
+ }
1338
+ if (message.playbackQueue) {
1339
+ player.setPlaybackQueue(message.playbackQueue);
1340
+ if (isActiveClient) this.emit("playbackQueueChanged", client, player);
1341
+ }
722
1342
  this.emit("setState", message);
723
- if (bundleIdentifier === this.#nowPlayingClientBundleIdentifier) this.#emitNowPlayingChangedIfNeeded();
1343
+ if (isActiveClient) this.#emitNowPlayingChangedIfNeeded();
724
1344
  }
1345
+ /**
1346
+ * Handles content item updates (metadata, artwork, lyrics changes for existing items).
1347
+ *
1348
+ * @param message - The update content item message.
1349
+ */
725
1350
  onUpdateContentItem(message) {
726
- const bundleIdentifier = message.playerPath.client.bundleIdentifier;
727
- const client = this.#client(bundleIdentifier, message.playerPath.client.displayName);
1351
+ const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
1352
+ if (!bundleIdentifier) return;
1353
+ const client = this.#client(bundleIdentifier, message.playerPath?.client?.displayName ?? "");
728
1354
  const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
729
1355
  const player = client.getOrCreatePlayer(playerIdentifier, message.playerPath?.player?.displayName);
730
1356
  for (const item of message.contentItems) player.updateContentItem(item);
731
1357
  this.emit("updateContentItem", message);
732
1358
  if (bundleIdentifier === this.#nowPlayingClientBundleIdentifier) this.#emitNowPlayingChangedIfNeeded();
733
1359
  }
1360
+ /**
1361
+ * Handles artwork updates for content items. Emits 'artworkChanged' if a client and player are active.
1362
+ *
1363
+ * @param message - The update content item artwork message.
1364
+ */
734
1365
  onUpdateContentItemArtwork(message) {
735
1366
  this.emit("updateContentItemArtwork", message);
736
- }
1367
+ const client = this.nowPlayingClient;
1368
+ const player = client?.activePlayer;
1369
+ if (client && player) this.emit("artworkChanged", client, player);
1370
+ }
1371
+ /**
1372
+ * Handles player registration or update. Creates the player if it does not exist.
1373
+ *
1374
+ * @param message - The update player message.
1375
+ */
737
1376
  onUpdatePlayer(message) {
738
1377
  if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName).getOrCreatePlayer(message.playerPath.player.identifier, message.playerPath.player.displayName);
739
1378
  this.emit("updatePlayer", message);
740
1379
  }
1380
+ /**
1381
+ * Handles player removal. Removes the player from its client and emits
1382
+ * active player changed events if the removed player was active.
1383
+ *
1384
+ * @param message - The remove player message.
1385
+ */
741
1386
  onRemovePlayer(message) {
742
1387
  if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) {
743
1388
  const client = this.#clients[message.playerPath.client.bundleIdentifier];
744
1389
  if (client) client.removePlayer(message.playerPath.player.identifier);
745
1390
  }
746
1391
  this.emit("removePlayer", message);
747
- if (message.playerPath?.client?.bundleIdentifier === this.#nowPlayingClientBundleIdentifier) this.#emitNowPlayingChangedIfNeeded();
1392
+ if (message.playerPath?.client?.bundleIdentifier === this.#nowPlayingClientBundleIdentifier) {
1393
+ this.#emitActivePlayerChanged();
1394
+ this.#emitNowPlayingChangedIfNeeded();
1395
+ }
748
1396
  }
1397
+ /**
1398
+ * Handles client (app) registration or display name update.
1399
+ *
1400
+ * @param message - The update client message.
1401
+ */
749
1402
  onUpdateClient(message) {
1403
+ if (!message.client?.bundleIdentifier) return;
750
1404
  this.#client(message.client.bundleIdentifier, message.client.displayName);
1405
+ this.emit("updateClient", message);
751
1406
  this.emit("clients", this.#clients);
752
1407
  }
1408
+ /**
1409
+ * Handles output device list updates. Prefers cluster-aware devices when available.
1410
+ *
1411
+ * @param message - The update output device message.
1412
+ */
753
1413
  onUpdateOutputDevice(message) {
754
- this.#outputDevices = message.outputDevices;
1414
+ this.#outputDevices = message.clusterAwareOutputDevices?.length > 0 ? message.clusterAwareOutputDevices : message.outputDevices;
755
1415
  this.emit("updateOutputDevice", message);
756
1416
  }
1417
+ /**
1418
+ * Handles volume control availability changes.
1419
+ *
1420
+ * @param message - The volume control availability message.
1421
+ */
757
1422
  onVolumeControlAvailability(message) {
758
1423
  this.#volumeAvailable = message.volumeControlAvailable;
759
1424
  this.#volumeCapabilities = message.volumeCapabilities;
760
1425
  this.emit("volumeControlAvailability", message.volumeControlAvailable, message.volumeCapabilities);
761
1426
  }
1427
+ /**
1428
+ * Handles volume capabilities changes (e.g. device gains or loses absolute volume support).
1429
+ *
1430
+ * @param message - The volume capabilities change message.
1431
+ */
762
1432
  onVolumeControlCapabilitiesDidChange(message) {
1433
+ if (!message.capabilities) return;
763
1434
  this.#volumeAvailable = message.capabilities.volumeControlAvailable;
764
1435
  this.#volumeCapabilities = message.capabilities.volumeCapabilities;
765
1436
  this.emit("volumeControlCapabilitiesDidChange", message.capabilities.volumeControlAvailable, message.capabilities.volumeCapabilities);
766
1437
  }
1438
+ /**
1439
+ * Handles volume level changes.
1440
+ *
1441
+ * @param message - The volume change message.
1442
+ */
767
1443
  onVolumeDidChange(message) {
768
1444
  this.#volume = message.volume;
769
1445
  this.emit("volumeDidChange", message.volume);
770
1446
  }
771
- #updateOutputDeviceUID(message) {
1447
+ /**
1448
+ * Handles mute state changes.
1449
+ *
1450
+ * @param message - The volume muted change message.
1451
+ */
1452
+ onVolumeMutedDidChange(message) {
1453
+ this.#volumeMuted = message.isMuted;
1454
+ this.emit("volumeMutedDidChange", this.#volumeMuted);
1455
+ }
1456
+ /**
1457
+ * Extracts output device UID and cluster information from a device info message.
1458
+ *
1459
+ * @param message - The device info message.
1460
+ */
1461
+ #updateDeviceInfo(message) {
772
1462
  this.#outputDeviceUID = message.clusterID || message.deviceUID || message.uniqueIdentifier || null;
773
- }
1463
+ this.#clusterID = message.clusterID || null;
1464
+ this.#clusterType = message.clusterType ?? 0;
1465
+ this.#isClusterAware = message.isClusterAware ?? false;
1466
+ this.#isClusterLeader = message.isClusterLeader ?? false;
1467
+ }
1468
+ /**
1469
+ * Gets or creates a Client for the given bundle identifier.
1470
+ * Updates the display name if the client already exists.
1471
+ *
1472
+ * @param bundleIdentifier - The app's bundle identifier.
1473
+ * @param displayName - The app's display name.
1474
+ * @returns The existing or newly created Client.
1475
+ */
774
1476
  #client(bundleIdentifier, displayName) {
775
1477
  if (bundleIdentifier in this.#clients) {
776
1478
  const client = this.#clients[bundleIdentifier];
@@ -783,6 +1485,11 @@ var state_default = class extends EventEmitter {
783
1485
  return client;
784
1486
  }
785
1487
  }
1488
+ /**
1489
+ * Creates a snapshot of the current now-playing state for change detection.
1490
+ *
1491
+ * @returns A NowPlayingSnapshot of the current state.
1492
+ */
786
1493
  #createNowPlayingSnapshot() {
787
1494
  const client = this.nowPlayingClient;
788
1495
  const player = client?.activePlayer ?? null;
@@ -795,6 +1502,7 @@ var state_default = class extends EventEmitter {
795
1502
  album: player?.album ?? "",
796
1503
  genre: player?.genre ?? "",
797
1504
  duration: player?.duration ?? 0,
1505
+ playbackRate: player?.playbackRate ?? 0,
798
1506
  shuffleMode: player?.shuffleMode ?? Proto.ShuffleMode_Enum.Unknown,
799
1507
  repeatMode: player?.repeatMode ?? Proto.RepeatMode_Enum.Unknown,
800
1508
  mediaType: player?.mediaType ?? Proto.ContentItemMetadata_MediaType.UnknownMediaType,
@@ -804,9 +1512,17 @@ var state_default = class extends EventEmitter {
804
1512
  contentIdentifier: player?.contentIdentifier ?? "",
805
1513
  artworkId: player?.artworkId ?? null,
806
1514
  hasArtworkUrl: player?.artworkUrl() != null,
807
- hasArtworkData: player?.currentItemArtwork != null
1515
+ hasArtworkData: player?.currentItemArtwork != null,
1516
+ isAlwaysLive: player?.nowPlayingInfo?.isAlwaysLive ?? false,
1517
+ isAdvertisement: player?.nowPlayingInfo?.isAdvertisement ?? false
808
1518
  };
809
1519
  }
1520
+ /** Emits the 'activePlayerChanged' event with the current client and player. */
1521
+ #emitActivePlayerChanged() {
1522
+ const client = this.nowPlayingClient;
1523
+ this.emit("activePlayerChanged", client, client?.activePlayer ?? null);
1524
+ }
1525
+ /** Emits 'nowPlayingChanged' only if the now-playing snapshot has actually changed. */
810
1526
  #emitNowPlayingChangedIfNeeded() {
811
1527
  const snapshot = this.#createNowPlayingSnapshot();
812
1528
  const previous = this.#nowPlayingSnapshot;
@@ -815,25 +1531,51 @@ var state_default = class extends EventEmitter {
815
1531
  const client = this.nowPlayingClient;
816
1532
  this.emit("nowPlayingChanged", client, client?.activePlayer ?? null);
817
1533
  }
1534
+ /**
1535
+ * Compares two NowPlayingSnapshot instances field-by-field for equality.
1536
+ *
1537
+ * @param a - First snapshot.
1538
+ * @param b - Second snapshot.
1539
+ * @returns True if all fields are equal.
1540
+ */
818
1541
  #snapshotsEqual(a, b) {
819
- return a.bundleIdentifier === b.bundleIdentifier && a.playerIdentifier === b.playerIdentifier && a.playbackState === b.playbackState && a.title === b.title && a.artist === b.artist && a.album === b.album && a.genre === b.genre && a.duration === b.duration && a.shuffleMode === b.shuffleMode && a.repeatMode === b.repeatMode && a.mediaType === b.mediaType && a.seriesName === b.seriesName && a.seasonNumber === b.seasonNumber && a.episodeNumber === b.episodeNumber && a.contentIdentifier === b.contentIdentifier && a.artworkId === b.artworkId && a.hasArtworkUrl === b.hasArtworkUrl && a.hasArtworkData === b.hasArtworkData;
1542
+ return a.bundleIdentifier === b.bundleIdentifier && a.playerIdentifier === b.playerIdentifier && a.playbackState === b.playbackState && a.title === b.title && a.artist === b.artist && a.album === b.album && a.genre === b.genre && a.duration === b.duration && a.playbackRate === b.playbackRate && a.shuffleMode === b.shuffleMode && a.repeatMode === b.repeatMode && a.mediaType === b.mediaType && a.seriesName === b.seriesName && a.seasonNumber === b.seasonNumber && a.episodeNumber === b.episodeNumber && a.contentIdentifier === b.contentIdentifier && a.artworkId === b.artworkId && a.hasArtworkUrl === b.hasArtworkUrl && a.hasArtworkData === b.hasArtworkData && a.isAlwaysLive === b.isAlwaysLive && a.isAdvertisement === b.isAdvertisement;
820
1543
  }
821
1544
  };
822
1545
 
823
1546
  //#endregion
824
1547
  //#region src/airplay/volume.ts
1548
+ /** Volume adjustment step size as a fraction (0.05 = 5%). */
825
1549
  const VOLUME_STEP = .05;
1550
+ /**
1551
+ * Smart volume controller for an AirPlay device.
1552
+ * Automatically chooses between absolute volume (set a specific level) and
1553
+ * relative volume (HID volume up/down keys) based on the device's reported capabilities.
1554
+ */
826
1555
  var volume_default = class {
1556
+ /** @returns The underlying AirPlay Protocol instance. */
827
1557
  get #protocol() {
828
1558
  return this.#device[PROTOCOL];
829
1559
  }
1560
+ /** @returns The AirPlay state for volume and capability information. */
830
1561
  get #state() {
831
1562
  return this.#device.state;
832
1563
  }
833
1564
  #device;
1565
+ /**
1566
+ * Creates a new Volume controller.
1567
+ *
1568
+ * @param device - The AirPlay device to control volume for.
1569
+ */
834
1570
  constructor(device) {
835
1571
  this.#device = device;
836
1572
  }
1573
+ /**
1574
+ * Decreases the volume by one step. Uses absolute volume when available,
1575
+ * falls back to HID relative volume keys otherwise.
1576
+ *
1577
+ * @throws CommandError when volume control is not available.
1578
+ */
837
1579
  async down() {
838
1580
  switch (this.#state.volumeCapabilities) {
839
1581
  case Proto.VolumeCapabilities_Enum.Absolute:
@@ -844,31 +1586,49 @@ var volume_default = class {
844
1586
  case Proto.VolumeCapabilities_Enum.Relative:
845
1587
  await this.#device.remote.volumeDown();
846
1588
  break;
847
- default: throw new Error("Volume control is not available.");
1589
+ default: throw new CommandError("Volume control is not available.");
848
1590
  }
849
1591
  }
1592
+ /**
1593
+ * Increases the volume by one step. Uses absolute volume when available,
1594
+ * falls back to HID relative volume keys otherwise.
1595
+ *
1596
+ * @throws CommandError when volume control is not available.
1597
+ */
850
1598
  async up() {
851
1599
  switch (this.#state.volumeCapabilities) {
852
1600
  case Proto.VolumeCapabilities_Enum.Absolute:
853
1601
  case Proto.VolumeCapabilities_Enum.Both:
854
- const newVolume = Math.max(0, this.#state.volume + VOLUME_STEP);
1602
+ const newVolume = Math.min(1, Math.max(0, this.#state.volume + VOLUME_STEP));
855
1603
  await this.set(newVolume);
856
1604
  break;
857
1605
  case Proto.VolumeCapabilities_Enum.Relative:
858
1606
  await this.#device.remote.volumeUp();
859
1607
  break;
860
- default: throw new Error("Volume control is not available.");
1608
+ default: throw new CommandError("Volume control is not available.");
861
1609
  }
862
1610
  }
1611
+ /**
1612
+ * Fetches the current volume level from the device.
1613
+ *
1614
+ * @returns The volume level as a float between 0.0 and 1.0.
1615
+ * @throws CommandError when no output device is active or the request fails.
1616
+ */
863
1617
  async get() {
864
- if (!this.#state.outputDeviceUID) throw new Error("No output device active.");
1618
+ if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
865
1619
  const response = await this.#protocol.dataStream.exchange(DataStreamMessage.getVolume(this.#state.outputDeviceUID));
866
1620
  if (response.type === Proto.ProtocolMessage_Type.GET_VOLUME_RESULT_MESSAGE) return DataStreamMessage.getExtension(response, Proto.getVolumeResultMessage).volume;
867
- throw new Error("Failed to get volume.");
868
- }
1621
+ throw new CommandError("Failed to get volume.");
1622
+ }
1623
+ /**
1624
+ * Sets the volume to an absolute level.
1625
+ *
1626
+ * @param volume - The desired volume level (clamped to 0.0 - 1.0).
1627
+ * @throws CommandError when no output device is active or absolute volume is not supported.
1628
+ */
869
1629
  async set(volume) {
870
- if (!this.#state.outputDeviceUID) throw new Error("No output device active.");
871
- if (![Proto.VolumeCapabilities_Enum.Absolute, Proto.VolumeCapabilities_Enum.Both].includes(this.#state.volumeCapabilities)) throw new Error("Absolute volume control is not available.");
1630
+ if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
1631
+ if (![Proto.VolumeCapabilities_Enum.Absolute, Proto.VolumeCapabilities_Enum.Both].includes(this.#state.volumeCapabilities)) throw new CommandError("Absolute volume control is not available.");
872
1632
  volume = Math.min(1, Math.max(0, volume));
873
1633
  this.#protocol.context.logger.info(`Setting volume to ${volume} for device ${this.#state.outputDeviceUID}`);
874
1634
  await this.#protocol.dataStream.exchange(DataStreamMessage.setVolume(this.#state.outputDeviceUID, volume));
@@ -877,31 +1637,68 @@ var volume_default = class {
877
1637
 
878
1638
  //#endregion
879
1639
  //#region src/airplay/device.ts
1640
+ /**
1641
+ * High-level abstraction for an AirPlay device (Apple TV or HomePod).
1642
+ * Manages the full lifecycle: connect, pair/verify, set up control/data/event streams,
1643
+ * and provides access to Remote, State, and Volume controllers.
1644
+ * Supports both transient (PIN-less) and credential-based pairing.
1645
+ */
880
1646
  var device_default = class extends EventEmitter {
1647
+ /** @returns The underlying AirPlay Protocol instance (accessed via symbol for internal use). */
881
1648
  get [PROTOCOL]() {
882
1649
  return this.#protocol;
883
1650
  }
1651
+ /** The mDNS discovery result used to connect to this device. */
884
1652
  get discoveryResult() {
885
1653
  return this.#discoveryResult;
886
1654
  }
1655
+ /** Updates the discovery result, e.g. when the device's address changes. */
887
1656
  set discoveryResult(discoveryResult) {
888
1657
  this.#discoveryResult = discoveryResult;
889
1658
  }
1659
+ /**
1660
+ * Device capabilities derived from the AirPlay feature flags.
1661
+ * Indicates which protocols and features the receiver supports.
1662
+ */
1663
+ get capabilities() {
1664
+ const has = (f) => this.#protocol?.hasReceiverFeature(f) ?? false;
1665
+ return {
1666
+ supportsAudio: has(AirPlayFeature.SupportsAirPlayAudio),
1667
+ supportsBufferedAudio: has(AirPlayFeature.SupportsBufferedAudio),
1668
+ supportsPTP: has(AirPlayFeature.SupportsPTP),
1669
+ supportsRFC2198Redundancy: has(AirPlayFeature.SupportsRFC2198Redundancy),
1670
+ supportsHangdogRemoteControl: has(AirPlayFeature.SupportsHangdogRemoteControl),
1671
+ supportsUnifiedMediaControl: has(AirPlayFeature.SupportsUnifiedMediaControl),
1672
+ supportsTransientPairing: has(AirPlayFeature.SupportsHKPairingAndAccessControl),
1673
+ supportsSystemPairing: has(AirPlayFeature.SupportsSystemPairing),
1674
+ supportsCoreUtilsPairing: has(AirPlayFeature.SupportsCoreUtilsPairingAndEncryption)
1675
+ };
1676
+ }
1677
+ /** Whether the control stream TCP connection is currently active. */
890
1678
  get isConnected() {
891
1679
  return this.#protocol?.controlStream?.isConnected ?? false;
892
1680
  }
1681
+ /** Raw receiver info dictionary from the /info endpoint, or undefined before connect. */
1682
+ get receiverInfo() {
1683
+ return this.#protocol?.receiverInfo;
1684
+ }
1685
+ /** The Remote controller for HID keys, SendCommand, text input, and touch. */
893
1686
  get remote() {
894
1687
  return this.#remote;
895
1688
  }
1689
+ /** The State tracker for now-playing, volume, keyboard, and output device state. */
896
1690
  get state() {
897
1691
  return this.#state;
898
1692
  }
1693
+ /** The Volume controller for absolute and relative volume adjustments. */
899
1694
  get volume() {
900
1695
  return this.#volume;
901
1696
  }
1697
+ /** The shared PTP timing server, if one is assigned for multi-room sync. */
902
1698
  get timingServer() {
903
1699
  return this.#timingServer;
904
1700
  }
1701
+ /** Assigns a PTP timing server for multi-room audio synchronization. */
905
1702
  set timingServer(timingServer) {
906
1703
  this.#timingServer = timingServer;
907
1704
  }
@@ -911,25 +1708,53 @@ var device_default = class extends EventEmitter {
911
1708
  #credentials;
912
1709
  #disconnect = false;
913
1710
  #discoveryResult;
1711
+ #identity;
914
1712
  #feedbackInterval;
915
1713
  #keys;
1714
+ #playUrlProtocol;
1715
+ #prevDataStream;
1716
+ #prevEventStream;
916
1717
  #protocol;
917
1718
  #timingServer;
918
- constructor(discoveryResult) {
1719
+ /**
1720
+ * Creates a new AirPlayDevice.
1721
+ *
1722
+ * @param discoveryResult - The mDNS discovery result for the target device.
1723
+ * @param identity - Optional partial device identity to present during pairing.
1724
+ */
1725
+ constructor(discoveryResult, identity) {
919
1726
  super();
920
1727
  this.#discoveryResult = discoveryResult;
1728
+ this.#identity = identity;
921
1729
  this.#remote = new remote_default(this);
922
1730
  this.#state = new state_default(this);
1731
+ this.onClose = this.onClose.bind(this);
1732
+ this.onError = this.onError.bind(this);
1733
+ this.onTimeout = this.onTimeout.bind(this);
923
1734
  this.#volume = new volume_default(this);
924
1735
  }
1736
+ /**
1737
+ * Connects to the AirPlay device, performs pairing/verification,
1738
+ * and sets up all streams (control, data, event). Emits 'connected' on success.
1739
+ * If credentials are set, uses pair-verify; otherwise uses transient pairing.
1740
+ */
925
1741
  async connect() {
1742
+ if (this.#protocol) {
1743
+ this.#protocol.controlStream.off("close", this.onClose);
1744
+ this.#protocol.controlStream.off("error", this.onError);
1745
+ this.#protocol.controlStream.off("timeout", this.onTimeout);
1746
+ try {
1747
+ this.#protocol.disconnect();
1748
+ } catch {}
1749
+ }
926
1750
  this.#disconnect = false;
927
1751
  this.#state.clear();
928
- this.#protocol = new Protocol(this.#discoveryResult);
929
- this.#protocol.controlStream.on("close", this.#onClose.bind(this));
930
- this.#protocol.controlStream.on("error", this.#onError.bind(this));
931
- this.#protocol.controlStream.on("timeout", this.#onTimeout.bind(this));
1752
+ this.#protocol = new Protocol(this.#discoveryResult, this.#identity);
1753
+ this.#protocol.controlStream.on("close", this.onClose);
1754
+ this.#protocol.controlStream.on("error", this.onError);
1755
+ this.#protocol.controlStream.on("timeout", this.onTimeout);
932
1756
  await this.#protocol.connect();
1757
+ await this.#protocol.fetchInfo();
933
1758
  if (this.#credentials) this.#keys = await this.#protocol.verify.start(this.#credentials);
934
1759
  else {
935
1760
  await this.#protocol.pairing.start();
@@ -938,15 +1763,19 @@ var device_default = class extends EventEmitter {
938
1763
  await this.#setup();
939
1764
  this.emit("connected");
940
1765
  }
1766
+ /** Gracefully disconnects from the device, clears intervals, and tears down all streams. */
941
1767
  disconnect() {
942
1768
  this.#disconnect = true;
943
1769
  if (this.#feedbackInterval) {
944
1770
  clearInterval(this.#feedbackInterval);
945
1771
  this.#feedbackInterval = void 0;
946
1772
  }
1773
+ this.#cleanupPlayUrl();
947
1774
  this.#unsubscribe();
948
1775
  this.#protocol.disconnect();
1776
+ this.emit("disconnected", false);
949
1777
  }
1778
+ /** Disconnects gracefully, swallowing any errors during cleanup. */
950
1779
  disconnectSafely() {
951
1780
  try {
952
1781
  this.disconnect();
@@ -954,27 +1783,128 @@ var device_default = class extends EventEmitter {
954
1783
  this.#protocol?.context?.logger?.warn("[device]", "Error during safe disconnect", err);
955
1784
  }
956
1785
  }
1786
+ /**
1787
+ * Enables or disables conversation detection on the output device (HomePod feature).
1788
+ *
1789
+ * @param enabled - Whether to enable conversation detection.
1790
+ * @throws Error when no output device is active.
1791
+ */
1792
+ async setConversationDetectionEnabled(enabled) {
1793
+ const outputDeviceUID = this.#state.outputDeviceUID;
1794
+ if (!outputDeviceUID) throw new Error("No output device active.");
1795
+ await this.#protocol.dataStream.send(DataStreamMessage.setConversationDetectionEnabled(enabled, outputDeviceUID));
1796
+ }
1797
+ /**
1798
+ * Adds devices to the current multi-room output context.
1799
+ *
1800
+ * @param deviceUIDs - UIDs of the devices to add.
1801
+ */
957
1802
  async addOutputDevices(deviceUIDs) {
958
1803
  await this.#protocol.dataStream.exchange(DataStreamMessage.modifyOutputContext(deviceUIDs));
959
1804
  }
1805
+ /**
1806
+ * Removes devices from the current multi-room output context.
1807
+ *
1808
+ * @param deviceUIDs - UIDs of the devices to remove.
1809
+ */
960
1810
  async removeOutputDevices(deviceUIDs) {
961
1811
  await this.#protocol.dataStream.exchange(DataStreamMessage.modifyOutputContext([], deviceUIDs));
962
1812
  }
1813
+ /**
1814
+ * Replaces the entire multi-room output context with the given devices.
1815
+ *
1816
+ * @param deviceUIDs - UIDs of the devices to set as the output context.
1817
+ */
963
1818
  async setOutputDevices(deviceUIDs) {
964
1819
  await this.#protocol.dataStream.exchange(DataStreamMessage.modifyOutputContext([], [], deviceUIDs));
965
1820
  }
1821
+ /**
1822
+ * Plays a URL on the device (the device fetches and plays the content).
1823
+ * Creates a separate Protocol instance to avoid conflicting with the
1824
+ * existing remote control session, following the same approach as pyatv.
1825
+ *
1826
+ * @param url - The media URL to play.
1827
+ * @param position - Start position in seconds (defaults to 0).
1828
+ * @throws Error when not connected.
1829
+ */
1830
+ async playUrl(url, position = 0) {
1831
+ if (!this.#keys) throw new Error("Not connected. Call connect() first.");
1832
+ this.#playUrlProtocol?.disconnect();
1833
+ const playProtocol = new Protocol(this.#discoveryResult, this.#identity);
1834
+ if (this.#timingServer) playProtocol.useTimingServer(this.#timingServer);
1835
+ try {
1836
+ await playProtocol.connect();
1837
+ await playProtocol.fetchInfo();
1838
+ let keys;
1839
+ if (this.#credentials) keys = await playProtocol.verify.start(this.#credentials);
1840
+ else {
1841
+ await playProtocol.pairing.start();
1842
+ keys = await playProtocol.pairing.transient();
1843
+ }
1844
+ playProtocol.controlStream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
1845
+ this.#playUrlProtocol = playProtocol;
1846
+ await playProtocol.playUrl(url, keys.sharedSecret, keys.pairingId, position);
1847
+ } catch (err) {
1848
+ if (this.#playUrlProtocol !== playProtocol) playProtocol.disconnect();
1849
+ throw err;
1850
+ }
1851
+ }
1852
+ /** Waits for the current URL playback to finish, then cleans up the play URL protocol. */
1853
+ async waitForPlaybackEnd() {
1854
+ if (!this.#playUrlProtocol) return;
1855
+ try {
1856
+ await this.#playUrlProtocol.waitForPlaybackEnd();
1857
+ } finally {
1858
+ this.#cleanupPlayUrl();
1859
+ }
1860
+ }
1861
+ /** Stops the current URL playback and cleans up the dedicated play URL protocol. */
1862
+ stopPlayUrl() {
1863
+ this.#cleanupPlayUrl();
1864
+ }
1865
+ /** Stops, disconnects, and discards the dedicated play URL protocol instance. */
1866
+ #cleanupPlayUrl() {
1867
+ if (this.#playUrlProtocol) {
1868
+ this.#playUrlProtocol.stopPlayUrl();
1869
+ this.#playUrlProtocol.disconnect();
1870
+ this.#playUrlProtocol = void 0;
1871
+ }
1872
+ }
1873
+ /**
1874
+ * Streams audio from a source to the device via RAOP/RTP.
1875
+ *
1876
+ * @param source - The audio source to stream (e.g. MP3, WAV, URL, live).
1877
+ */
966
1878
  async streamAudio(source) {
967
1879
  await this.#protocol.setupAudioStream(source);
968
1880
  }
1881
+ /**
1882
+ * Requests the playback queue from the device.
1883
+ *
1884
+ * @param length - Maximum number of queue items to retrieve.
1885
+ */
969
1886
  async requestPlaybackQueue(length) {
970
1887
  await this.#protocol.dataStream.exchange(DataStreamMessage.playbackQueueRequest(0, length));
971
1888
  }
1889
+ /**
1890
+ * Sends a raw MRP command to the device via the DataStream.
1891
+ *
1892
+ * @param command - The command to send.
1893
+ * @param options - Optional command options.
1894
+ */
972
1895
  async sendCommand(command, options) {
973
1896
  await this.#protocol.dataStream.exchange(DataStreamMessage.sendCommand(command, options));
974
1897
  }
1898
+ /**
1899
+ * Sets the pairing credentials for pair-verify authentication.
1900
+ * Must be called before connect() if credential-based pairing is desired.
1901
+ *
1902
+ * @param credentials - The accessory credentials obtained from pair-setup.
1903
+ */
975
1904
  setCredentials(credentials) {
976
1905
  this.#credentials = credentials;
977
1906
  }
1907
+ /** Sends a periodic feedback request to keep the AirPlay session alive. */
978
1908
  async #feedback() {
979
1909
  try {
980
1910
  await this.#protocol.feedback();
@@ -982,37 +1912,55 @@ var device_default = class extends EventEmitter {
982
1912
  this.#protocol.context.logger.error("Feedback error", err);
983
1913
  }
984
1914
  }
985
- #onClose() {
986
- this.#protocol.context.logger.net("#onClose() called on airplay device.");
987
- if (!this.#disconnect) {
988
- this.disconnectSafely();
989
- this.emit("disconnected", true);
990
- } else this.emit("disconnected", false);
991
- }
992
- #onError(err) {
1915
+ /** Handles the control stream close event. Emits 'disconnected' with unexpected=true if not intentional. */
1916
+ onClose() {
1917
+ this.#protocol.context.logger.net("onClose() called on airplay device.");
1918
+ if (this.#disconnect) return;
1919
+ this.#disconnect = true;
1920
+ this.disconnectSafely();
1921
+ this.emit("disconnected", true);
1922
+ }
1923
+ /**
1924
+ * Handles stream error events by logging them.
1925
+ *
1926
+ * @param err - The error that occurred.
1927
+ */
1928
+ onError(err) {
993
1929
  this.#protocol.context.logger.error("AirPlay error", err);
994
1930
  }
995
- #onTimeout() {
1931
+ /** Handles stream timeout events by destroying the control stream. */
1932
+ onTimeout() {
996
1933
  this.#protocol.context.logger.error("AirPlay timeout");
997
1934
  this.#protocol.controlStream.destroy();
998
1935
  }
1936
+ /**
1937
+ * Sets up encryption, event/data streams, feedback interval, and initial state subscriptions.
1938
+ * Called after successful pairing/verification.
1939
+ */
999
1940
  async #setup() {
1000
1941
  const keys = this.#keys;
1001
1942
  this.#protocol.controlStream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
1002
1943
  this.#unsubscribe();
1003
1944
  if (this.#timingServer) this.#protocol.useTimingServer(this.#timingServer);
1004
1945
  try {
1946
+ this.#prevDataStream?.off("error", this.onError);
1947
+ this.#prevDataStream?.off("timeout", this.onTimeout);
1948
+ this.#prevEventStream?.off("error", this.onError);
1949
+ this.#prevEventStream?.off("timeout", this.onTimeout);
1005
1950
  await this.#protocol.setupEventStream(keys.sharedSecret, keys.pairingId);
1006
1951
  await this.#protocol.setupDataStream(keys.sharedSecret, () => this.#subscribe());
1007
- this.#protocol.dataStream.on("error", this.#onError.bind(this));
1008
- this.#protocol.dataStream.on("timeout", this.#onTimeout.bind(this));
1009
- this.#protocol.eventStream.on("error", this.#onError.bind(this));
1010
- this.#protocol.eventStream.on("timeout", this.#onTimeout.bind(this));
1952
+ this.#protocol.dataStream.on("error", this.onError);
1953
+ this.#protocol.dataStream.on("timeout", this.onTimeout);
1954
+ this.#protocol.eventStream.on("error", this.onError);
1955
+ this.#protocol.eventStream.on("timeout", this.onTimeout);
1956
+ this.#prevDataStream = this.#protocol.dataStream;
1957
+ this.#prevEventStream = this.#protocol.eventStream;
1011
1958
  if (this.#feedbackInterval) clearInterval(this.#feedbackInterval);
1012
1959
  this.#feedbackInterval = setInterval(async () => await this.#feedback(), FEEDBACK_INTERVAL);
1013
- await this.#protocol.dataStream.exchange(DataStreamMessage.deviceInfo(keys.pairingId));
1960
+ await this.#protocol.dataStream.exchange(DataStreamMessage.deviceInfo(keys.pairingId, this.#protocol.context.identity));
1014
1961
  await this.#protocol.dataStream.exchange(DataStreamMessage.setConnectionState());
1015
- await this.#protocol.dataStream.exchange(DataStreamMessage.clientUpdatesConfig());
1962
+ await this.#protocol.dataStream.exchange(DataStreamMessage.clientUpdatesConfig(true, true, true, true, true, true));
1963
+ await this.#protocol.dataStream.exchange(DataStreamMessage.getState());
1016
1964
  this.#protocol.context.logger.info("Protocol ready.");
1017
1965
  } catch (err) {
1018
1966
  if (this.#feedbackInterval) {
@@ -1024,9 +1972,11 @@ var device_default = class extends EventEmitter {
1024
1972
  throw err;
1025
1973
  }
1026
1974
  }
1975
+ /** Subscribes the state tracker to DataStream events. */
1027
1976
  #subscribe() {
1028
1977
  this.#state[STATE_SUBSCRIBE_SYMBOL]();
1029
1978
  }
1979
+ /** Unsubscribes the state tracker from DataStream events. */
1030
1980
  #unsubscribe() {
1031
1981
  try {
1032
1982
  this.#state[STATE_UNSUBSCRIBE_SYMBOL]();
@@ -1038,109 +1988,640 @@ var device_default = class extends EventEmitter {
1038
1988
 
1039
1989
  //#endregion
1040
1990
  //#region src/companion-link/const.ts
1991
+ /** Symbol used to access the underlying Companion Link Protocol instance from a CompanionLinkDevice. */
1041
1992
  const PROTOCOL$1 = Symbol("com.basmilius.companion-link:protocol");
1042
1993
 
1994
+ //#endregion
1995
+ //#region src/companion-link/state.ts
1996
+ /** Default text input state when no text input session is active. */
1997
+ const DEFAULT_TEXT_INPUT = {
1998
+ isActive: false,
1999
+ documentText: "",
2000
+ isSecure: false,
2001
+ keyboardType: 0,
2002
+ autocorrection: false,
2003
+ autocapitalization: false
2004
+ };
2005
+ /**
2006
+ * Tracks the state of a Companion Link device: attention state, media controls,
2007
+ * now-playing info, supported actions, text input, and volume availability.
2008
+ * Subscribes to protocol stream events and emits typed state change events.
2009
+ */
2010
+ var CompanionLinkState = class extends EventEmitter {
2011
+ /** Current attention state of the device (active, idle, screensaver, etc.). */
2012
+ get attentionState() {
2013
+ return this.#attentionState;
2014
+ }
2015
+ /** Parsed media capabilities indicating which controls are available. */
2016
+ get mediaCapabilities() {
2017
+ return this.#mediaCapabilities;
2018
+ }
2019
+ /** Raw media control flags bitmask from the device. */
2020
+ get mediaControlFlags() {
2021
+ return this.#mediaControlFlags;
2022
+ }
2023
+ /** Current now-playing info as a key-value dictionary, or null. */
2024
+ get nowPlayingInfo() {
2025
+ return this.#nowPlayingInfo;
2026
+ }
2027
+ /** Currently supported actions dictionary, or null. */
2028
+ get supportedActions() {
2029
+ return this.#supportedActions;
2030
+ }
2031
+ /** Current text input session state. */
2032
+ get textInputState() {
2033
+ return this.#textInputState;
2034
+ }
2035
+ /** Whether volume control is currently available via the Companion Link protocol. */
2036
+ get volumeAvailable() {
2037
+ return this.#volumeAvailable;
2038
+ }
2039
+ #protocol;
2040
+ #attentionState = "unknown";
2041
+ #mediaCapabilities = parseMediaControlFlags(0);
2042
+ #mediaControlFlags = 0;
2043
+ #nowPlayingInfo = null;
2044
+ #supportedActions = null;
2045
+ #textInputState = { ...DEFAULT_TEXT_INPUT };
2046
+ #volumeAvailable = false;
2047
+ /**
2048
+ * Creates a new CompanionLinkState tracker.
2049
+ *
2050
+ * @param protocol - The Companion Link protocol instance to observe.
2051
+ */
2052
+ constructor(protocol) {
2053
+ super();
2054
+ this.#protocol = protocol;
2055
+ this.onMediaControl = this.onMediaControl.bind(this);
2056
+ this.onSystemStatus = this.onSystemStatus.bind(this);
2057
+ this.onTVSystemStatus = this.onTVSystemStatus.bind(this);
2058
+ this.onNowPlayingInfo = this.onNowPlayingInfo.bind(this);
2059
+ this.onSupportedActions = this.onSupportedActions.bind(this);
2060
+ this.onTextInputStarted = this.onTextInputStarted.bind(this);
2061
+ this.onTextInputStopped = this.onTextInputStopped.bind(this);
2062
+ }
2063
+ /** Subscribes to protocol stream events and registers interests for push notifications. */
2064
+ subscribe() {
2065
+ const stream = this.#protocol.stream;
2066
+ stream.on("_iMC", this.onMediaControl);
2067
+ stream.on("SystemStatus", this.onSystemStatus);
2068
+ stream.on("TVSystemStatus", this.onTVSystemStatus);
2069
+ stream.on("NowPlayingInfo", this.onNowPlayingInfo);
2070
+ stream.on("SupportedActions", this.onSupportedActions);
2071
+ stream.on("_tiStarted", this.onTextInputStarted);
2072
+ stream.on("_tiStopped", this.onTextInputStopped);
2073
+ this.#protocol.registerInterests(["_iMC"]);
2074
+ this.#protocol.registerInterests(["SystemStatus"]);
2075
+ this.#protocol.registerInterests(["TVSystemStatus"]);
2076
+ this.#protocol.registerInterests(["NowPlayingInfo"]);
2077
+ this.#protocol.registerInterests(["SupportedActions"]);
2078
+ }
2079
+ /** Unsubscribes from protocol stream events and deregisters interests. */
2080
+ unsubscribe() {
2081
+ const stream = this.#protocol.stream;
2082
+ if (!stream.isConnected) return;
2083
+ stream.off("_iMC", this.onMediaControl);
2084
+ stream.off("SystemStatus", this.onSystemStatus);
2085
+ stream.off("TVSystemStatus", this.onTVSystemStatus);
2086
+ stream.off("NowPlayingInfo", this.onNowPlayingInfo);
2087
+ stream.off("SupportedActions", this.onSupportedActions);
2088
+ stream.off("_tiStarted", this.onTextInputStarted);
2089
+ stream.off("_tiStopped", this.onTextInputStopped);
2090
+ try {
2091
+ this.#protocol.deregisterInterests(["_iMC"]);
2092
+ this.#protocol.deregisterInterests(["SystemStatus"]);
2093
+ this.#protocol.deregisterInterests(["TVSystemStatus"]);
2094
+ this.#protocol.deregisterInterests(["NowPlayingInfo"]);
2095
+ this.#protocol.deregisterInterests(["SupportedActions"]);
2096
+ } catch {}
2097
+ }
2098
+ /** Fetches the initial attention state and media control status from the device. */
2099
+ async fetchInitialState() {
2100
+ try {
2101
+ const state = await this.#protocol.getAttentionState();
2102
+ this.#attentionState = state;
2103
+ this.emit("attentionStateChanged", state);
2104
+ } catch (err) {
2105
+ this.#protocol.context.logger.warn("[cl-state]", "Failed to fetch initial attention state", err);
2106
+ }
2107
+ try {
2108
+ await this.#protocol.fetchMediaControlStatus();
2109
+ } catch (err) {
2110
+ this.#protocol.context.logger.warn("[cl-state]", "Failed to fetch media control status", err);
2111
+ }
2112
+ }
2113
+ /** Resets all state to initial/default values. */
2114
+ clear() {
2115
+ this.#attentionState = "unknown";
2116
+ this.#mediaCapabilities = parseMediaControlFlags(0);
2117
+ this.#mediaControlFlags = 0;
2118
+ this.#nowPlayingInfo = null;
2119
+ this.#supportedActions = null;
2120
+ this.#textInputState = { ...DEFAULT_TEXT_INPUT };
2121
+ this.#volumeAvailable = false;
2122
+ }
2123
+ /**
2124
+ * Handles media control flag updates (_iMC). Parses the flags bitmask and
2125
+ * emits events if capabilities or volume availability changed.
2126
+ *
2127
+ * @param data - The raw media control payload.
2128
+ */
2129
+ onMediaControl(data) {
2130
+ try {
2131
+ const payload = data;
2132
+ const flags = Number(payload?._mcF ?? 0);
2133
+ if (flags !== this.#mediaControlFlags) {
2134
+ this.#mediaControlFlags = flags;
2135
+ this.#mediaCapabilities = parseMediaControlFlags(flags);
2136
+ const wasVolumeAvailable = this.#volumeAvailable;
2137
+ this.#volumeAvailable = this.#mediaCapabilities.volume;
2138
+ this.emit("mediaControlFlagsChanged", flags, this.#mediaCapabilities);
2139
+ if (wasVolumeAvailable !== this.#volumeAvailable) this.emit("volumeAvailabilityChanged", this.#volumeAvailable);
2140
+ }
2141
+ } catch (err) {
2142
+ this.#protocol.context.logger.error("[cl-state]", "_iMC parse error", err);
2143
+ }
2144
+ }
2145
+ /**
2146
+ * Handles SystemStatus events (attention state changes from non-TV devices).
2147
+ *
2148
+ * @param data - The raw system status payload containing a state code.
2149
+ */
2150
+ onSystemStatus(data) {
2151
+ const state = convertAttentionState(data.state);
2152
+ if (state !== this.#attentionState) {
2153
+ this.#attentionState = state;
2154
+ this.emit("attentionStateChanged", state);
2155
+ }
2156
+ }
2157
+ /**
2158
+ * Handles TVSystemStatus events (attention state changes from Apple TV).
2159
+ *
2160
+ * @param data - The raw TV system status payload containing a state code.
2161
+ */
2162
+ onTVSystemStatus(data) {
2163
+ const state = convertAttentionState(data.state);
2164
+ if (state !== this.#attentionState) {
2165
+ this.#attentionState = state;
2166
+ this.emit("attentionStateChanged", state);
2167
+ }
2168
+ }
2169
+ /**
2170
+ * Handles NowPlayingInfo updates. Decodes the NSKeyedArchiver plist payload
2171
+ * when present, otherwise uses the raw dictionary.
2172
+ *
2173
+ * @param data - The raw now-playing info payload.
2174
+ */
2175
+ onNowPlayingInfo(data) {
2176
+ try {
2177
+ const payload = data;
2178
+ if (payload?.NowPlayingInfoKey) {
2179
+ const raw = payload.NowPlayingInfoKey;
2180
+ const buf = Buffer.from(raw);
2181
+ const plist = Plist.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
2182
+ const decoded = NSKeyedArchiver.decode(plist);
2183
+ this.#nowPlayingInfo = decoded && typeof decoded === "object" && !Array.isArray(decoded) ? decoded : null;
2184
+ } else this.#nowPlayingInfo = payload ?? null;
2185
+ this.emit("nowPlayingInfoChanged", this.#nowPlayingInfo);
2186
+ } catch (err) {
2187
+ this.#protocol.context.logger.error("[cl-state]", "NowPlayingInfo parse error", err);
2188
+ }
2189
+ }
2190
+ /**
2191
+ * Handles SupportedActions updates from the device.
2192
+ *
2193
+ * @param data - The raw supported actions payload.
2194
+ */
2195
+ onSupportedActions(data) {
2196
+ try {
2197
+ const payload = data;
2198
+ this.#supportedActions = payload ?? null;
2199
+ this.emit("supportedActionsChanged", payload ?? {});
2200
+ } catch (err) {
2201
+ this.#protocol.context.logger.error("[cl-state]", "SupportedActions parse error", err);
2202
+ }
2203
+ }
2204
+ /**
2205
+ * Handles the start of a text input session. Parses the plist payload to extract
2206
+ * document text, security mode, keyboard type, and autocorrection settings.
2207
+ *
2208
+ * @param data - The raw text input started payload.
2209
+ */
2210
+ onTextInputStarted(data) {
2211
+ try {
2212
+ const payload = data;
2213
+ let documentText = "";
2214
+ let isSecure = false;
2215
+ let keyboardType = 0;
2216
+ let autocorrection = false;
2217
+ let autocapitalization = false;
2218
+ if (payload?._tiD) {
2219
+ const plistData = Plist.parse(Buffer.from(payload._tiD).buffer);
2220
+ documentText = plistData._tiDT ?? "";
2221
+ isSecure = plistData._tiSR ?? false;
2222
+ keyboardType = plistData._tiKT ?? 0;
2223
+ autocorrection = plistData._tiAC ?? false;
2224
+ autocapitalization = plistData._tiAP ?? false;
2225
+ }
2226
+ this.#textInputState = {
2227
+ isActive: true,
2228
+ documentText,
2229
+ isSecure,
2230
+ keyboardType,
2231
+ autocorrection,
2232
+ autocapitalization
2233
+ };
2234
+ this.emit("textInputChanged", this.#textInputState);
2235
+ } catch (err) {
2236
+ this.#protocol.context.logger.error("[cl-state]", "Text input started parse error", err);
2237
+ }
2238
+ }
2239
+ /**
2240
+ * Handles the end of a text input session. Resets the text input state to defaults.
2241
+ *
2242
+ * @param _data - The raw text input stopped payload (unused).
2243
+ */
2244
+ onTextInputStopped(_data) {
2245
+ this.#textInputState = { ...DEFAULT_TEXT_INPUT };
2246
+ this.emit("textInputChanged", this.#textInputState);
2247
+ }
2248
+ };
2249
+ /**
2250
+ * Parses a media control flags bitmask into a structured MediaCapabilities object.
2251
+ *
2252
+ * @param flags - The raw bitmask from the _iMC event.
2253
+ * @returns An object indicating which media controls are available.
2254
+ */
2255
+ function parseMediaControlFlags(flags) {
2256
+ return {
2257
+ play: (flags & MediaControlFlag.Play) !== 0,
2258
+ pause: (flags & MediaControlFlag.Pause) !== 0,
2259
+ previousTrack: (flags & MediaControlFlag.PreviousTrack) !== 0,
2260
+ nextTrack: (flags & MediaControlFlag.NextTrack) !== 0,
2261
+ fastForward: (flags & MediaControlFlag.FastForward) !== 0,
2262
+ rewind: (flags & MediaControlFlag.Rewind) !== 0,
2263
+ volume: (flags & MediaControlFlag.Volume) !== 0,
2264
+ skipForward: (flags & MediaControlFlag.SkipForward) !== 0,
2265
+ skipBackward: (flags & MediaControlFlag.SkipBackward) !== 0
2266
+ };
2267
+ }
2268
+
1043
2269
  //#endregion
1044
2270
  //#region src/companion-link/device.ts
2271
+ /**
2272
+ * High-level abstraction for a Companion Link device (Apple TV).
2273
+ * Manages the OPack-based Companion Link protocol lifecycle: connect, pair-verify,
2274
+ * session setup, and provides access to HID buttons, app launching, user accounts,
2275
+ * media control, text input, touch, Siri, and system controls.
2276
+ * Requires credentials (obtained from pair-setup) to connect.
2277
+ */
1045
2278
  var device_default$1 = class extends EventEmitter {
2279
+ /** @returns The underlying Companion Link Protocol instance (accessed via symbol for internal use). */
1046
2280
  get [PROTOCOL$1]() {
1047
2281
  return this.#protocol;
1048
2282
  }
2283
+ /** The mDNS discovery result used to connect to this device. */
1049
2284
  get discoveryResult() {
1050
2285
  return this.#discoveryResult;
1051
2286
  }
2287
+ /** Updates the discovery result, e.g. when the device's address changes. */
1052
2288
  set discoveryResult(discoveryResult) {
1053
2289
  this.#discoveryResult = discoveryResult;
1054
2290
  }
2291
+ /** Whether the Companion Link stream is currently connected. */
1055
2292
  get isConnected() {
1056
2293
  return this.#protocol?.stream?.isConnected ?? false;
1057
2294
  }
2295
+ /** The state tracker for attention, media controls, now-playing, and text input. */
2296
+ get state() {
2297
+ return this.#state;
2298
+ }
2299
+ /** Current text input session state (convenience accessor). */
2300
+ get textInputState() {
2301
+ return this.#state.textInputState;
2302
+ }
1058
2303
  #credentials;
1059
2304
  #disconnect = false;
1060
2305
  #discoveryResult;
1061
2306
  #heartbeatInterval;
1062
2307
  #keys;
1063
2308
  #protocol;
2309
+ #state;
2310
+ /**
2311
+ * Creates a new CompanionLinkDevice.
2312
+ *
2313
+ * @param discoveryResult - The mDNS discovery result for the target device.
2314
+ */
1064
2315
  constructor(discoveryResult) {
1065
2316
  super();
1066
2317
  this.#discoveryResult = discoveryResult;
1067
- this.onSystemStatus = this.onSystemStatus.bind(this);
1068
- this.onTVSystemStatus = this.onTVSystemStatus.bind(this);
1069
- }
2318
+ this.onClose = this.onClose.bind(this);
2319
+ this.onError = this.onError.bind(this);
2320
+ this.onTimeout = this.onTimeout.bind(this);
2321
+ }
2322
+ /**
2323
+ * Connects to the Companion Link device, performs pair-verify, and sets up
2324
+ * all protocol sessions (system info, TVRC, touch, text input).
2325
+ * Emits 'connected' on success.
2326
+ *
2327
+ * @throws CredentialsError when no credentials are set.
2328
+ */
1070
2329
  async connect() {
1071
- if (!this.#credentials) throw new Error("Credentials are required to connect to a Companion Link device.");
2330
+ if (!this.#credentials) throw new CredentialsError("Credentials are required to connect to a Companion Link device.");
2331
+ if (this.#protocol) {
2332
+ this.#protocol.stream.off("close", this.onClose);
2333
+ this.#protocol.stream.off("error", this.onError);
2334
+ this.#protocol.stream.off("timeout", this.onTimeout);
2335
+ }
1072
2336
  this.#disconnect = false;
1073
2337
  this.#protocol = new Protocol$1(this.#discoveryResult);
1074
- this.#protocol.stream.on("close", async () => this.#onClose());
1075
- this.#protocol.stream.on("error", async (err) => this.#onError(err));
1076
- this.#protocol.stream.on("timeout", async () => this.#onTimeout());
2338
+ this.#protocol.stream.on("close", this.onClose);
2339
+ this.#protocol.stream.on("error", this.onError);
2340
+ this.#protocol.stream.on("timeout", this.onTimeout);
1077
2341
  await this.#protocol.connect();
1078
2342
  this.#keys = await this.#protocol.verify.start(this.#credentials);
1079
2343
  await this.#setup();
1080
2344
  this.emit("connected");
1081
2345
  }
2346
+ /** Gracefully disconnects from the device, clears heartbeat interval, and unsubscribes from events. */
1082
2347
  async disconnect() {
1083
2348
  this.#disconnect = true;
1084
2349
  if (this.#heartbeatInterval) {
1085
2350
  clearInterval(this.#heartbeatInterval);
1086
2351
  this.#heartbeatInterval = void 0;
1087
2352
  }
1088
- await this.#unsubscribe();
2353
+ this.#state?.unsubscribe();
1089
2354
  await this.#protocol.disconnect();
1090
2355
  }
2356
+ /** Disconnects gracefully, swallowing any errors during cleanup. */
1091
2357
  async disconnectSafely() {
1092
2358
  try {
1093
2359
  await this.disconnect();
1094
- } catch (_) {}
2360
+ } catch {}
1095
2361
  }
2362
+ /**
2363
+ * Sets the pairing credentials required for pair-verify authentication.
2364
+ * Must be called before connect().
2365
+ *
2366
+ * @param credentials - The accessory credentials from pair-setup.
2367
+ */
1096
2368
  async setCredentials(credentials) {
1097
2369
  this.#credentials = credentials;
1098
2370
  }
2371
+ /**
2372
+ * Fetches the current attention state of the device (active, idle, screensaver, etc.).
2373
+ *
2374
+ * @returns The current attention state.
2375
+ */
1099
2376
  async getAttentionState() {
1100
2377
  return await this.#protocol.getAttentionState();
1101
2378
  }
2379
+ /**
2380
+ * Fetches the list of apps that can be launched on the device.
2381
+ *
2382
+ * @returns Array of launchable app descriptors.
2383
+ */
1102
2384
  async getLaunchableApps() {
1103
2385
  return await this.#protocol.getLaunchableApps();
1104
2386
  }
2387
+ /**
2388
+ * Fetches the list of user accounts configured on the device.
2389
+ *
2390
+ * @returns Array of user account descriptors.
2391
+ */
1105
2392
  async getUserAccounts() {
1106
2393
  return await this.#protocol.getUserAccounts();
1107
2394
  }
2395
+ /**
2396
+ * Fetches the current now-playing information from the device.
2397
+ *
2398
+ * @returns The now-playing info payload.
2399
+ */
2400
+ async fetchNowPlayingInfo() {
2401
+ return await this.#protocol.fetchNowPlayingInfo();
2402
+ }
2403
+ /**
2404
+ * Fetches the currently supported actions from the device.
2405
+ *
2406
+ * @returns The supported actions payload.
2407
+ */
2408
+ async fetchSupportedActions() {
2409
+ return await this.#protocol.fetchSupportedActions();
2410
+ }
2411
+ /**
2412
+ * Fetches the current media control status (available controls bitmask).
2413
+ *
2414
+ * @returns The media control status payload.
2415
+ */
2416
+ async fetchMediaControlStatus() {
2417
+ return await this.#protocol.fetchMediaControlStatus();
2418
+ }
2419
+ /**
2420
+ * Launches an app on the device by its bundle identifier.
2421
+ *
2422
+ * @param bundleId - The bundle identifier of the app to launch.
2423
+ */
1108
2424
  async launchApp(bundleId) {
1109
2425
  await this.#protocol.launchApp(bundleId);
1110
2426
  }
2427
+ /**
2428
+ * Opens a URL on the device (universal link or app-specific URL scheme).
2429
+ *
2430
+ * @param url - The URL to open.
2431
+ */
1111
2432
  async launchUrl(url) {
1112
2433
  await this.#protocol.launchUrl(url);
1113
2434
  }
2435
+ /**
2436
+ * Sends a media control command (play, pause, next, etc.) via the Companion Link protocol.
2437
+ *
2438
+ * @param command - The media control command key.
2439
+ * @param content - Optional additional content for the command.
2440
+ */
1114
2441
  async mediaControlCommand(command, content) {
1115
2442
  await this.#protocol.mediaControlCommand(command, content);
1116
2443
  }
2444
+ /**
2445
+ * Sends a HID button press via the Companion Link protocol.
2446
+ *
2447
+ * @param command - The HID command key (e.g. 'up', 'select', 'menu').
2448
+ * @param type - Optional press type (short, long, double).
2449
+ * @param holdDelayMs - Optional hold duration in milliseconds for long presses.
2450
+ */
1117
2451
  async pressButton(command, type, holdDelayMs) {
1118
2452
  await this.#protocol.pressButton(command, type, holdDelayMs);
1119
2453
  }
2454
+ /**
2455
+ * Switches to a different user account on the device.
2456
+ *
2457
+ * @param accountId - The ID of the user account to switch to.
2458
+ */
1120
2459
  async switchUserAccount(accountId) {
1121
2460
  await this.#protocol.switchUserAccount(accountId);
1122
2461
  }
1123
- async #heartbeat() {
1124
- try {
1125
- this.#protocol.noOp();
1126
- } catch (err) {
1127
- this.#protocol.context.logger.error("Heartbeat error", err);
2462
+ /**
2463
+ * Sets the text input field to the given text, replacing any existing content.
2464
+ *
2465
+ * @param text - The text to set.
2466
+ */
2467
+ async textSet(text) {
2468
+ await this.#protocol.textInputCommand(text, true);
2469
+ }
2470
+ /**
2471
+ * Appends text to the current text input field content.
2472
+ *
2473
+ * @param text - The text to append.
2474
+ */
2475
+ async textAppend(text) {
2476
+ await this.#protocol.textInputCommand(text, false);
2477
+ }
2478
+ /** Clears the text input field. */
2479
+ async textClear() {
2480
+ await this.#protocol.textInputCommand("", true);
2481
+ }
2482
+ /**
2483
+ * Sends a raw touch event to the device.
2484
+ *
2485
+ * @param finger - Finger index (0-based).
2486
+ * @param phase - Touch phase (0 = Began, 1 = Moved, 2 = Ended).
2487
+ * @param x - Horizontal position.
2488
+ * @param y - Vertical position.
2489
+ */
2490
+ async sendTouchEvent(finger, phase, x, y) {
2491
+ await this.#protocol.sendTouchEvent(finger, phase, x, y);
2492
+ }
2493
+ /**
2494
+ * Simulates a tap at the given coordinates.
2495
+ *
2496
+ * @param x - Horizontal position (defaults to center 500).
2497
+ * @param y - Vertical position (defaults to center 500).
2498
+ */
2499
+ async tap(x = 500, y = 500) {
2500
+ await this.sendTouchEvent(0, 0, x, y);
2501
+ await waitFor(50);
2502
+ await this.sendTouchEvent(0, 2, x, y);
2503
+ }
2504
+ /**
2505
+ * Simulates a swipe gesture in the given direction.
2506
+ *
2507
+ * @param direction - Swipe direction.
2508
+ * @param duration - Swipe duration in milliseconds (defaults to 200).
2509
+ */
2510
+ async swipe(direction, duration = 200) {
2511
+ const [startX, startY, endX, endY] = {
2512
+ up: [
2513
+ 500,
2514
+ 700,
2515
+ 500,
2516
+ 300
2517
+ ],
2518
+ down: [
2519
+ 500,
2520
+ 300,
2521
+ 500,
2522
+ 700
2523
+ ],
2524
+ left: [
2525
+ 700,
2526
+ 500,
2527
+ 300,
2528
+ 500
2529
+ ],
2530
+ right: [
2531
+ 300,
2532
+ 500,
2533
+ 700,
2534
+ 500
2535
+ ]
2536
+ }[direction];
2537
+ const steps = Math.max(4, Math.floor(duration / 50));
2538
+ const deltaX = (endX - startX) / steps;
2539
+ const deltaY = (endY - startY) / steps;
2540
+ const stepDuration = duration / steps;
2541
+ await this.sendTouchEvent(0, 0, startX, startY);
2542
+ for (let i = 1; i < steps; i++) {
2543
+ await waitFor(stepDuration);
2544
+ await this.sendTouchEvent(0, 1, Math.round(startX + deltaX * i), Math.round(startY + deltaY * i));
1128
2545
  }
1129
- }
1130
- async #onClose() {
1131
- this.#protocol.context.logger.net("#onClose() called on companion link device.");
1132
- if (!this.#disconnect) {
1133
- await this.disconnectSafely();
1134
- this.emit("disconnected", true);
1135
- } else this.emit("disconnected", false);
1136
- }
1137
- async #onError(err) {
1138
- this.#protocol.context.logger.error("Companion Link error", err);
1139
- }
1140
- async #onTimeout() {
1141
- this.#protocol.context.logger.error("Companion Link timeout");
1142
- await this.#protocol.stream.destroy();
1143
- }
2546
+ await waitFor(stepDuration);
2547
+ await this.sendTouchEvent(0, 2, endX, endY);
2548
+ }
2549
+ /** Toggles closed captions on the device. */
2550
+ async toggleCaptions() {
2551
+ await this.#protocol.toggleCaptions();
2552
+ }
2553
+ /**
2554
+ * Toggles the system appearance between light and dark mode.
2555
+ *
2556
+ * @param light - True for light mode, false for dark mode.
2557
+ */
2558
+ async toggleSystemAppearance(light) {
2559
+ await this.#protocol.toggleSystemAppearance(light);
2560
+ }
2561
+ /**
2562
+ * Enables or disables the "Reduce Loud Sounds" setting.
2563
+ *
2564
+ * @param enabled - Whether to enable the setting.
2565
+ */
2566
+ async toggleReduceLoudSounds(enabled) {
2567
+ await this.#protocol.toggleReduceLoudSounds(enabled);
2568
+ }
2569
+ /**
2570
+ * Enables or disables finding mode (Find My integration).
2571
+ *
2572
+ * @param enabled - Whether to enable finding mode.
2573
+ */
2574
+ async toggleFindingMode(enabled) {
2575
+ await this.#protocol.toggleFindingMode(enabled);
2576
+ }
2577
+ /**
2578
+ * Fetches the "Up Next" queue from the device.
2579
+ *
2580
+ * @param paginationToken - Optional token for paginated results.
2581
+ * @returns The Up Next queue payload.
2582
+ */
2583
+ async fetchUpNext(paginationToken) {
2584
+ return await this.#protocol.fetchUpNext(paginationToken);
2585
+ }
2586
+ /**
2587
+ * Adds an item to the "Up Next" queue.
2588
+ *
2589
+ * @param identifier - Content item identifier.
2590
+ * @param kind - Content kind descriptor.
2591
+ */
2592
+ async addToUpNext(identifier, kind) {
2593
+ await this.#protocol.addToUpNext(identifier, kind);
2594
+ }
2595
+ /**
2596
+ * Removes an item from the "Up Next" queue.
2597
+ *
2598
+ * @param identifier - Content item identifier.
2599
+ * @param kind - Content kind descriptor.
2600
+ */
2601
+ async removeFromUpNext(identifier, kind) {
2602
+ await this.#protocol.removeFromUpNext(identifier, kind);
2603
+ }
2604
+ /**
2605
+ * Marks a content item as watched.
2606
+ *
2607
+ * @param identifier - Content item identifier.
2608
+ * @param kind - Content kind descriptor.
2609
+ */
2610
+ async markAsWatched(identifier, kind) {
2611
+ await this.#protocol.markAsWatched(identifier, kind);
2612
+ }
2613
+ /** Starts a Siri session on the device. */
2614
+ async siriStart() {
2615
+ await this.#protocol.siriStart();
2616
+ }
2617
+ /** Stops the active Siri session on the device. */
2618
+ async siriStop() {
2619
+ await this.#protocol.siriStop();
2620
+ }
2621
+ /**
2622
+ * Sets up encryption, protocol sessions (system info, TVRC, touch, text input),
2623
+ * heartbeat interval, and state tracking. Called after successful pair-verify.
2624
+ */
1144
2625
  async #setup() {
1145
2626
  const keys = this.#keys;
1146
2627
  this.#protocol.stream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
@@ -1150,102 +2631,145 @@ var device_default$1 = class extends EventEmitter {
1150
2631
  await this.#protocol.tvrcSessionStart();
1151
2632
  await this.#protocol.touchStart();
1152
2633
  await this.#protocol.tiStart();
1153
- await this.#protocol.subscribe("_iMC", (data) => {
1154
- this.emit("mediaControl", data);
1155
- });
1156
- this.#heartbeatInterval = setInterval(async () => await this.#heartbeat(), 15e3);
1157
- await this.#subscribe();
2634
+ this.#heartbeatInterval = setInterval(() => {
2635
+ try {
2636
+ this.#protocol.noOp();
2637
+ } catch (err) {
2638
+ this.#protocol.context.logger.error("Heartbeat failed", err);
2639
+ }
2640
+ }, 15e3);
2641
+ if (this.#state) this.#state.removeAllListeners();
2642
+ this.#state = new CompanionLinkState(this.#protocol);
2643
+ this.#state.on("attentionStateChanged", (s) => this.emit("attentionStateChanged", s));
2644
+ this.#state.on("mediaControlFlagsChanged", (f, c) => this.emit("mediaControlFlagsChanged", f, c));
2645
+ this.#state.on("nowPlayingInfoChanged", (i) => this.emit("nowPlayingInfoChanged", i));
2646
+ this.#state.on("supportedActionsChanged", (a) => this.emit("supportedActionsChanged", a));
2647
+ this.#state.on("textInputChanged", (s) => this.emit("textInputChanged", s));
2648
+ this.#state.on("volumeAvailabilityChanged", (a) => this.emit("volumeAvailabilityChanged", a));
2649
+ this.#state.subscribe();
2650
+ await this.#state.fetchInitialState();
1158
2651
  } catch (err) {
1159
2652
  clearInterval(this.#heartbeatInterval);
1160
2653
  this.#heartbeatInterval = void 0;
1161
2654
  throw err;
1162
2655
  }
1163
2656
  }
1164
- async #subscribe() {
1165
- await this.#protocol.subscribe("SystemStatus", this.onSystemStatus);
1166
- await this.#protocol.subscribe("TVSystemStatus", this.onTVSystemStatus);
1167
- const state = await this.getAttentionState();
1168
- this.emit("power", state);
1169
- }
1170
- async #unsubscribe() {
1171
- try {
1172
- await this.#protocol.unsubscribe("SystemStatus", this.onSystemStatus);
1173
- await this.#protocol.unsubscribe("TVSystemStatus", this.onTVSystemStatus);
1174
- } catch (_) {}
2657
+ /** Handles the stream close event. Emits 'disconnected' with unexpected=true if not intentional. */
2658
+ onClose() {
2659
+ this.#protocol.context.logger.net("onClose() called on companion link device.");
2660
+ if (!this.#disconnect) {
2661
+ this.disconnectSafely();
2662
+ this.emit("disconnected", true);
2663
+ } else this.emit("disconnected", false);
1175
2664
  }
1176
- async onSystemStatus(data) {
1177
- this.#protocol.context.logger.info("System Status", data);
1178
- this.emit("power", convertAttentionState(data.state));
2665
+ /**
2666
+ * Handles stream error events by logging them.
2667
+ *
2668
+ * @param err - The error that occurred.
2669
+ */
2670
+ onError(err) {
2671
+ this.#protocol.context.logger.error("Companion Link error", err);
1179
2672
  }
1180
- async onTVSystemStatus(data) {
1181
- this.#protocol.context.logger.info("TV System Status", data);
1182
- this.emit("power", convertAttentionState(data.state));
2673
+ /** Handles stream timeout events by destroying the stream. */
2674
+ onTimeout() {
2675
+ this.#protocol.context.logger.error("Companion Link timeout");
2676
+ this.#protocol.stream.destroy();
1183
2677
  }
1184
2678
  };
1185
2679
 
1186
2680
  //#endregion
1187
2681
  //#region src/model/apple-tv.ts
2682
+ /**
2683
+ * High-level Apple TV device model combining AirPlay (remote control, media, streaming)
2684
+ * and Companion Link (apps, accounts, power, text input) protocols.
2685
+ * Provides a unified interface for controlling an Apple TV.
2686
+ */
1188
2687
  var apple_tv_default = class extends EventEmitter {
2688
+ /** The underlying AirPlay device for direct protocol access. */
1189
2689
  get airplay() {
1190
2690
  return this.#airplay;
1191
2691
  }
2692
+ /** The underlying Companion Link device for direct protocol access. */
1192
2693
  get companionLink() {
1193
2694
  return this.#companionLink;
1194
2695
  }
2696
+ /** AirPlay remote controller for HID keys and commands. */
1195
2697
  get remote() {
1196
2698
  return this.#airplay.remote;
1197
2699
  }
2700
+ /** AirPlay state tracker for now-playing, volume, and keyboard. */
1198
2701
  get state() {
1199
2702
  return this.#airplay.state;
1200
2703
  }
2704
+ /** AirPlay volume controller. */
1201
2705
  get volumeControl() {
1202
2706
  return this.#airplay.volume;
1203
2707
  }
2708
+ /** Bundle identifier of the currently playing app, or null. */
1204
2709
  get bundleIdentifier() {
1205
2710
  return this.#nowPlayingClient?.bundleIdentifier ?? null;
1206
2711
  }
2712
+ /** Display name of the currently playing app, or null. */
1207
2713
  get displayName() {
1208
2714
  return this.#nowPlayingClient?.displayName ?? null;
1209
2715
  }
2716
+ /** Whether both AirPlay and Companion Link are connected. */
1210
2717
  get isConnected() {
1211
2718
  return this.#airplay.isConnected && this.#companionLink.isConnected;
1212
2719
  }
2720
+ /** Whether the active player is currently playing. */
1213
2721
  get isPlaying() {
1214
2722
  return this.#nowPlayingClient?.isPlaying ?? false;
1215
2723
  }
2724
+ /** Current track title. */
1216
2725
  get title() {
1217
2726
  return this.#nowPlayingClient?.title ?? "";
1218
2727
  }
2728
+ /** Current track artist. */
1219
2729
  get artist() {
1220
2730
  return this.#nowPlayingClient?.artist ?? "";
1221
2731
  }
2732
+ /** Current track album. */
1222
2733
  get album() {
1223
2734
  return this.#nowPlayingClient?.album ?? "";
1224
2735
  }
2736
+ /** Duration of the current track in seconds. */
1225
2737
  get duration() {
1226
2738
  return this.#nowPlayingClient?.duration ?? 0;
1227
2739
  }
2740
+ /** Extrapolated elapsed time in seconds. */
1228
2741
  get elapsedTime() {
1229
2742
  return this.#nowPlayingClient?.elapsedTime ?? 0;
1230
2743
  }
2744
+ /** Current playback queue from the active player. */
1231
2745
  get playbackQueue() {
1232
2746
  return this.#nowPlayingClient?.playbackQueue ?? null;
1233
2747
  }
2748
+ /** Current playback state. */
1234
2749
  get playbackState() {
1235
2750
  return this.#nowPlayingClient?.playbackState ?? AirPlay.Proto.PlaybackState_Enum.Unknown;
1236
2751
  }
2752
+ /** Timestamp of the last playback state update. */
1237
2753
  get playbackStateTimestamp() {
1238
2754
  return this.#nowPlayingClient?.playbackStateTimestamp ?? -1;
1239
2755
  }
2756
+ /** Current volume level (0.0 - 1.0). */
1240
2757
  get volume() {
1241
2758
  return this.#airplay.state.volume ?? 0;
1242
2759
  }
2760
+ /** @returns The currently active now-playing client, or null. */
1243
2761
  get #nowPlayingClient() {
1244
2762
  return this.#airplay.state.nowPlayingClient;
1245
2763
  }
1246
2764
  #airplay;
1247
2765
  #companionLink;
1248
2766
  #disconnect = false;
2767
+ /**
2768
+ * Creates a new AppleTV instance.
2769
+ *
2770
+ * @param airplayDiscoveryResult - The mDNS discovery result for the AirPlay service.
2771
+ * @param companionLinkDiscoveryResult - The mDNS discovery result for the Companion Link service.
2772
+ */
1249
2773
  constructor(airplayDiscoveryResult, companionLinkDiscoveryResult) {
1250
2774
  super();
1251
2775
  this.#airplay = new device_default(airplayDiscoveryResult);
@@ -1254,81 +2778,170 @@ var apple_tv_default = class extends EventEmitter {
1254
2778
  this.#airplay.on("disconnected", (unexpected) => this.#onDisconnected(unexpected));
1255
2779
  this.#companionLink.on("connected", () => this.#onConnected());
1256
2780
  this.#companionLink.on("disconnected", (unexpected) => this.#onDisconnected(unexpected));
1257
- this.#companionLink.on("power", (state) => this.emit("power", state));
1258
- }
2781
+ this.#companionLink.on("attentionStateChanged", (state) => this.emit("power", state));
2782
+ this.#companionLink.on("textInputChanged", (state) => this.emit("textInput", state));
2783
+ }
2784
+ /**
2785
+ * Connects both AirPlay and Companion Link protocols.
2786
+ * If Companion Link fails, AirPlay is disconnected to maintain consistency.
2787
+ *
2788
+ * @param airplayCredentials - Credentials for the AirPlay service.
2789
+ * @param companionLinkCredentials - Optional separate credentials for Companion Link (defaults to AirPlay credentials).
2790
+ */
1259
2791
  async connect(airplayCredentials, companionLinkCredentials) {
1260
2792
  this.#airplay.setCredentials(airplayCredentials);
1261
2793
  await this.#companionLink.setCredentials(companionLinkCredentials ?? airplayCredentials);
1262
2794
  await this.#airplay.connect();
1263
- await this.#companionLink.connect();
2795
+ try {
2796
+ await this.#companionLink.connect();
2797
+ } catch (err) {
2798
+ this.#airplay.disconnectSafely();
2799
+ throw err;
2800
+ }
1264
2801
  this.#disconnect = false;
1265
2802
  }
2803
+ /** Disconnects both AirPlay and Companion Link protocols. */
1266
2804
  async disconnect() {
1267
2805
  await this.#airplay.disconnect();
1268
2806
  await this.#companionLink.disconnect();
1269
2807
  }
2808
+ /** Puts the Apple TV to sleep via a suspend HID key press. */
1270
2809
  async turnOff() {
1271
2810
  await this.#airplay.remote.suspend();
1272
2811
  }
2812
+ /** Wakes the Apple TV from sleep via a wake HID key press. */
1273
2813
  async turnOn() {
1274
2814
  await this.#airplay.remote.wake();
1275
2815
  }
2816
+ /** Sends a Pause command. */
1276
2817
  async pause() {
1277
2818
  await this.#airplay.sendCommand(AirPlay.Proto.Command.Pause);
1278
2819
  }
2820
+ /** Sends a TogglePlayPause command. */
1279
2821
  async playPause() {
1280
2822
  await this.#airplay.sendCommand(AirPlay.Proto.Command.TogglePlayPause);
1281
2823
  }
2824
+ /** Sends a Play command. */
1282
2825
  async play() {
1283
2826
  await this.#airplay.sendCommand(AirPlay.Proto.Command.Play);
1284
2827
  }
2828
+ /** Sends a Stop command. */
1285
2829
  async stop() {
1286
2830
  await this.#airplay.sendCommand(AirPlay.Proto.Command.Stop);
1287
2831
  }
2832
+ /** Sends a NextInContext command (next track/episode). */
1288
2833
  async next() {
1289
2834
  await this.#airplay.sendCommand(AirPlay.Proto.Command.NextInContext);
1290
2835
  }
2836
+ /** Sends a PreviousInContext command (previous track/episode). */
1291
2837
  async previous() {
1292
2838
  await this.#airplay.sendCommand(AirPlay.Proto.Command.PreviousInContext);
1293
2839
  }
2840
+ /** Decreases volume via HID volume down key. */
1294
2841
  async volumeDown() {
1295
2842
  await this.#airplay.remote.volumeDown();
1296
2843
  }
2844
+ /** Toggles mute via HID mute key. */
1297
2845
  async volumeMute() {
1298
2846
  await this.#airplay.remote.mute();
1299
2847
  }
2848
+ /** Increases volume via HID volume up key. */
1300
2849
  async volumeUp() {
1301
2850
  await this.#airplay.remote.volumeUp();
1302
2851
  }
2852
+ /**
2853
+ * Fetches the current attention state via Companion Link.
2854
+ *
2855
+ * @returns The current attention state.
2856
+ */
1303
2857
  async getAttentionState() {
1304
2858
  return await this.#companionLink.getAttentionState();
1305
2859
  }
2860
+ /**
2861
+ * Fetches the list of launchable apps via Companion Link.
2862
+ *
2863
+ * @returns Array of launchable app descriptors.
2864
+ */
1306
2865
  async getLaunchableApps() {
1307
2866
  return await this.#companionLink.getLaunchableApps();
1308
2867
  }
2868
+ /**
2869
+ * Fetches user accounts configured on the device via Companion Link.
2870
+ *
2871
+ * @returns Array of user account descriptors.
2872
+ */
1309
2873
  async getUserAccounts() {
1310
2874
  return await this.#companionLink.getUserAccounts();
1311
2875
  }
2876
+ /**
2877
+ * Launches an app via Companion Link.
2878
+ *
2879
+ * @param bundleId - The bundle identifier of the app to launch.
2880
+ */
1312
2881
  async launchApp(bundleId) {
1313
2882
  await this.#companionLink.launchApp(bundleId);
1314
2883
  }
2884
+ /**
2885
+ * Switches user account via Companion Link.
2886
+ *
2887
+ * @param accountId - The ID of the user account to switch to.
2888
+ */
1315
2889
  async switchUserAccount(accountId) {
1316
2890
  await this.#companionLink.switchUserAccount(accountId);
1317
2891
  }
2892
+ /**
2893
+ * Sets the text input field to the given text via Companion Link.
2894
+ *
2895
+ * @param text - The text to set.
2896
+ */
2897
+ async textSet(text) {
2898
+ await this.#companionLink.textSet(text);
2899
+ }
2900
+ /**
2901
+ * Appends text to the text input field via Companion Link.
2902
+ *
2903
+ * @param text - The text to append.
2904
+ */
2905
+ async textAppend(text) {
2906
+ await this.#companionLink.textAppend(text);
2907
+ }
2908
+ /** Clears the text input field via Companion Link. */
2909
+ async textClear() {
2910
+ await this.#companionLink.textClear();
2911
+ }
2912
+ /**
2913
+ * Gets the CommandInfo for a specific command from the active player.
2914
+ *
2915
+ * @param command - The command to look up.
2916
+ * @returns The command info, or null if no client is active or command not found.
2917
+ */
1318
2918
  async getCommandInfo(command) {
1319
2919
  const client = this.#airplay.state.nowPlayingClient;
1320
2920
  if (!client) return null;
1321
2921
  return client.supportedCommands.find((c) => c.command === command) ?? null;
1322
2922
  }
2923
+ /**
2924
+ * Checks whether a command is supported and enabled by the active player.
2925
+ *
2926
+ * @param command - The command to check.
2927
+ * @returns True if supported and enabled, false otherwise.
2928
+ */
1323
2929
  async isCommandSupported(command) {
1324
2930
  const client = this.#airplay.state.nowPlayingClient;
1325
2931
  if (!client) return false;
1326
2932
  return client.isCommandSupported(command);
1327
2933
  }
2934
+ /** Emits 'connected' when both AirPlay and Companion Link are connected. */
1328
2935
  async #onConnected() {
1329
2936
  if (!this.#airplay.isConnected || !this.#companionLink.isConnected) return;
1330
2937
  this.emit("connected");
1331
2938
  }
2939
+ /**
2940
+ * Handles disconnection from either protocol. Disconnects both sides
2941
+ * and emits 'disconnected'. Only fires once per disconnect cycle.
2942
+ *
2943
+ * @param unexpected - Whether the disconnection was unexpected.
2944
+ */
1332
2945
  async #onDisconnected(unexpected) {
1333
2946
  if (this.#disconnect) return;
1334
2947
  this.#disconnect = true;
@@ -1339,110 +2952,187 @@ var apple_tv_default = class extends EventEmitter {
1339
2952
 
1340
2953
  //#endregion
1341
2954
  //#region src/model/homepod-base.ts
2955
+ /**
2956
+ * Abstract base class for HomePod models (HomePod and HomePod Mini).
2957
+ * Uses AirPlay only (no Companion Link). Supports transient pairing, media control,
2958
+ * URL playback, and audio streaming.
2959
+ */
1342
2960
  var homepod_base_default = class extends EventEmitter {
2961
+ /** The underlying AirPlay device for direct protocol access. */
1343
2962
  get airplay() {
1344
2963
  return this.#airplay;
1345
2964
  }
2965
+ /** AirPlay remote controller for HID keys and commands. */
1346
2966
  get remote() {
1347
2967
  return this.#airplay.remote;
1348
2968
  }
2969
+ /** AirPlay state tracker for now-playing and volume. */
1349
2970
  get state() {
1350
2971
  return this.#airplay.state;
1351
2972
  }
2973
+ /** AirPlay volume controller. */
1352
2974
  get volumeControl() {
1353
2975
  return this.#airplay.volume;
1354
2976
  }
2977
+ /** Bundle identifier of the currently playing app, or null. */
1355
2978
  get bundleIdentifier() {
1356
2979
  return this.#nowPlayingClient?.bundleIdentifier ?? null;
1357
2980
  }
2981
+ /** Display name of the currently playing app, or null. */
1358
2982
  get displayName() {
1359
2983
  return this.#nowPlayingClient?.displayName ?? null;
1360
2984
  }
2985
+ /** Whether the AirPlay connection is active. */
1361
2986
  get isConnected() {
1362
2987
  return this.#airplay.isConnected;
1363
2988
  }
2989
+ /** Whether the active player is currently playing. */
1364
2990
  get isPlaying() {
1365
2991
  return this.#nowPlayingClient?.isPlaying ?? false;
1366
2992
  }
2993
+ /** Current track title. */
1367
2994
  get title() {
1368
2995
  return this.#nowPlayingClient?.title ?? "";
1369
2996
  }
2997
+ /** Current track artist. */
1370
2998
  get artist() {
1371
2999
  return this.#nowPlayingClient?.artist ?? "";
1372
3000
  }
3001
+ /** Current track album. */
1373
3002
  get album() {
1374
3003
  return this.#nowPlayingClient?.album ?? "";
1375
3004
  }
3005
+ /** Duration of the current track in seconds. */
1376
3006
  get duration() {
1377
3007
  return this.#nowPlayingClient?.duration ?? 0;
1378
3008
  }
3009
+ /** Extrapolated elapsed time in seconds. */
1379
3010
  get elapsedTime() {
1380
3011
  return this.#nowPlayingClient?.elapsedTime ?? 0;
1381
3012
  }
3013
+ /** Current playback queue from the active player. */
1382
3014
  get playbackQueue() {
1383
3015
  return this.#nowPlayingClient?.playbackQueue ?? null;
1384
3016
  }
3017
+ /** Current playback state. */
1385
3018
  get playbackState() {
1386
3019
  return this.#nowPlayingClient?.playbackState ?? AirPlay.Proto.PlaybackState_Enum.Unknown;
1387
3020
  }
3021
+ /** Timestamp of the last playback state update. */
1388
3022
  get playbackStateTimestamp() {
1389
3023
  return this.#nowPlayingClient?.playbackStateTimestamp ?? -1;
1390
3024
  }
3025
+ /** Current volume level (0.0 - 1.0). */
1391
3026
  get volume() {
1392
3027
  return this.#airplay.state.volume ?? 0;
1393
3028
  }
3029
+ /** @returns The currently active now-playing client, or null. */
1394
3030
  get #nowPlayingClient() {
1395
3031
  return this.#airplay.state.nowPlayingClient;
1396
3032
  }
1397
3033
  #airplay;
1398
3034
  #disconnect = false;
3035
+ /**
3036
+ * Creates a new HomePod base instance.
3037
+ *
3038
+ * @param discoveryResult - The mDNS discovery result for the AirPlay service.
3039
+ */
1399
3040
  constructor(discoveryResult) {
1400
3041
  super();
1401
3042
  this.#airplay = new device_default(discoveryResult);
1402
3043
  this.#airplay.on("connected", () => this.#onConnected());
1403
3044
  this.#airplay.on("disconnected", (unexpected) => this.#onDisconnected(unexpected));
1404
3045
  }
3046
+ /** Connects to the HomePod via AirPlay (transient pairing). */
1405
3047
  async connect() {
1406
3048
  await this.#airplay.connect();
1407
3049
  this.#disconnect = false;
1408
3050
  }
3051
+ /** Disconnects from the HomePod. */
1409
3052
  async disconnect() {
1410
3053
  await this.#airplay.disconnect();
1411
3054
  }
3055
+ /** Sends a Pause command. */
1412
3056
  async pause() {
1413
3057
  await this.#airplay.sendCommand(AirPlay.Proto.Command.Pause);
1414
3058
  }
3059
+ /** Sends a TogglePlayPause command. */
1415
3060
  async playPause() {
1416
3061
  await this.#airplay.sendCommand(AirPlay.Proto.Command.TogglePlayPause);
1417
3062
  }
3063
+ /** Sends a Play command. */
1418
3064
  async play() {
1419
3065
  await this.#airplay.sendCommand(AirPlay.Proto.Command.Play);
1420
3066
  }
3067
+ /** Sends a Stop command. */
1421
3068
  async stop() {
1422
3069
  await this.#airplay.sendCommand(AirPlay.Proto.Command.Stop);
1423
3070
  }
3071
+ /** Sends a NextInContext command (next track). */
1424
3072
  async next() {
1425
3073
  await this.#airplay.sendCommand(AirPlay.Proto.Command.NextInContext);
1426
3074
  }
3075
+ /** Sends a PreviousInContext command (previous track). */
1427
3076
  async previous() {
1428
3077
  await this.#airplay.sendCommand(AirPlay.Proto.Command.PreviousInContext);
1429
3078
  }
3079
+ /**
3080
+ * Plays a URL on the HomePod (the device fetches and plays the content).
3081
+ *
3082
+ * @param url - The media URL to play.
3083
+ * @param position - Start position in seconds (defaults to 0).
3084
+ */
3085
+ async playUrl(url, position = 0) {
3086
+ await this.#airplay.playUrl(url, position);
3087
+ }
3088
+ /** Waits for the current URL playback to finish. */
3089
+ async waitForPlaybackEnd() {
3090
+ await this.#airplay.waitForPlaybackEnd();
3091
+ }
3092
+ /** Stops the current URL playback and cleans up. */
3093
+ stopPlayUrl() {
3094
+ this.#airplay.stopPlayUrl();
3095
+ }
3096
+ /**
3097
+ * Streams audio from a source to the HomePod via RAOP/RTP.
3098
+ *
3099
+ * @param source - The audio source to stream.
3100
+ */
1430
3101
  async streamAudio(source) {
1431
3102
  await this.#airplay.streamAudio(source);
1432
3103
  }
3104
+ /**
3105
+ * Gets the CommandInfo for a specific command from the active player.
3106
+ *
3107
+ * @param command - The command to look up.
3108
+ * @returns The command info, or null if no client is active or command not found.
3109
+ */
1433
3110
  async getCommandInfo(command) {
1434
3111
  const client = this.#airplay.state.nowPlayingClient;
1435
3112
  if (!client) return null;
1436
3113
  return client.supportedCommands.find((c) => c.command === command) ?? null;
1437
3114
  }
3115
+ /**
3116
+ * Checks whether a command is supported and enabled by the active player.
3117
+ *
3118
+ * @param command - The command to check.
3119
+ * @returns True if supported and enabled, false otherwise.
3120
+ */
1438
3121
  async isCommandSupported(command) {
1439
3122
  const client = this.#airplay.state.nowPlayingClient;
1440
3123
  if (!client) return false;
1441
3124
  return client.isCommandSupported(command);
1442
3125
  }
3126
+ /** Emits 'connected' when the AirPlay connection is established. */
1443
3127
  async #onConnected() {
1444
3128
  this.emit("connected");
1445
3129
  }
3130
+ /**
3131
+ * Handles disconnection. Disconnects and emits 'disconnected'.
3132
+ * Only fires once per disconnect cycle.
3133
+ *
3134
+ * @param unexpected - Whether the disconnection was unexpected.
3135
+ */
1446
3136
  async #onDisconnected(unexpected) {
1447
3137
  if (this.#disconnect) return;
1448
3138
  this.#disconnect = true;
@@ -1453,10 +3143,18 @@ var homepod_base_default = class extends EventEmitter {
1453
3143
 
1454
3144
  //#endregion
1455
3145
  //#region src/model/homepod.ts
3146
+ /**
3147
+ * HomePod device model. AirPlay only (no Companion Link).
3148
+ * Supports media control, URL playback, audio streaming, and volume control.
3149
+ */
1456
3150
  var homepod_default = class extends homepod_base_default {};
1457
3151
 
1458
3152
  //#endregion
1459
3153
  //#region src/model/homepod-mini.ts
3154
+ /**
3155
+ * HomePod Mini device model. AirPlay only (no Companion Link).
3156
+ * Identical feature set to HomePod, distinguished for device model identification.
3157
+ */
1460
3158
  var homepod_mini_default = class extends homepod_base_default {};
1461
3159
 
1462
3160
  //#endregion