@basmilius/apple-devices 0.9.19 → 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.
- package/dist/index.d.mts +1168 -15
- package/dist/index.mjs +1660 -190
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,90 +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
4
|
import { CommandError, CredentialsError, waitFor } from "@basmilius/apple-common";
|
|
5
|
-
import { Protocol as Protocol$1, convertAttentionState } from "@basmilius/apple-companion-link";
|
|
6
|
-
import { Plist } from "@basmilius/apple-encoding";
|
|
5
|
+
import { MediaControlFlag, Protocol as Protocol$1, convertAttentionState } from "@basmilius/apple-companion-link";
|
|
6
|
+
import { NSKeyedArchiver, Plist } from "@basmilius/apple-encoding";
|
|
7
7
|
|
|
8
8
|
//#region src/airplay/player.ts
|
|
9
|
+
/** Offset in seconds between the Cocoa epoch (2001-01-01) and the Unix epoch (1970-01-01). */
|
|
9
10
|
const COCOA_EPOCH_OFFSET = 978307200;
|
|
11
|
+
/** Default player identifier used by the Apple TV when no specific player is active. */
|
|
10
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
|
+
*/
|
|
11
22
|
const extrapolateElapsed = (elapsed, cocoaTimestamp, rate) => {
|
|
12
23
|
if (!rate) return elapsed;
|
|
13
24
|
const timestampUnix = cocoaTimestamp + COCOA_EPOCH_OFFSET;
|
|
14
25
|
const delta = (Date.now() / 1e3 - timestampUnix) * rate;
|
|
15
26
|
return Math.max(0, elapsed + delta);
|
|
16
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
|
+
*/
|
|
17
34
|
var Player = class {
|
|
35
|
+
/** Unique identifier for this player (e.g. a player path). */
|
|
18
36
|
get identifier() {
|
|
19
37
|
return this.#identifier;
|
|
20
38
|
}
|
|
39
|
+
/** Human-readable display name for this player. */
|
|
21
40
|
get displayName() {
|
|
22
41
|
return this.#displayName;
|
|
23
42
|
}
|
|
43
|
+
/** Whether this is the default fallback player (MediaRemote-DefaultPlayer). */
|
|
24
44
|
get isDefaultPlayer() {
|
|
25
45
|
return this.#identifier === DEFAULT_PLAYER_ID;
|
|
26
46
|
}
|
|
47
|
+
/** Raw now-playing info from the Apple TV, or null if unavailable. */
|
|
27
48
|
get nowPlayingInfo() {
|
|
28
49
|
return this.#nowPlayingInfo;
|
|
29
50
|
}
|
|
51
|
+
/** Current playback queue, or null if unavailable. */
|
|
30
52
|
get playbackQueue() {
|
|
31
53
|
return this.#playbackQueue;
|
|
32
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
|
+
*/
|
|
33
59
|
get playbackState() {
|
|
34
60
|
if (this.#playbackState === Proto.PlaybackState_Enum.Playing && this.playbackRate === 0) return Proto.PlaybackState_Enum.Paused;
|
|
35
61
|
return this.#playbackState;
|
|
36
62
|
}
|
|
63
|
+
/** The raw playback state as reported by the Apple TV, without corrections. */
|
|
37
64
|
get rawPlaybackState() {
|
|
38
65
|
return this.#playbackState;
|
|
39
66
|
}
|
|
67
|
+
/** Timestamp of the last playback state update, used to discard stale updates. */
|
|
40
68
|
get playbackStateTimestamp() {
|
|
41
69
|
return this.#playbackStateTimestamp;
|
|
42
70
|
}
|
|
71
|
+
/** List of commands supported by this player. */
|
|
43
72
|
get supportedCommands() {
|
|
44
73
|
return this.#supportedCommands;
|
|
45
74
|
}
|
|
75
|
+
/** Current track title from NowPlayingInfo or content item metadata. */
|
|
46
76
|
get title() {
|
|
47
77
|
return this.#nowPlayingInfo?.title || this.currentItemMetadata?.title || "";
|
|
48
78
|
}
|
|
79
|
+
/** Current track artist from NowPlayingInfo or content item metadata. */
|
|
49
80
|
get artist() {
|
|
50
81
|
return this.#nowPlayingInfo?.artist || this.currentItemMetadata?.trackArtistName || "";
|
|
51
82
|
}
|
|
83
|
+
/** Current track album from NowPlayingInfo or content item metadata. */
|
|
52
84
|
get album() {
|
|
53
85
|
return this.#nowPlayingInfo?.album || this.currentItemMetadata?.albumName || "";
|
|
54
86
|
}
|
|
87
|
+
/** Genre of the current content item. */
|
|
55
88
|
get genre() {
|
|
56
89
|
return this.currentItemMetadata?.genre || "";
|
|
57
90
|
}
|
|
91
|
+
/** Series name for TV show content. */
|
|
58
92
|
get seriesName() {
|
|
59
93
|
return this.currentItemMetadata?.seriesName || "";
|
|
60
94
|
}
|
|
95
|
+
/** Season number for TV show content, or 0 if not applicable. */
|
|
61
96
|
get seasonNumber() {
|
|
62
97
|
return this.currentItemMetadata?.seasonNumber || 0;
|
|
63
98
|
}
|
|
99
|
+
/** Episode number for TV show content, or 0 if not applicable. */
|
|
64
100
|
get episodeNumber() {
|
|
65
101
|
return this.currentItemMetadata?.episodeNumber || 0;
|
|
66
102
|
}
|
|
103
|
+
/** Media type of the current content item (music, video, etc.). */
|
|
67
104
|
get mediaType() {
|
|
68
105
|
return this.currentItemMetadata?.mediaType ?? Proto.ContentItemMetadata_MediaType.UnknownMediaType;
|
|
69
106
|
}
|
|
107
|
+
/** Unique content identifier for the current item (e.g. iTunes store ID). */
|
|
70
108
|
get contentIdentifier() {
|
|
71
109
|
return this.currentItemMetadata?.contentIdentifier || "";
|
|
72
110
|
}
|
|
111
|
+
/** Duration of the current track in seconds, from NowPlayingInfo or metadata. */
|
|
73
112
|
get duration() {
|
|
74
113
|
return this.#nowPlayingInfo?.duration || this.currentItemMetadata?.duration || 0;
|
|
75
114
|
}
|
|
115
|
+
/** Current playback rate (1.0 = normal, 0 = paused, 2.0 = double speed). */
|
|
76
116
|
get playbackRate() {
|
|
77
117
|
return this.#nowPlayingInfo?.playbackRate ?? this.currentItemMetadata?.playbackRate ?? 0;
|
|
78
118
|
}
|
|
119
|
+
/** Whether the player is currently playing (based on effective playback state). */
|
|
79
120
|
get isPlaying() {
|
|
80
|
-
return this
|
|
121
|
+
return this.playbackState === Proto.PlaybackState_Enum.Playing;
|
|
81
122
|
}
|
|
123
|
+
/** Current shuffle mode, derived from the ChangeShuffleMode command info. */
|
|
82
124
|
get shuffleMode() {
|
|
83
125
|
return this.#supportedCommands.find((c) => c.command === Proto.Command.ChangeShuffleMode)?.shuffleMode ?? Proto.ShuffleMode_Enum.Unknown;
|
|
84
126
|
}
|
|
127
|
+
/** Current repeat mode, derived from the ChangeRepeatMode command info. */
|
|
85
128
|
get repeatMode() {
|
|
86
129
|
return this.#supportedCommands.find((c) => c.command === Proto.Command.ChangeRepeatMode)?.repeatMode ?? Proto.RepeatMode_Enum.Unknown;
|
|
87
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
|
+
*/
|
|
88
136
|
get elapsedTime() {
|
|
89
137
|
const npi = this.#nowPlayingInfo;
|
|
90
138
|
const meta = this.currentItemMetadata;
|
|
@@ -98,13 +146,19 @@ var Player = class {
|
|
|
98
146
|
if (metaValid) return extrapolateElapsed(meta.elapsedTime, meta.elapsedTimeTimestamp, meta.playbackRate);
|
|
99
147
|
return npi?.elapsedTime || meta?.elapsedTime || 0;
|
|
100
148
|
}
|
|
149
|
+
/** The currently playing content item from the playback queue, or null. */
|
|
101
150
|
get currentItem() {
|
|
102
151
|
if (!this.#playbackQueue || this.#playbackQueue.contentItems.length === 0) return null;
|
|
103
152
|
return this.#playbackQueue.contentItems[this.#playbackQueue.location] ?? this.#playbackQueue.contentItems[0] ?? null;
|
|
104
153
|
}
|
|
154
|
+
/** Metadata of the current content item, or null if no item is playing. */
|
|
105
155
|
get currentItemMetadata() {
|
|
106
156
|
return this.currentItem?.metadata ?? null;
|
|
107
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Unique identifier for the current artwork, used for change detection.
|
|
160
|
+
* Returns null if no artwork evidence exists.
|
|
161
|
+
*/
|
|
108
162
|
get artworkId() {
|
|
109
163
|
const metadata = this.currentItemMetadata;
|
|
110
164
|
if (!metadata) return null;
|
|
@@ -113,6 +167,14 @@ var Player = class {
|
|
|
113
167
|
if (metadata.contentIdentifier) return metadata.contentIdentifier;
|
|
114
168
|
return this.currentItem?.identifier ?? null;
|
|
115
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
|
+
*/
|
|
116
178
|
artworkUrl(width = 600, height = -1) {
|
|
117
179
|
const metadata = this.currentItemMetadata;
|
|
118
180
|
if (metadata?.artworkURL) return metadata.artworkURL;
|
|
@@ -124,6 +186,7 @@ var Player = class {
|
|
|
124
186
|
} catch {}
|
|
125
187
|
return null;
|
|
126
188
|
}
|
|
189
|
+
/** Raw artwork data (image bytes) for the current item, or null if not embedded. */
|
|
127
190
|
get currentItemArtwork() {
|
|
128
191
|
const item = this.currentItem;
|
|
129
192
|
if (!item) return null;
|
|
@@ -131,9 +194,11 @@ var Player = class {
|
|
|
131
194
|
if (item.dataArtworks.length > 0 && item.dataArtworks[0].imageData?.byteLength > 0) return item.dataArtworks[0].imageData;
|
|
132
195
|
return null;
|
|
133
196
|
}
|
|
197
|
+
/** Convenience getter for the artwork URL at default dimensions (600px). */
|
|
134
198
|
get currentItemArtworkUrl() {
|
|
135
199
|
return this.artworkUrl();
|
|
136
200
|
}
|
|
201
|
+
/** Lyrics for the current content item, or null if unavailable. */
|
|
137
202
|
get currentItemLyrics() {
|
|
138
203
|
return this.currentItem?.lyrics ?? null;
|
|
139
204
|
}
|
|
@@ -144,38 +209,84 @@ var Player = class {
|
|
|
144
209
|
#playbackState;
|
|
145
210
|
#playbackStateTimestamp = 0;
|
|
146
211
|
#supportedCommands = [];
|
|
212
|
+
/**
|
|
213
|
+
* Creates a new Player instance.
|
|
214
|
+
*
|
|
215
|
+
* @param identifier - Unique player identifier.
|
|
216
|
+
* @param displayName - Human-readable display name.
|
|
217
|
+
*/
|
|
147
218
|
constructor(identifier, displayName) {
|
|
148
219
|
this.#identifier = identifier;
|
|
149
220
|
this.#displayName = displayName;
|
|
150
221
|
this.#playbackState = Proto.PlaybackState_Enum.Unknown;
|
|
151
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
|
+
*/
|
|
152
229
|
findCommand(command) {
|
|
153
230
|
return this.#supportedCommands.find((c) => c.command === command) ?? null;
|
|
154
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
|
+
*/
|
|
155
238
|
isCommandSupported(command) {
|
|
156
239
|
const info = this.findCommand(command);
|
|
157
240
|
return info != null && info.enabled !== false;
|
|
158
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
|
+
*/
|
|
159
247
|
setNowPlayingInfo(nowPlayingInfo) {
|
|
160
248
|
this.#nowPlayingInfo = nowPlayingInfo;
|
|
161
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Updates the playback queue for this player.
|
|
252
|
+
*
|
|
253
|
+
* @param playbackQueue - The new playback queue from the Apple TV.
|
|
254
|
+
*/
|
|
162
255
|
setPlaybackQueue(playbackQueue) {
|
|
163
256
|
this.#playbackQueue = playbackQueue;
|
|
164
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
|
+
*/
|
|
165
265
|
setPlaybackState(playbackState, playbackStateTimestamp) {
|
|
166
266
|
if (playbackStateTimestamp < this.#playbackStateTimestamp) return;
|
|
167
267
|
this.#playbackState = playbackState;
|
|
168
268
|
this.#playbackStateTimestamp = playbackStateTimestamp;
|
|
169
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Replaces the list of supported commands for this player.
|
|
272
|
+
*
|
|
273
|
+
* @param supportedCommands - The new list of supported commands.
|
|
274
|
+
*/
|
|
170
275
|
setSupportedCommands(supportedCommands) {
|
|
171
276
|
this.#supportedCommands = supportedCommands;
|
|
172
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
|
+
*/
|
|
173
284
|
updateContentItem(item) {
|
|
174
285
|
if (!this.#playbackQueue) return;
|
|
175
286
|
const existing = this.#playbackQueue.contentItems.find((i) => i.identifier === item.identifier);
|
|
176
287
|
if (!existing) return;
|
|
177
288
|
if (item.metadata != null && existing.metadata != null) {
|
|
178
|
-
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;
|
|
179
290
|
} else if (item.metadata != null) existing.metadata = item.metadata;
|
|
180
291
|
if (item.artworkData != null) existing.artworkData = item.artworkData;
|
|
181
292
|
if (item.lyrics != null) existing.lyrics = item.lyrics;
|
|
@@ -185,31 +296,49 @@ var Player = class {
|
|
|
185
296
|
|
|
186
297
|
//#endregion
|
|
187
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
|
+
*/
|
|
188
305
|
var Client = class {
|
|
306
|
+
/** Bundle identifier of the app (e.g. "com.apple.TVMusic"). */
|
|
189
307
|
get bundleIdentifier() {
|
|
190
308
|
return this.#bundleIdentifier;
|
|
191
309
|
}
|
|
310
|
+
/** Human-readable display name of the app. */
|
|
192
311
|
get displayName() {
|
|
193
312
|
return this.#displayName;
|
|
194
313
|
}
|
|
314
|
+
/** Map of all known players for this client, keyed by player identifier. */
|
|
195
315
|
get players() {
|
|
196
316
|
return this.#players;
|
|
197
317
|
}
|
|
318
|
+
/** The currently active player, or null if none is active. Falls back to the default player. */
|
|
198
319
|
get activePlayer() {
|
|
199
320
|
return this.#players.get(this.#activePlayerId ?? "MediaRemote-DefaultPlayer") ?? null;
|
|
200
321
|
}
|
|
322
|
+
/** Now-playing info from the active player, or null. */
|
|
201
323
|
get nowPlayingInfo() {
|
|
202
324
|
return this.activePlayer?.nowPlayingInfo ?? null;
|
|
203
325
|
}
|
|
326
|
+
/** Playback queue from the active player, or null. */
|
|
204
327
|
get playbackQueue() {
|
|
205
328
|
return this.activePlayer?.playbackQueue ?? null;
|
|
206
329
|
}
|
|
330
|
+
/** Playback state from the active player, or Unknown. */
|
|
207
331
|
get playbackState() {
|
|
208
332
|
return this.activePlayer?.playbackState ?? Proto.PlaybackState_Enum.Unknown;
|
|
209
333
|
}
|
|
334
|
+
/** Timestamp of the last playback state update from the active player. */
|
|
210
335
|
get playbackStateTimestamp() {
|
|
211
336
|
return this.activePlayer?.playbackStateTimestamp ?? -1;
|
|
212
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
|
+
*/
|
|
213
342
|
get supportedCommands() {
|
|
214
343
|
const playerCommands = this.activePlayer?.supportedCommands ?? [];
|
|
215
344
|
if (playerCommands.length === 0) return this.#defaultSupportedCommands;
|
|
@@ -219,69 +348,97 @@ var Client = class {
|
|
|
219
348
|
for (const cmd of this.#defaultSupportedCommands) if (!playerCommandSet.has(cmd.command)) merged.push(cmd);
|
|
220
349
|
return merged;
|
|
221
350
|
}
|
|
351
|
+
/** Current track title from the active player. */
|
|
222
352
|
get title() {
|
|
223
353
|
return this.activePlayer?.title ?? "";
|
|
224
354
|
}
|
|
355
|
+
/** Current track artist from the active player. */
|
|
225
356
|
get artist() {
|
|
226
357
|
return this.activePlayer?.artist ?? "";
|
|
227
358
|
}
|
|
359
|
+
/** Current track album from the active player. */
|
|
228
360
|
get album() {
|
|
229
361
|
return this.activePlayer?.album ?? "";
|
|
230
362
|
}
|
|
363
|
+
/** Genre of the current content from the active player. */
|
|
231
364
|
get genre() {
|
|
232
365
|
return this.activePlayer?.genre ?? "";
|
|
233
366
|
}
|
|
367
|
+
/** Series name for TV show content from the active player. */
|
|
234
368
|
get seriesName() {
|
|
235
369
|
return this.activePlayer?.seriesName ?? "";
|
|
236
370
|
}
|
|
371
|
+
/** Season number for TV show content from the active player. */
|
|
237
372
|
get seasonNumber() {
|
|
238
373
|
return this.activePlayer?.seasonNumber ?? 0;
|
|
239
374
|
}
|
|
375
|
+
/** Episode number for TV show content from the active player. */
|
|
240
376
|
get episodeNumber() {
|
|
241
377
|
return this.activePlayer?.episodeNumber ?? 0;
|
|
242
378
|
}
|
|
379
|
+
/** Media type of the current content from the active player. */
|
|
243
380
|
get mediaType() {
|
|
244
381
|
return this.activePlayer?.mediaType ?? Proto.ContentItemMetadata_MediaType.UnknownMediaType;
|
|
245
382
|
}
|
|
383
|
+
/** Content identifier of the current item from the active player. */
|
|
246
384
|
get contentIdentifier() {
|
|
247
385
|
return this.activePlayer?.contentIdentifier ?? "";
|
|
248
386
|
}
|
|
387
|
+
/** Duration of the current track in seconds from the active player. */
|
|
249
388
|
get duration() {
|
|
250
389
|
return this.activePlayer?.duration ?? 0;
|
|
251
390
|
}
|
|
391
|
+
/** Playback rate from the active player (1.0 = normal, 0 = paused). */
|
|
252
392
|
get playbackRate() {
|
|
253
393
|
return this.activePlayer?.playbackRate ?? 0;
|
|
254
394
|
}
|
|
395
|
+
/** Whether the active player is currently playing. */
|
|
255
396
|
get isPlaying() {
|
|
256
397
|
return this.activePlayer?.isPlaying ?? false;
|
|
257
398
|
}
|
|
399
|
+
/** Current shuffle mode from the active player. */
|
|
258
400
|
get shuffleMode() {
|
|
259
401
|
return this.activePlayer?.shuffleMode ?? Proto.ShuffleMode_Enum.Unknown;
|
|
260
402
|
}
|
|
403
|
+
/** Current repeat mode from the active player. */
|
|
261
404
|
get repeatMode() {
|
|
262
405
|
return this.activePlayer?.repeatMode ?? Proto.RepeatMode_Enum.Unknown;
|
|
263
406
|
}
|
|
407
|
+
/** Extrapolated elapsed time in seconds from the active player. */
|
|
264
408
|
get elapsedTime() {
|
|
265
409
|
return this.activePlayer?.elapsedTime ?? 0;
|
|
266
410
|
}
|
|
411
|
+
/** Artwork identifier for change detection from the active player. */
|
|
267
412
|
get artworkId() {
|
|
268
413
|
return this.activePlayer?.artworkId ?? null;
|
|
269
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
|
+
*/
|
|
270
422
|
artworkUrl(width = 600, height = -1) {
|
|
271
423
|
return this.activePlayer?.artworkUrl(width, height) ?? null;
|
|
272
424
|
}
|
|
425
|
+
/** Current content item from the active player's playback queue. */
|
|
273
426
|
get currentItem() {
|
|
274
427
|
return this.activePlayer?.currentItem ?? null;
|
|
275
428
|
}
|
|
429
|
+
/** Metadata of the current content item from the active player. */
|
|
276
430
|
get currentItemMetadata() {
|
|
277
431
|
return this.activePlayer?.currentItemMetadata ?? null;
|
|
278
432
|
}
|
|
433
|
+
/** Raw artwork data (image bytes) from the active player. */
|
|
279
434
|
get currentItemArtwork() {
|
|
280
435
|
return this.activePlayer?.currentItemArtwork ?? null;
|
|
281
436
|
}
|
|
437
|
+
/** Artwork URL at default dimensions from the active player. */
|
|
282
438
|
get currentItemArtworkUrl() {
|
|
283
439
|
return this.activePlayer?.currentItemArtworkUrl ?? null;
|
|
284
440
|
}
|
|
441
|
+
/** Lyrics for the current content item from the active player. */
|
|
285
442
|
get currentItemLyrics() {
|
|
286
443
|
return this.activePlayer?.currentItemLyrics ?? null;
|
|
287
444
|
}
|
|
@@ -290,10 +447,23 @@ var Client = class {
|
|
|
290
447
|
#players = /* @__PURE__ */ new Map();
|
|
291
448
|
#activePlayerId = null;
|
|
292
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
|
+
*/
|
|
293
456
|
constructor(bundleIdentifier, displayName) {
|
|
294
457
|
this.#bundleIdentifier = bundleIdentifier;
|
|
295
458
|
this.#displayName = displayName;
|
|
296
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
|
+
*/
|
|
297
467
|
getOrCreatePlayer(identifier, displayName) {
|
|
298
468
|
let player = this.#players.get(identifier);
|
|
299
469
|
if (!player) {
|
|
@@ -302,25 +472,61 @@ var Client = class {
|
|
|
302
472
|
}
|
|
303
473
|
return player;
|
|
304
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Sets the active player by identifier.
|
|
477
|
+
*
|
|
478
|
+
* @param identifier - Identifier of the player to activate.
|
|
479
|
+
*/
|
|
305
480
|
setActivePlayer(identifier) {
|
|
306
481
|
this.#activePlayerId = identifier;
|
|
307
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
|
+
*/
|
|
308
489
|
removePlayer(identifier) {
|
|
309
490
|
this.#players.delete(identifier);
|
|
310
491
|
if (this.#activePlayerId === identifier) this.#activePlayerId = null;
|
|
311
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
|
+
*/
|
|
312
499
|
setDefaultSupportedCommands(supportedCommands) {
|
|
313
500
|
this.#defaultSupportedCommands = supportedCommands;
|
|
314
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
|
+
*/
|
|
315
509
|
findCommand(command) {
|
|
316
510
|
const playerCmd = this.activePlayer?.findCommand(command) ?? null;
|
|
317
511
|
if (playerCmd) return playerCmd;
|
|
318
512
|
return this.#defaultSupportedCommands.find((c) => c.command === command) ?? null;
|
|
319
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
|
+
*/
|
|
320
521
|
isCommandSupported(command) {
|
|
321
522
|
const info = this.findCommand(command);
|
|
322
523
|
return info != null && info.enabled !== false;
|
|
323
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Updates the display name for this client.
|
|
527
|
+
*
|
|
528
|
+
* @param displayName - The new display name.
|
|
529
|
+
*/
|
|
324
530
|
updateDisplayName(displayName) {
|
|
325
531
|
this.#displayName = displayName;
|
|
326
532
|
}
|
|
@@ -328,16 +534,32 @@ var Client = class {
|
|
|
328
534
|
|
|
329
535
|
//#endregion
|
|
330
536
|
//#region src/airplay/const.ts
|
|
537
|
+
/** Interval in milliseconds between periodic feedback requests to keep the AirPlay session alive. */
|
|
331
538
|
const FEEDBACK_INTERVAL = 2e3;
|
|
539
|
+
/** Symbol used to access the underlying AirPlay Protocol instance from an AirPlayDevice. */
|
|
332
540
|
const PROTOCOL = Symbol("com.basmilius.airplay:protocol");
|
|
541
|
+
/** Symbol used to subscribe AirPlayState to DataStream events. */
|
|
333
542
|
const STATE_SUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:subscribe");
|
|
543
|
+
/** Symbol used to unsubscribe AirPlayState from DataStream events. */
|
|
334
544
|
const STATE_UNSUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:unsubscribe");
|
|
335
545
|
|
|
336
546
|
//#endregion
|
|
337
547
|
//#region src/airplay/remote.ts
|
|
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
|
+
*/
|
|
338
552
|
var SendCommandError = class extends CommandError {
|
|
553
|
+
/** The send error reported by the Apple TV. */
|
|
339
554
|
sendError;
|
|
555
|
+
/** The handler return status reported by the Apple TV. */
|
|
340
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
|
+
*/
|
|
341
563
|
constructor(sendError, handlerReturnStatus) {
|
|
342
564
|
super(`SendCommand failed: sendError=${Proto.SendError_Enum[sendError]}, handlerReturnStatus=${Proto.HandlerReturnStatus_Enum[handlerReturnStatus]}`);
|
|
343
565
|
this.name = "SendCommandError";
|
|
@@ -345,213 +567,398 @@ var SendCommandError = class extends CommandError {
|
|
|
345
567
|
this.handlerReturnStatus = handlerReturnStatus;
|
|
346
568
|
}
|
|
347
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
|
+
*/
|
|
348
576
|
var remote_default = class {
|
|
577
|
+
/** @returns The DataStream for sending HID events and commands. */
|
|
349
578
|
get #dataStream() {
|
|
350
579
|
return this.#protocol.dataStream;
|
|
351
580
|
}
|
|
581
|
+
/** @returns The underlying AirPlay Protocol instance. */
|
|
352
582
|
get #protocol() {
|
|
353
583
|
return this.#device[PROTOCOL];
|
|
354
584
|
}
|
|
355
585
|
#device;
|
|
586
|
+
/**
|
|
587
|
+
* Creates a new Remote controller.
|
|
588
|
+
*
|
|
589
|
+
* @param device - The AirPlay device to control.
|
|
590
|
+
*/
|
|
356
591
|
constructor(device) {
|
|
357
592
|
this.#device = device;
|
|
358
593
|
}
|
|
594
|
+
/** Sends an Up navigation key press (Generic Desktop, usage 0x8C). */
|
|
359
595
|
async up() {
|
|
360
596
|
await this.pressAndRelease(1, 140);
|
|
361
597
|
}
|
|
598
|
+
/** Sends a Down navigation key press (Generic Desktop, usage 0x8D). */
|
|
362
599
|
async down() {
|
|
363
600
|
await this.pressAndRelease(1, 141);
|
|
364
601
|
}
|
|
602
|
+
/** Sends a Left navigation key press (Generic Desktop, usage 0x8B). */
|
|
365
603
|
async left() {
|
|
366
604
|
await this.pressAndRelease(1, 139);
|
|
367
605
|
}
|
|
606
|
+
/** Sends a Right navigation key press (Generic Desktop, usage 0x8A). */
|
|
368
607
|
async right() {
|
|
369
608
|
await this.pressAndRelease(1, 138);
|
|
370
609
|
}
|
|
610
|
+
/** Sends a Menu key press (Generic Desktop, usage 0x86). */
|
|
371
611
|
async menu() {
|
|
372
612
|
await this.pressAndRelease(1, 134);
|
|
373
613
|
}
|
|
614
|
+
/** Sends a Select/Enter key press (Generic Desktop, usage 0x89). */
|
|
374
615
|
async select() {
|
|
375
616
|
await this.pressAndRelease(1, 137);
|
|
376
617
|
}
|
|
618
|
+
/** Sends a Home button press (Consumer, usage 0x40). */
|
|
377
619
|
async home() {
|
|
378
620
|
await this.pressAndRelease(12, 64);
|
|
379
621
|
}
|
|
622
|
+
/** Sends a Suspend/Sleep key press to put the device to sleep (Generic Desktop, usage 0x82). */
|
|
380
623
|
async suspend() {
|
|
381
624
|
await this.pressAndRelease(1, 130);
|
|
382
625
|
}
|
|
626
|
+
/** Sends a Wake key press to wake the device (Generic Desktop, usage 0x83). */
|
|
383
627
|
async wake() {
|
|
384
628
|
await this.pressAndRelease(1, 131);
|
|
385
629
|
}
|
|
630
|
+
/** Sends a Play key press (Consumer, usage 0xB0). */
|
|
386
631
|
async play() {
|
|
387
632
|
await this.pressAndRelease(12, 176);
|
|
388
633
|
}
|
|
634
|
+
/** Sends a Pause key press (Consumer, usage 0xB1). */
|
|
389
635
|
async pause() {
|
|
390
636
|
await this.pressAndRelease(12, 177);
|
|
391
637
|
}
|
|
638
|
+
/** Toggles play/pause based on the current playback state. */
|
|
392
639
|
async playPause() {
|
|
393
640
|
if (this.#device.state.nowPlayingClient?.isPlaying) await this.pause();
|
|
394
641
|
else await this.play();
|
|
395
642
|
}
|
|
643
|
+
/** Sends a Stop key press (Consumer, usage 0xB7). */
|
|
396
644
|
async stop() {
|
|
397
645
|
await this.pressAndRelease(12, 183);
|
|
398
646
|
}
|
|
647
|
+
/** Sends a Next Track key press (Consumer, usage 0xB5). */
|
|
399
648
|
async next() {
|
|
400
649
|
await this.pressAndRelease(12, 181);
|
|
401
650
|
}
|
|
651
|
+
/** Sends a Previous Track key press (Consumer, usage 0xB6). */
|
|
402
652
|
async previous() {
|
|
403
653
|
await this.pressAndRelease(12, 182);
|
|
404
654
|
}
|
|
655
|
+
/** Sends a Volume Up key press (Consumer, usage 0xE9). */
|
|
405
656
|
async volumeUp() {
|
|
406
657
|
await this.pressAndRelease(12, 233);
|
|
407
658
|
}
|
|
659
|
+
/** Sends a Volume Down key press (Consumer, usage 0xEA). */
|
|
408
660
|
async volumeDown() {
|
|
409
661
|
await this.pressAndRelease(12, 234);
|
|
410
662
|
}
|
|
663
|
+
/** Sends a Mute key press (Consumer, usage 0xE2). */
|
|
411
664
|
async mute() {
|
|
412
665
|
await this.pressAndRelease(12, 226);
|
|
413
666
|
}
|
|
667
|
+
/** Sends a Top Menu key press (Consumer, usage 0x60). */
|
|
414
668
|
async topMenu() {
|
|
415
669
|
await this.pressAndRelease(12, 96);
|
|
416
670
|
}
|
|
671
|
+
/** Sends a Channel Up key press (Consumer, usage 0x9C). */
|
|
417
672
|
async channelUp() {
|
|
418
673
|
await this.pressAndRelease(12, 156);
|
|
419
674
|
}
|
|
675
|
+
/** Sends a Channel Down key press (Consumer, usage 0x9D). */
|
|
420
676
|
async channelDown() {
|
|
421
677
|
await this.pressAndRelease(12, 157);
|
|
422
678
|
}
|
|
679
|
+
/** Sends a Play command via the MRP SendCommand protocol. */
|
|
423
680
|
async commandPlay() {
|
|
424
681
|
await this.#sendCommand(Proto.Command.Play);
|
|
425
682
|
}
|
|
683
|
+
/** Sends a Pause command via the MRP SendCommand protocol. */
|
|
426
684
|
async commandPause() {
|
|
427
685
|
await this.#sendCommand(Proto.Command.Pause);
|
|
428
686
|
}
|
|
687
|
+
/** Sends a TogglePlayPause command via the MRP SendCommand protocol. */
|
|
429
688
|
async commandTogglePlayPause() {
|
|
430
689
|
await this.#sendCommand(Proto.Command.TogglePlayPause);
|
|
431
690
|
}
|
|
691
|
+
/** Sends a Stop command via the MRP SendCommand protocol. */
|
|
432
692
|
async commandStop() {
|
|
433
693
|
await this.#sendCommand(Proto.Command.Stop);
|
|
434
694
|
}
|
|
695
|
+
/** Sends a NextTrack command via the MRP SendCommand protocol. */
|
|
435
696
|
async commandNextTrack() {
|
|
436
697
|
await this.#sendCommand(Proto.Command.NextTrack);
|
|
437
698
|
}
|
|
699
|
+
/** Sends a PreviousTrack command via the MRP SendCommand protocol. */
|
|
438
700
|
async commandPreviousTrack() {
|
|
439
701
|
await this.#sendCommand(Proto.Command.PreviousTrack);
|
|
440
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Sends a SkipForward command with a configurable interval.
|
|
705
|
+
*
|
|
706
|
+
* @param interval - Seconds to skip forward (defaults to 15).
|
|
707
|
+
*/
|
|
441
708
|
async commandSkipForward(interval = 15) {
|
|
442
709
|
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithSkipInterval(Proto.Command.SkipForward, interval));
|
|
443
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* Sends a SkipBackward command with a configurable interval.
|
|
713
|
+
*
|
|
714
|
+
* @param interval - Seconds to skip backward (defaults to 15).
|
|
715
|
+
*/
|
|
444
716
|
async commandSkipBackward(interval = 15) {
|
|
445
717
|
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithSkipInterval(Proto.Command.SkipBackward, interval));
|
|
446
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Seeks to an absolute playback position.
|
|
721
|
+
*
|
|
722
|
+
* @param position - The target position in seconds.
|
|
723
|
+
*/
|
|
447
724
|
async commandSeekToPosition(position) {
|
|
448
725
|
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithPlaybackPosition(Proto.Command.SeekToPlaybackPosition, position));
|
|
449
726
|
}
|
|
727
|
+
/**
|
|
728
|
+
* Sets the shuffle mode.
|
|
729
|
+
*
|
|
730
|
+
* @param mode - The desired shuffle mode.
|
|
731
|
+
*/
|
|
450
732
|
async commandSetShuffleMode(mode) {
|
|
451
733
|
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithShuffleMode(Proto.Command.ChangeShuffleMode, mode));
|
|
452
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Sets the repeat mode.
|
|
737
|
+
*
|
|
738
|
+
* @param mode - The desired repeat mode.
|
|
739
|
+
*/
|
|
453
740
|
async commandSetRepeatMode(mode) {
|
|
454
741
|
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithRepeatMode(Proto.Command.ChangeRepeatMode, mode));
|
|
455
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
|
+
*/
|
|
456
748
|
async commandChangePlaybackRate(rate) {
|
|
457
749
|
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithPlaybackRate(Proto.Command.ChangePlaybackRate, rate));
|
|
458
750
|
}
|
|
751
|
+
/** Cycles the shuffle mode to the next value. */
|
|
459
752
|
async commandAdvanceShuffleMode() {
|
|
460
753
|
await this.#sendCommand(Proto.Command.AdvanceShuffleMode);
|
|
461
754
|
}
|
|
755
|
+
/** Cycles the repeat mode to the next value. */
|
|
462
756
|
async commandAdvanceRepeatMode() {
|
|
463
757
|
await this.#sendCommand(Proto.Command.AdvanceRepeatMode);
|
|
464
758
|
}
|
|
759
|
+
/** Begins fast-forwarding playback. */
|
|
465
760
|
async commandBeginFastForward() {
|
|
466
761
|
await this.#sendCommand(Proto.Command.BeginFastForward);
|
|
467
762
|
}
|
|
763
|
+
/** Ends fast-forwarding playback. */
|
|
468
764
|
async commandEndFastForward() {
|
|
469
765
|
await this.#sendCommand(Proto.Command.EndFastForward);
|
|
470
766
|
}
|
|
767
|
+
/** Begins rewinding playback. */
|
|
471
768
|
async commandBeginRewind() {
|
|
472
769
|
await this.#sendCommand(Proto.Command.BeginRewind);
|
|
473
770
|
}
|
|
771
|
+
/** Ends rewinding playback. */
|
|
474
772
|
async commandEndRewind() {
|
|
475
773
|
await this.#sendCommand(Proto.Command.EndRewind);
|
|
476
774
|
}
|
|
775
|
+
/** Skips to the next chapter. */
|
|
477
776
|
async commandNextChapter() {
|
|
478
777
|
await this.#sendCommand(Proto.Command.NextChapter);
|
|
479
778
|
}
|
|
779
|
+
/** Skips to the previous chapter. */
|
|
480
780
|
async commandPreviousChapter() {
|
|
481
781
|
await this.#sendCommand(Proto.Command.PreviousChapter);
|
|
482
782
|
}
|
|
783
|
+
/** Marks the current track as liked. */
|
|
483
784
|
async commandLikeTrack() {
|
|
484
785
|
await this.#sendCommand(Proto.Command.LikeTrack);
|
|
485
786
|
}
|
|
787
|
+
/** Marks the current track as disliked. */
|
|
486
788
|
async commandDislikeTrack() {
|
|
487
789
|
await this.#sendCommand(Proto.Command.DislikeTrack);
|
|
488
790
|
}
|
|
791
|
+
/** Bookmarks the current track. */
|
|
489
792
|
async commandBookmarkTrack() {
|
|
490
793
|
await this.#sendCommand(Proto.Command.BookmarkTrack);
|
|
491
794
|
}
|
|
795
|
+
/** Adds the currently playing item to the user's library. */
|
|
492
796
|
async commandAddNowPlayingItemToLibrary() {
|
|
493
797
|
await this.#sendCommand(Proto.Command.AddNowPlayingItemToLibrary);
|
|
494
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
|
+
*/
|
|
495
804
|
async textSet(text) {
|
|
496
805
|
await this.#dataStream.send(DataStreamMessage.textInput(text, Proto.ActionType_Enum.Set));
|
|
497
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* Appends text to the current text input field content.
|
|
809
|
+
*
|
|
810
|
+
* @param text - The text to append.
|
|
811
|
+
*/
|
|
498
812
|
async textAppend(text) {
|
|
499
813
|
await this.#dataStream.send(DataStreamMessage.textInput(text, Proto.ActionType_Enum.Insert));
|
|
500
814
|
}
|
|
815
|
+
/** Clears the text input field. */
|
|
501
816
|
async textClear() {
|
|
502
817
|
await this.#dataStream.send(DataStreamMessage.textInput("", Proto.ActionType_Enum.ClearAction));
|
|
503
818
|
}
|
|
819
|
+
/** Requests the current keyboard session state from the Apple TV. */
|
|
504
820
|
async getKeyboardSession() {
|
|
505
821
|
await this.#dataStream.send(DataStreamMessage.getKeyboardSession());
|
|
506
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
|
+
*/
|
|
507
830
|
async tap(x, y, finger = 1) {
|
|
508
831
|
await this.#sendTouch(x, y, 1, finger);
|
|
509
832
|
await waitFor(50);
|
|
510
833
|
await this.#sendTouch(x, y, 4, finger);
|
|
511
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* Simulates an upward swipe gesture.
|
|
837
|
+
*
|
|
838
|
+
* @param duration - Swipe duration in milliseconds (defaults to 200).
|
|
839
|
+
*/
|
|
512
840
|
async swipeUp(duration = 200) {
|
|
513
841
|
await this.#swipe(200, 400, 200, 100, duration);
|
|
514
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Simulates a downward swipe gesture.
|
|
845
|
+
*
|
|
846
|
+
* @param duration - Swipe duration in milliseconds (defaults to 200).
|
|
847
|
+
*/
|
|
515
848
|
async swipeDown(duration = 200) {
|
|
516
849
|
await this.#swipe(200, 100, 200, 400, duration);
|
|
517
850
|
}
|
|
851
|
+
/**
|
|
852
|
+
* Simulates a leftward swipe gesture.
|
|
853
|
+
*
|
|
854
|
+
* @param duration - Swipe duration in milliseconds (defaults to 200).
|
|
855
|
+
*/
|
|
518
856
|
async swipeLeft(duration = 200) {
|
|
519
857
|
await this.#swipe(400, 200, 100, 200, duration);
|
|
520
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Simulates a rightward swipe gesture.
|
|
861
|
+
*
|
|
862
|
+
* @param duration - Swipe duration in milliseconds (defaults to 200).
|
|
863
|
+
*/
|
|
521
864
|
async swipeRight(duration = 200) {
|
|
522
865
|
await this.#swipe(100, 200, 400, 200, duration);
|
|
523
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
|
+
*/
|
|
524
873
|
async doublePress(usePage, usage) {
|
|
525
874
|
await this.pressAndRelease(usePage, usage);
|
|
526
875
|
await waitFor(150);
|
|
527
876
|
await this.pressAndRelease(usePage, usage);
|
|
528
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
|
+
*/
|
|
529
885
|
async longPress(usePage, usage, duration = 1e3) {
|
|
530
886
|
await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, true));
|
|
531
887
|
await waitFor(duration);
|
|
532
888
|
await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, false));
|
|
533
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
|
+
*/
|
|
534
896
|
async pressAndRelease(usePage, usage) {
|
|
535
897
|
await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, true));
|
|
536
898
|
await waitFor(25);
|
|
537
899
|
await this.#dataStream.exchange(DataStreamMessage.sendHIDEvent(usePage, usage, false));
|
|
538
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
|
+
*/
|
|
539
909
|
async #sendCommand(command, options) {
|
|
540
910
|
const response = await this.#dataStream.exchange(DataStreamMessage.sendCommand(command, options));
|
|
541
911
|
return this.#checkCommandResult(response);
|
|
542
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
|
+
*/
|
|
543
920
|
async #sendCommandRaw(message) {
|
|
544
921
|
const response = await this.#dataStream.exchange(message);
|
|
545
922
|
return this.#checkCommandResult(response);
|
|
546
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
|
+
*/
|
|
547
931
|
#checkCommandResult(response) {
|
|
548
|
-
|
|
932
|
+
let result;
|
|
933
|
+
try {
|
|
934
|
+
result = DataStreamMessage.getExtension(response, Proto.sendCommandResultMessage);
|
|
935
|
+
} catch {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (!result) return;
|
|
549
939
|
if (result.sendError !== Proto.SendError_Enum.NoError) throw new SendCommandError(result.sendError, result.handlerReturnStatus);
|
|
550
940
|
return result;
|
|
551
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
|
+
*/
|
|
552
950
|
async #sendTouch(x, y, phase, finger) {
|
|
553
951
|
await this.#dataStream.exchange(DataStreamMessage.sendVirtualTouchEvent(x, y, phase, finger));
|
|
554
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
|
+
*/
|
|
555
962
|
async #swipe(startX, startY, endX, endY, duration) {
|
|
556
963
|
const steps = Math.max(4, Math.floor(duration / 50));
|
|
557
964
|
const deltaX = (endX - startX) / steps;
|
|
@@ -569,52 +976,81 @@ var remote_default = class {
|
|
|
569
976
|
|
|
570
977
|
//#endregion
|
|
571
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
|
+
*/
|
|
572
985
|
var state_default = class extends EventEmitter {
|
|
986
|
+
/** @returns The DataStream for event subscription. */
|
|
573
987
|
get #dataStream() {
|
|
574
988
|
return this.#protocol.dataStream;
|
|
575
989
|
}
|
|
990
|
+
/** @returns The underlying AirPlay Protocol instance. */
|
|
576
991
|
get #protocol() {
|
|
577
992
|
return this.#device[PROTOCOL];
|
|
578
993
|
}
|
|
994
|
+
/** All known clients (apps) keyed by bundle identifier. */
|
|
579
995
|
get clients() {
|
|
580
996
|
return this.#clients;
|
|
581
997
|
}
|
|
998
|
+
/** Whether a keyboard/text input session is currently active on the Apple TV. */
|
|
582
999
|
get isKeyboardActive() {
|
|
583
1000
|
return this.#keyboardState === Proto.KeyboardState_Enum.DidBeginEditing || this.#keyboardState === Proto.KeyboardState_Enum.Editing || this.#keyboardState === Proto.KeyboardState_Enum.TextDidChange;
|
|
584
1001
|
}
|
|
1002
|
+
/** Text editing attributes for the active keyboard session, or null. */
|
|
585
1003
|
get keyboardAttributes() {
|
|
586
1004
|
return this.#keyboardAttributes;
|
|
587
1005
|
}
|
|
1006
|
+
/** Current keyboard state enum value. */
|
|
588
1007
|
get keyboardState() {
|
|
589
1008
|
return this.#keyboardState;
|
|
590
1009
|
}
|
|
1010
|
+
/** The currently active now-playing client, or null if nothing is playing. */
|
|
591
1011
|
get nowPlayingClient() {
|
|
592
1012
|
return this.#nowPlayingClientBundleIdentifier ? this.#clients[this.#nowPlayingClientBundleIdentifier] ?? null : null;
|
|
593
1013
|
}
|
|
1014
|
+
/** UID of the primary output device (used for volume control and multi-room). */
|
|
594
1015
|
get outputDeviceUID() {
|
|
595
1016
|
return this.#outputDeviceUID;
|
|
596
1017
|
}
|
|
1018
|
+
/** List of all output device descriptors in the current AirPlay group. */
|
|
597
1019
|
get outputDevices() {
|
|
598
1020
|
return this.#outputDevices;
|
|
599
1021
|
}
|
|
1022
|
+
/** Cluster identifier for multi-room groups, or null. */
|
|
600
1023
|
get clusterID() {
|
|
601
1024
|
return this.#clusterID;
|
|
602
1025
|
}
|
|
1026
|
+
/** Cluster type code (0 if not clustered). */
|
|
603
1027
|
get clusterType() {
|
|
604
1028
|
return this.#clusterType;
|
|
605
1029
|
}
|
|
1030
|
+
/** Whether this device is aware of multi-room clusters. */
|
|
606
1031
|
get isClusterAware() {
|
|
607
1032
|
return this.#isClusterAware;
|
|
608
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). */
|
|
609
1039
|
get volume() {
|
|
610
1040
|
return this.#volume;
|
|
611
1041
|
}
|
|
1042
|
+
/** Whether volume control is available on this device. */
|
|
612
1043
|
get volumeAvailable() {
|
|
613
1044
|
return this.#volumeAvailable;
|
|
614
1045
|
}
|
|
1046
|
+
/** Volume capabilities (absolute, relative, both, or none). */
|
|
615
1047
|
get volumeCapabilities() {
|
|
616
1048
|
return this.#volumeCapabilities;
|
|
617
1049
|
}
|
|
1050
|
+
/** Whether the device is currently muted. */
|
|
1051
|
+
get volumeMuted() {
|
|
1052
|
+
return this.#volumeMuted;
|
|
1053
|
+
}
|
|
618
1054
|
#device;
|
|
619
1055
|
#clients;
|
|
620
1056
|
#keyboardAttributes;
|
|
@@ -626,13 +1062,21 @@ var state_default = class extends EventEmitter {
|
|
|
626
1062
|
#clusterID;
|
|
627
1063
|
#clusterType;
|
|
628
1064
|
#isClusterAware;
|
|
1065
|
+
#isClusterLeader;
|
|
629
1066
|
#volume;
|
|
630
1067
|
#volumeAvailable;
|
|
631
1068
|
#volumeCapabilities;
|
|
1069
|
+
#volumeMuted;
|
|
1070
|
+
/**
|
|
1071
|
+
* Creates a new AirPlayState tracker.
|
|
1072
|
+
*
|
|
1073
|
+
* @param device - The AirPlay device to track state for.
|
|
1074
|
+
*/
|
|
632
1075
|
constructor(device) {
|
|
633
1076
|
super();
|
|
634
1077
|
this.#device = device;
|
|
635
1078
|
this.clear();
|
|
1079
|
+
this.onConfigureConnection = this.onConfigureConnection.bind(this);
|
|
636
1080
|
this.onKeyboard = this.onKeyboard.bind(this);
|
|
637
1081
|
this.onDeviceInfo = this.onDeviceInfo.bind(this);
|
|
638
1082
|
this.onDeviceInfoUpdate = this.onDeviceInfoUpdate.bind(this);
|
|
@@ -641,6 +1085,7 @@ var state_default = class extends EventEmitter {
|
|
|
641
1085
|
this.onRemoveClient = this.onRemoveClient.bind(this);
|
|
642
1086
|
this.onRemovePlayer = this.onRemovePlayer.bind(this);
|
|
643
1087
|
this.onSendCommandResult = this.onSendCommandResult.bind(this);
|
|
1088
|
+
this.onSendLyricsEvent = this.onSendLyricsEvent.bind(this);
|
|
644
1089
|
this.onSetArtwork = this.onSetArtwork.bind(this);
|
|
645
1090
|
this.onSetDefaultSupportedCommands = this.onSetDefaultSupportedCommands.bind(this);
|
|
646
1091
|
this.onSetNowPlayingClient = this.onSetNowPlayingClient.bind(this);
|
|
@@ -654,8 +1099,11 @@ var state_default = class extends EventEmitter {
|
|
|
654
1099
|
this.onVolumeControlAvailability = this.onVolumeControlAvailability.bind(this);
|
|
655
1100
|
this.onVolumeControlCapabilitiesDidChange = this.onVolumeControlCapabilitiesDidChange.bind(this);
|
|
656
1101
|
this.onVolumeDidChange = this.onVolumeDidChange.bind(this);
|
|
1102
|
+
this.onVolumeMutedDidChange = this.onVolumeMutedDidChange.bind(this);
|
|
657
1103
|
}
|
|
1104
|
+
/** Subscribes to all DataStream events to track device state. Called internally via symbol. */
|
|
658
1105
|
[STATE_SUBSCRIBE_SYMBOL]() {
|
|
1106
|
+
this.#dataStream.on("configureConnection", this.onConfigureConnection);
|
|
659
1107
|
this.#dataStream.on("keyboard", this.onKeyboard);
|
|
660
1108
|
this.#dataStream.on("deviceInfo", this.onDeviceInfo);
|
|
661
1109
|
this.#dataStream.on("deviceInfoUpdate", this.onDeviceInfoUpdate);
|
|
@@ -664,6 +1112,7 @@ var state_default = class extends EventEmitter {
|
|
|
664
1112
|
this.#dataStream.on("removeClient", this.onRemoveClient);
|
|
665
1113
|
this.#dataStream.on("removePlayer", this.onRemovePlayer);
|
|
666
1114
|
this.#dataStream.on("sendCommandResult", this.onSendCommandResult);
|
|
1115
|
+
this.#dataStream.on("sendLyricsEvent", this.onSendLyricsEvent);
|
|
667
1116
|
this.#dataStream.on("setArtwork", this.onSetArtwork);
|
|
668
1117
|
this.#dataStream.on("setDefaultSupportedCommands", this.onSetDefaultSupportedCommands);
|
|
669
1118
|
this.#dataStream.on("setNowPlayingClient", this.onSetNowPlayingClient);
|
|
@@ -677,10 +1126,13 @@ var state_default = class extends EventEmitter {
|
|
|
677
1126
|
this.#dataStream.on("volumeControlAvailability", this.onVolumeControlAvailability);
|
|
678
1127
|
this.#dataStream.on("volumeControlCapabilitiesDidChange", this.onVolumeControlCapabilitiesDidChange);
|
|
679
1128
|
this.#dataStream.on("volumeDidChange", this.onVolumeDidChange);
|
|
1129
|
+
this.#dataStream.on("volumeMutedDidChange", this.onVolumeMutedDidChange);
|
|
680
1130
|
}
|
|
1131
|
+
/** Unsubscribes from all DataStream events. Called internally via symbol. */
|
|
681
1132
|
[STATE_UNSUBSCRIBE_SYMBOL]() {
|
|
682
1133
|
const dataStream = this.#dataStream;
|
|
683
1134
|
if (!dataStream) return;
|
|
1135
|
+
dataStream.off("configureConnection", this.onConfigureConnection);
|
|
684
1136
|
dataStream.off("keyboard", this.onKeyboard);
|
|
685
1137
|
dataStream.off("deviceInfo", this.onDeviceInfo);
|
|
686
1138
|
dataStream.off("deviceInfoUpdate", this.onDeviceInfoUpdate);
|
|
@@ -689,6 +1141,7 @@ var state_default = class extends EventEmitter {
|
|
|
689
1141
|
dataStream.off("removeClient", this.onRemoveClient);
|
|
690
1142
|
dataStream.off("removePlayer", this.onRemovePlayer);
|
|
691
1143
|
dataStream.off("sendCommandResult", this.onSendCommandResult);
|
|
1144
|
+
dataStream.off("sendLyricsEvent", this.onSendLyricsEvent);
|
|
692
1145
|
dataStream.off("setArtwork", this.onSetArtwork);
|
|
693
1146
|
dataStream.off("setDefaultSupportedCommands", this.onSetDefaultSupportedCommands);
|
|
694
1147
|
dataStream.off("setNowPlayingClient", this.onSetNowPlayingClient);
|
|
@@ -702,7 +1155,9 @@ var state_default = class extends EventEmitter {
|
|
|
702
1155
|
dataStream.off("volumeControlAvailability", this.onVolumeControlAvailability);
|
|
703
1156
|
dataStream.off("volumeControlCapabilitiesDidChange", this.onVolumeControlCapabilitiesDidChange);
|
|
704
1157
|
dataStream.off("volumeDidChange", this.onVolumeDidChange);
|
|
1158
|
+
dataStream.off("volumeMutedDidChange", this.onVolumeMutedDidChange);
|
|
705
1159
|
}
|
|
1160
|
+
/** Resets all state to initial/default values. Called on connect and reconnect. */
|
|
706
1161
|
clear() {
|
|
707
1162
|
this.#clients = {};
|
|
708
1163
|
this.#keyboardAttributes = null;
|
|
@@ -714,127 +1169,310 @@ var state_default = class extends EventEmitter {
|
|
|
714
1169
|
this.#clusterID = null;
|
|
715
1170
|
this.#clusterType = 0;
|
|
716
1171
|
this.#isClusterAware = false;
|
|
1172
|
+
this.#isClusterLeader = false;
|
|
717
1173
|
this.#volume = 0;
|
|
718
1174
|
this.#volumeAvailable = false;
|
|
719
1175
|
this.#volumeCapabilities = Proto.VolumeCapabilities_Enum.None;
|
|
720
|
-
|
|
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
|
+
*/
|
|
721
1191
|
onKeyboard(message) {
|
|
722
1192
|
this.#keyboardState = message.state;
|
|
723
1193
|
this.#keyboardAttributes = message.attributes ?? null;
|
|
724
1194
|
this.emit("keyboard", message);
|
|
725
1195
|
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Handles initial device info. Updates output device UID and cluster info.
|
|
1198
|
+
*
|
|
1199
|
+
* @param message - The device info message.
|
|
1200
|
+
*/
|
|
726
1201
|
onDeviceInfo(message) {
|
|
727
|
-
this.#
|
|
1202
|
+
this.#updateDeviceInfo(message);
|
|
728
1203
|
this.emit("deviceInfo", message);
|
|
729
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
|
+
*/
|
|
730
1210
|
onDeviceInfoUpdate(message) {
|
|
731
|
-
this.#
|
|
1211
|
+
this.#updateDeviceInfo(message);
|
|
732
1212
|
this.emit("deviceInfoUpdate", message);
|
|
733
1213
|
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Handles origin client properties updates.
|
|
1216
|
+
*
|
|
1217
|
+
* @param message - The origin client properties message.
|
|
1218
|
+
*/
|
|
734
1219
|
onOriginClientProperties(message) {
|
|
735
1220
|
this.emit("originClientProperties", message);
|
|
736
1221
|
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Handles player client properties updates.
|
|
1224
|
+
*
|
|
1225
|
+
* @param message - The player client properties message.
|
|
1226
|
+
*/
|
|
737
1227
|
onPlayerClientProperties(message) {
|
|
738
1228
|
this.emit("playerClientProperties", message);
|
|
739
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
|
+
*/
|
|
740
1236
|
onRemoveClient(message) {
|
|
1237
|
+
if (!message.client?.bundleIdentifier) return;
|
|
741
1238
|
if (!(message.client.bundleIdentifier in this.#clients)) return;
|
|
742
1239
|
const wasActive = this.#nowPlayingClientBundleIdentifier === message.client.bundleIdentifier;
|
|
743
1240
|
delete this.#clients[message.client.bundleIdentifier];
|
|
744
1241
|
if (wasActive) this.#nowPlayingClientBundleIdentifier = null;
|
|
745
1242
|
this.emit("removeClient", message);
|
|
746
1243
|
this.emit("clients", this.#clients);
|
|
747
|
-
if (wasActive)
|
|
1244
|
+
if (wasActive) {
|
|
1245
|
+
this.#emitActivePlayerChanged();
|
|
1246
|
+
this.#emitNowPlayingChangedIfNeeded();
|
|
1247
|
+
}
|
|
748
1248
|
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Handles command result notifications from the Apple TV.
|
|
1251
|
+
*
|
|
1252
|
+
* @param message - The send command result message.
|
|
1253
|
+
*/
|
|
749
1254
|
onSendCommandResult(message) {
|
|
750
1255
|
this.emit("sendCommandResult", message);
|
|
751
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
|
+
*/
|
|
752
1270
|
onSetArtwork(message) {
|
|
753
1271
|
this.emit("setArtwork", message);
|
|
754
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
|
+
*/
|
|
755
1279
|
onSetDefaultSupportedCommands(message) {
|
|
756
1280
|
if (message.playerPath?.client?.bundleIdentifier && message.supportedCommands) this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName).setDefaultSupportedCommands(message.supportedCommands.supportedCommands);
|
|
757
1281
|
this.emit("setDefaultSupportedCommands", message);
|
|
758
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
|
+
*/
|
|
759
1289
|
onSetNowPlayingClient(message) {
|
|
1290
|
+
const oldBundleId = this.#nowPlayingClientBundleIdentifier;
|
|
760
1291
|
this.#nowPlayingClientBundleIdentifier = message.client?.bundleIdentifier ?? null;
|
|
761
1292
|
if (message.client?.bundleIdentifier && message.client?.displayName) this.#client(message.client.bundleIdentifier, message.client.displayName);
|
|
762
1293
|
this.emit("setNowPlayingClient", message);
|
|
1294
|
+
if (oldBundleId !== this.#nowPlayingClientBundleIdentifier) this.#emitActivePlayerChanged();
|
|
763
1295
|
this.#emitNowPlayingChangedIfNeeded();
|
|
764
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
|
+
*/
|
|
765
1303
|
onSetNowPlayingPlayer(message) {
|
|
766
1304
|
if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) {
|
|
767
1305
|
const client = this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName);
|
|
1306
|
+
const oldActiveId = client.activePlayer?.identifier;
|
|
768
1307
|
client.getOrCreatePlayer(message.playerPath.player.identifier, message.playerPath.player.displayName);
|
|
769
1308
|
client.setActivePlayer(message.playerPath.player.identifier);
|
|
1309
|
+
if (oldActiveId !== message.playerPath.player.identifier) this.#emitActivePlayerChanged();
|
|
770
1310
|
}
|
|
771
1311
|
this.emit("setNowPlayingPlayer", message);
|
|
772
1312
|
this.#emitNowPlayingChangedIfNeeded();
|
|
773
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
|
+
*/
|
|
774
1321
|
onSetState(message) {
|
|
775
|
-
const bundleIdentifier = message.playerPath
|
|
1322
|
+
const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
|
|
1323
|
+
if (!bundleIdentifier) return;
|
|
776
1324
|
const client = this.#client(bundleIdentifier, message.displayName);
|
|
777
1325
|
const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
|
|
778
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
|
+
}
|
|
779
1333
|
if (message.nowPlayingInfo) player.setNowPlayingInfo(message.nowPlayingInfo);
|
|
780
|
-
if (message.
|
|
781
|
-
|
|
782
|
-
|
|
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
|
+
}
|
|
783
1342
|
this.emit("setState", message);
|
|
784
|
-
if (
|
|
1343
|
+
if (isActiveClient) this.#emitNowPlayingChangedIfNeeded();
|
|
785
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Handles content item updates (metadata, artwork, lyrics changes for existing items).
|
|
1347
|
+
*
|
|
1348
|
+
* @param message - The update content item message.
|
|
1349
|
+
*/
|
|
786
1350
|
onUpdateContentItem(message) {
|
|
787
|
-
const bundleIdentifier = message.playerPath
|
|
788
|
-
|
|
1351
|
+
const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
|
|
1352
|
+
if (!bundleIdentifier) return;
|
|
1353
|
+
const client = this.#client(bundleIdentifier, message.playerPath?.client?.displayName ?? "");
|
|
789
1354
|
const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
|
|
790
1355
|
const player = client.getOrCreatePlayer(playerIdentifier, message.playerPath?.player?.displayName);
|
|
791
1356
|
for (const item of message.contentItems) player.updateContentItem(item);
|
|
792
1357
|
this.emit("updateContentItem", message);
|
|
793
1358
|
if (bundleIdentifier === this.#nowPlayingClientBundleIdentifier) this.#emitNowPlayingChangedIfNeeded();
|
|
794
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
|
+
*/
|
|
795
1365
|
onUpdateContentItemArtwork(message) {
|
|
796
1366
|
this.emit("updateContentItemArtwork", message);
|
|
797
|
-
|
|
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
|
+
*/
|
|
798
1376
|
onUpdatePlayer(message) {
|
|
799
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);
|
|
800
1378
|
this.emit("updatePlayer", message);
|
|
801
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
|
+
*/
|
|
802
1386
|
onRemovePlayer(message) {
|
|
803
1387
|
if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) {
|
|
804
1388
|
const client = this.#clients[message.playerPath.client.bundleIdentifier];
|
|
805
1389
|
if (client) client.removePlayer(message.playerPath.player.identifier);
|
|
806
1390
|
}
|
|
807
1391
|
this.emit("removePlayer", message);
|
|
808
|
-
if (message.playerPath?.client?.bundleIdentifier === this.#nowPlayingClientBundleIdentifier)
|
|
1392
|
+
if (message.playerPath?.client?.bundleIdentifier === this.#nowPlayingClientBundleIdentifier) {
|
|
1393
|
+
this.#emitActivePlayerChanged();
|
|
1394
|
+
this.#emitNowPlayingChangedIfNeeded();
|
|
1395
|
+
}
|
|
809
1396
|
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Handles client (app) registration or display name update.
|
|
1399
|
+
*
|
|
1400
|
+
* @param message - The update client message.
|
|
1401
|
+
*/
|
|
810
1402
|
onUpdateClient(message) {
|
|
1403
|
+
if (!message.client?.bundleIdentifier) return;
|
|
811
1404
|
this.#client(message.client.bundleIdentifier, message.client.displayName);
|
|
1405
|
+
this.emit("updateClient", message);
|
|
812
1406
|
this.emit("clients", this.#clients);
|
|
813
1407
|
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Handles output device list updates. Prefers cluster-aware devices when available.
|
|
1410
|
+
*
|
|
1411
|
+
* @param message - The update output device message.
|
|
1412
|
+
*/
|
|
814
1413
|
onUpdateOutputDevice(message) {
|
|
815
1414
|
this.#outputDevices = message.clusterAwareOutputDevices?.length > 0 ? message.clusterAwareOutputDevices : message.outputDevices;
|
|
816
1415
|
this.emit("updateOutputDevice", message);
|
|
817
1416
|
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Handles volume control availability changes.
|
|
1419
|
+
*
|
|
1420
|
+
* @param message - The volume control availability message.
|
|
1421
|
+
*/
|
|
818
1422
|
onVolumeControlAvailability(message) {
|
|
819
1423
|
this.#volumeAvailable = message.volumeControlAvailable;
|
|
820
1424
|
this.#volumeCapabilities = message.volumeCapabilities;
|
|
821
1425
|
this.emit("volumeControlAvailability", message.volumeControlAvailable, message.volumeCapabilities);
|
|
822
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
|
+
*/
|
|
823
1432
|
onVolumeControlCapabilitiesDidChange(message) {
|
|
1433
|
+
if (!message.capabilities) return;
|
|
824
1434
|
this.#volumeAvailable = message.capabilities.volumeControlAvailable;
|
|
825
1435
|
this.#volumeCapabilities = message.capabilities.volumeCapabilities;
|
|
826
1436
|
this.emit("volumeControlCapabilitiesDidChange", message.capabilities.volumeControlAvailable, message.capabilities.volumeCapabilities);
|
|
827
1437
|
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Handles volume level changes.
|
|
1440
|
+
*
|
|
1441
|
+
* @param message - The volume change message.
|
|
1442
|
+
*/
|
|
828
1443
|
onVolumeDidChange(message) {
|
|
829
1444
|
this.#volume = message.volume;
|
|
830
1445
|
this.emit("volumeDidChange", message.volume);
|
|
831
1446
|
}
|
|
832
|
-
|
|
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) {
|
|
833
1462
|
this.#outputDeviceUID = message.clusterID || message.deviceUID || message.uniqueIdentifier || null;
|
|
834
1463
|
this.#clusterID = message.clusterID || null;
|
|
835
1464
|
this.#clusterType = message.clusterType ?? 0;
|
|
836
1465
|
this.#isClusterAware = message.isClusterAware ?? false;
|
|
837
|
-
|
|
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
|
+
*/
|
|
838
1476
|
#client(bundleIdentifier, displayName) {
|
|
839
1477
|
if (bundleIdentifier in this.#clients) {
|
|
840
1478
|
const client = this.#clients[bundleIdentifier];
|
|
@@ -847,6 +1485,11 @@ var state_default = class extends EventEmitter {
|
|
|
847
1485
|
return client;
|
|
848
1486
|
}
|
|
849
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
|
+
*/
|
|
850
1493
|
#createNowPlayingSnapshot() {
|
|
851
1494
|
const client = this.nowPlayingClient;
|
|
852
1495
|
const player = client?.activePlayer ?? null;
|
|
@@ -859,6 +1502,7 @@ var state_default = class extends EventEmitter {
|
|
|
859
1502
|
album: player?.album ?? "",
|
|
860
1503
|
genre: player?.genre ?? "",
|
|
861
1504
|
duration: player?.duration ?? 0,
|
|
1505
|
+
playbackRate: player?.playbackRate ?? 0,
|
|
862
1506
|
shuffleMode: player?.shuffleMode ?? Proto.ShuffleMode_Enum.Unknown,
|
|
863
1507
|
repeatMode: player?.repeatMode ?? Proto.RepeatMode_Enum.Unknown,
|
|
864
1508
|
mediaType: player?.mediaType ?? Proto.ContentItemMetadata_MediaType.UnknownMediaType,
|
|
@@ -868,9 +1512,17 @@ var state_default = class extends EventEmitter {
|
|
|
868
1512
|
contentIdentifier: player?.contentIdentifier ?? "",
|
|
869
1513
|
artworkId: player?.artworkId ?? null,
|
|
870
1514
|
hasArtworkUrl: player?.artworkUrl() != null,
|
|
871
|
-
hasArtworkData: player?.currentItemArtwork != null
|
|
1515
|
+
hasArtworkData: player?.currentItemArtwork != null,
|
|
1516
|
+
isAlwaysLive: player?.nowPlayingInfo?.isAlwaysLive ?? false,
|
|
1517
|
+
isAdvertisement: player?.nowPlayingInfo?.isAdvertisement ?? false
|
|
872
1518
|
};
|
|
873
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. */
|
|
874
1526
|
#emitNowPlayingChangedIfNeeded() {
|
|
875
1527
|
const snapshot = this.#createNowPlayingSnapshot();
|
|
876
1528
|
const previous = this.#nowPlayingSnapshot;
|
|
@@ -879,25 +1531,51 @@ var state_default = class extends EventEmitter {
|
|
|
879
1531
|
const client = this.nowPlayingClient;
|
|
880
1532
|
this.emit("nowPlayingChanged", client, client?.activePlayer ?? null);
|
|
881
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
|
+
*/
|
|
882
1541
|
#snapshotsEqual(a, b) {
|
|
883
|
-
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;
|
|
884
1543
|
}
|
|
885
1544
|
};
|
|
886
1545
|
|
|
887
1546
|
//#endregion
|
|
888
1547
|
//#region src/airplay/volume.ts
|
|
1548
|
+
/** Volume adjustment step size as a fraction (0.05 = 5%). */
|
|
889
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
|
+
*/
|
|
890
1555
|
var volume_default = class {
|
|
1556
|
+
/** @returns The underlying AirPlay Protocol instance. */
|
|
891
1557
|
get #protocol() {
|
|
892
1558
|
return this.#device[PROTOCOL];
|
|
893
1559
|
}
|
|
1560
|
+
/** @returns The AirPlay state for volume and capability information. */
|
|
894
1561
|
get #state() {
|
|
895
1562
|
return this.#device.state;
|
|
896
1563
|
}
|
|
897
1564
|
#device;
|
|
1565
|
+
/**
|
|
1566
|
+
* Creates a new Volume controller.
|
|
1567
|
+
*
|
|
1568
|
+
* @param device - The AirPlay device to control volume for.
|
|
1569
|
+
*/
|
|
898
1570
|
constructor(device) {
|
|
899
1571
|
this.#device = device;
|
|
900
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
|
+
*/
|
|
901
1579
|
async down() {
|
|
902
1580
|
switch (this.#state.volumeCapabilities) {
|
|
903
1581
|
case Proto.VolumeCapabilities_Enum.Absolute:
|
|
@@ -911,6 +1589,12 @@ var volume_default = class {
|
|
|
911
1589
|
default: throw new CommandError("Volume control is not available.");
|
|
912
1590
|
}
|
|
913
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
|
+
*/
|
|
914
1598
|
async up() {
|
|
915
1599
|
switch (this.#state.volumeCapabilities) {
|
|
916
1600
|
case Proto.VolumeCapabilities_Enum.Absolute:
|
|
@@ -924,12 +1608,24 @@ var volume_default = class {
|
|
|
924
1608
|
default: throw new CommandError("Volume control is not available.");
|
|
925
1609
|
}
|
|
926
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
|
+
*/
|
|
927
1617
|
async get() {
|
|
928
1618
|
if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
|
|
929
1619
|
const response = await this.#protocol.dataStream.exchange(DataStreamMessage.getVolume(this.#state.outputDeviceUID));
|
|
930
1620
|
if (response.type === Proto.ProtocolMessage_Type.GET_VOLUME_RESULT_MESSAGE) return DataStreamMessage.getExtension(response, Proto.getVolumeResultMessage).volume;
|
|
931
1621
|
throw new CommandError("Failed to get volume.");
|
|
932
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
|
+
*/
|
|
933
1629
|
async set(volume) {
|
|
934
1630
|
if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
|
|
935
1631
|
if (![Proto.VolumeCapabilities_Enum.Absolute, Proto.VolumeCapabilities_Enum.Both].includes(this.#state.volumeCapabilities)) throw new CommandError("Absolute volume control is not available.");
|
|
@@ -941,43 +1637,78 @@ var volume_default = class {
|
|
|
941
1637
|
|
|
942
1638
|
//#endregion
|
|
943
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
|
+
*/
|
|
944
1646
|
var device_default = class extends EventEmitter {
|
|
1647
|
+
/** @returns The underlying AirPlay Protocol instance (accessed via symbol for internal use). */
|
|
945
1648
|
get [PROTOCOL]() {
|
|
946
1649
|
return this.#protocol;
|
|
947
1650
|
}
|
|
1651
|
+
/** The mDNS discovery result used to connect to this device. */
|
|
948
1652
|
get discoveryResult() {
|
|
949
1653
|
return this.#discoveryResult;
|
|
950
1654
|
}
|
|
1655
|
+
/** Updates the discovery result, e.g. when the device's address changes. */
|
|
951
1656
|
set discoveryResult(discoveryResult) {
|
|
952
1657
|
this.#discoveryResult = discoveryResult;
|
|
953
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. */
|
|
954
1678
|
get isConnected() {
|
|
955
1679
|
return this.#protocol?.controlStream?.isConnected ?? false;
|
|
956
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. */
|
|
957
1686
|
get remote() {
|
|
958
1687
|
return this.#remote;
|
|
959
1688
|
}
|
|
1689
|
+
/** The State tracker for now-playing, volume, keyboard, and output device state. */
|
|
960
1690
|
get state() {
|
|
961
1691
|
return this.#state;
|
|
962
1692
|
}
|
|
1693
|
+
/** The Volume controller for absolute and relative volume adjustments. */
|
|
963
1694
|
get volume() {
|
|
964
1695
|
return this.#volume;
|
|
965
1696
|
}
|
|
1697
|
+
/** The shared PTP timing server, if one is assigned for multi-room sync. */
|
|
966
1698
|
get timingServer() {
|
|
967
1699
|
return this.#timingServer;
|
|
968
1700
|
}
|
|
1701
|
+
/** Assigns a PTP timing server for multi-room audio synchronization. */
|
|
969
1702
|
set timingServer(timingServer) {
|
|
970
1703
|
this.#timingServer = timingServer;
|
|
971
1704
|
}
|
|
972
|
-
#boundOnClose = () => this.#onClose();
|
|
973
|
-
#boundOnError = (err) => this.#onError(err);
|
|
974
|
-
#boundOnTimeout = () => this.#onTimeout();
|
|
975
1705
|
#remote;
|
|
976
1706
|
#state;
|
|
977
1707
|
#volume;
|
|
978
1708
|
#credentials;
|
|
979
1709
|
#disconnect = false;
|
|
980
1710
|
#discoveryResult;
|
|
1711
|
+
#identity;
|
|
981
1712
|
#feedbackInterval;
|
|
982
1713
|
#keys;
|
|
983
1714
|
#playUrlProtocol;
|
|
@@ -985,26 +1716,45 @@ var device_default = class extends EventEmitter {
|
|
|
985
1716
|
#prevEventStream;
|
|
986
1717
|
#protocol;
|
|
987
1718
|
#timingServer;
|
|
988
|
-
|
|
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) {
|
|
989
1726
|
super();
|
|
990
1727
|
this.#discoveryResult = discoveryResult;
|
|
1728
|
+
this.#identity = identity;
|
|
991
1729
|
this.#remote = new remote_default(this);
|
|
992
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);
|
|
993
1734
|
this.#volume = new volume_default(this);
|
|
994
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
|
+
*/
|
|
995
1741
|
async connect() {
|
|
996
1742
|
if (this.#protocol) {
|
|
997
|
-
this.#protocol.controlStream.off("close", this
|
|
998
|
-
this.#protocol.controlStream.off("error", this
|
|
999
|
-
this.#protocol.controlStream.off("timeout", this
|
|
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 {}
|
|
1000
1749
|
}
|
|
1001
1750
|
this.#disconnect = false;
|
|
1002
1751
|
this.#state.clear();
|
|
1003
|
-
this.#protocol = new Protocol(this.#discoveryResult);
|
|
1004
|
-
this.#protocol.controlStream.on("close", this
|
|
1005
|
-
this.#protocol.controlStream.on("error", this
|
|
1006
|
-
this.#protocol.controlStream.on("timeout", 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);
|
|
1007
1756
|
await this.#protocol.connect();
|
|
1757
|
+
await this.#protocol.fetchInfo();
|
|
1008
1758
|
if (this.#credentials) this.#keys = await this.#protocol.verify.start(this.#credentials);
|
|
1009
1759
|
else {
|
|
1010
1760
|
await this.#protocol.pairing.start();
|
|
@@ -1013,6 +1763,7 @@ var device_default = class extends EventEmitter {
|
|
|
1013
1763
|
await this.#setup();
|
|
1014
1764
|
this.emit("connected");
|
|
1015
1765
|
}
|
|
1766
|
+
/** Gracefully disconnects from the device, clears intervals, and tears down all streams. */
|
|
1016
1767
|
disconnect() {
|
|
1017
1768
|
this.#disconnect = true;
|
|
1018
1769
|
if (this.#feedbackInterval) {
|
|
@@ -1022,7 +1773,9 @@ var device_default = class extends EventEmitter {
|
|
|
1022
1773
|
this.#cleanupPlayUrl();
|
|
1023
1774
|
this.#unsubscribe();
|
|
1024
1775
|
this.#protocol.disconnect();
|
|
1776
|
+
this.emit("disconnected", false);
|
|
1025
1777
|
}
|
|
1778
|
+
/** Disconnects gracefully, swallowing any errors during cleanup. */
|
|
1026
1779
|
disconnectSafely() {
|
|
1027
1780
|
try {
|
|
1028
1781
|
this.disconnect();
|
|
@@ -1030,31 +1783,73 @@ var device_default = class extends EventEmitter {
|
|
|
1030
1783
|
this.#protocol?.context?.logger?.warn("[device]", "Error during safe disconnect", err);
|
|
1031
1784
|
}
|
|
1032
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
|
+
*/
|
|
1033
1802
|
async addOutputDevices(deviceUIDs) {
|
|
1034
1803
|
await this.#protocol.dataStream.exchange(DataStreamMessage.modifyOutputContext(deviceUIDs));
|
|
1035
1804
|
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Removes devices from the current multi-room output context.
|
|
1807
|
+
*
|
|
1808
|
+
* @param deviceUIDs - UIDs of the devices to remove.
|
|
1809
|
+
*/
|
|
1036
1810
|
async removeOutputDevices(deviceUIDs) {
|
|
1037
1811
|
await this.#protocol.dataStream.exchange(DataStreamMessage.modifyOutputContext([], deviceUIDs));
|
|
1038
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
|
+
*/
|
|
1039
1818
|
async setOutputDevices(deviceUIDs) {
|
|
1040
1819
|
await this.#protocol.dataStream.exchange(DataStreamMessage.modifyOutputContext([], [], deviceUIDs));
|
|
1041
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
|
+
*/
|
|
1042
1830
|
async playUrl(url, position = 0) {
|
|
1043
1831
|
if (!this.#keys) throw new Error("Not connected. Call connect() first.");
|
|
1044
1832
|
this.#playUrlProtocol?.disconnect();
|
|
1045
|
-
const playProtocol = new Protocol(this.#discoveryResult);
|
|
1833
|
+
const playProtocol = new Protocol(this.#discoveryResult, this.#identity);
|
|
1046
1834
|
if (this.#timingServer) playProtocol.useTimingServer(this.#timingServer);
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
await playProtocol.
|
|
1052
|
-
|
|
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;
|
|
1053
1850
|
}
|
|
1054
|
-
playProtocol.controlStream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
|
|
1055
|
-
this.#playUrlProtocol = playProtocol;
|
|
1056
|
-
await playProtocol.playUrl(url, keys.sharedSecret, keys.pairingId, position);
|
|
1057
1851
|
}
|
|
1852
|
+
/** Waits for the current URL playback to finish, then cleans up the play URL protocol. */
|
|
1058
1853
|
async waitForPlaybackEnd() {
|
|
1059
1854
|
if (!this.#playUrlProtocol) return;
|
|
1060
1855
|
try {
|
|
@@ -1063,9 +1858,11 @@ var device_default = class extends EventEmitter {
|
|
|
1063
1858
|
this.#cleanupPlayUrl();
|
|
1064
1859
|
}
|
|
1065
1860
|
}
|
|
1861
|
+
/** Stops the current URL playback and cleans up the dedicated play URL protocol. */
|
|
1066
1862
|
stopPlayUrl() {
|
|
1067
1863
|
this.#cleanupPlayUrl();
|
|
1068
1864
|
}
|
|
1865
|
+
/** Stops, disconnects, and discards the dedicated play URL protocol instance. */
|
|
1069
1866
|
#cleanupPlayUrl() {
|
|
1070
1867
|
if (this.#playUrlProtocol) {
|
|
1071
1868
|
this.#playUrlProtocol.stopPlayUrl();
|
|
@@ -1073,18 +1870,41 @@ var device_default = class extends EventEmitter {
|
|
|
1073
1870
|
this.#playUrlProtocol = void 0;
|
|
1074
1871
|
}
|
|
1075
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
|
+
*/
|
|
1076
1878
|
async streamAudio(source) {
|
|
1077
1879
|
await this.#protocol.setupAudioStream(source);
|
|
1078
1880
|
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Requests the playback queue from the device.
|
|
1883
|
+
*
|
|
1884
|
+
* @param length - Maximum number of queue items to retrieve.
|
|
1885
|
+
*/
|
|
1079
1886
|
async requestPlaybackQueue(length) {
|
|
1080
1887
|
await this.#protocol.dataStream.exchange(DataStreamMessage.playbackQueueRequest(0, length));
|
|
1081
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
|
+
*/
|
|
1082
1895
|
async sendCommand(command, options) {
|
|
1083
1896
|
await this.#protocol.dataStream.exchange(DataStreamMessage.sendCommand(command, options));
|
|
1084
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
|
+
*/
|
|
1085
1904
|
setCredentials(credentials) {
|
|
1086
1905
|
this.#credentials = credentials;
|
|
1087
1906
|
}
|
|
1907
|
+
/** Sends a periodic feedback request to keep the AirPlay session alive. */
|
|
1088
1908
|
async #feedback() {
|
|
1089
1909
|
try {
|
|
1090
1910
|
await this.#protocol.feedback();
|
|
@@ -1092,43 +1912,55 @@ var device_default = class extends EventEmitter {
|
|
|
1092
1912
|
this.#protocol.context.logger.error("Feedback error", err);
|
|
1093
1913
|
}
|
|
1094
1914
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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) {
|
|
1103
1929
|
this.#protocol.context.logger.error("AirPlay error", err);
|
|
1104
1930
|
}
|
|
1105
|
-
|
|
1931
|
+
/** Handles stream timeout events by destroying the control stream. */
|
|
1932
|
+
onTimeout() {
|
|
1106
1933
|
this.#protocol.context.logger.error("AirPlay timeout");
|
|
1107
1934
|
this.#protocol.controlStream.destroy();
|
|
1108
1935
|
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Sets up encryption, event/data streams, feedback interval, and initial state subscriptions.
|
|
1938
|
+
* Called after successful pairing/verification.
|
|
1939
|
+
*/
|
|
1109
1940
|
async #setup() {
|
|
1110
1941
|
const keys = this.#keys;
|
|
1111
1942
|
this.#protocol.controlStream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
|
|
1112
1943
|
this.#unsubscribe();
|
|
1113
1944
|
if (this.#timingServer) this.#protocol.useTimingServer(this.#timingServer);
|
|
1114
1945
|
try {
|
|
1115
|
-
this.#prevDataStream?.off("error", this
|
|
1116
|
-
this.#prevDataStream?.off("timeout", this
|
|
1117
|
-
this.#prevEventStream?.off("error", this
|
|
1118
|
-
this.#prevEventStream?.off("timeout", this
|
|
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);
|
|
1119
1950
|
await this.#protocol.setupEventStream(keys.sharedSecret, keys.pairingId);
|
|
1120
1951
|
await this.#protocol.setupDataStream(keys.sharedSecret, () => this.#subscribe());
|
|
1121
|
-
this.#protocol.dataStream.on("error", this
|
|
1122
|
-
this.#protocol.dataStream.on("timeout", this
|
|
1123
|
-
this.#protocol.eventStream.on("error", this
|
|
1124
|
-
this.#protocol.eventStream.on("timeout", 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);
|
|
1125
1956
|
this.#prevDataStream = this.#protocol.dataStream;
|
|
1126
1957
|
this.#prevEventStream = this.#protocol.eventStream;
|
|
1127
1958
|
if (this.#feedbackInterval) clearInterval(this.#feedbackInterval);
|
|
1128
1959
|
this.#feedbackInterval = setInterval(async () => await this.#feedback(), FEEDBACK_INTERVAL);
|
|
1129
|
-
await this.#protocol.dataStream.exchange(DataStreamMessage.deviceInfo(keys.pairingId));
|
|
1960
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.deviceInfo(keys.pairingId, this.#protocol.context.identity));
|
|
1130
1961
|
await this.#protocol.dataStream.exchange(DataStreamMessage.setConnectionState());
|
|
1131
1962
|
await this.#protocol.dataStream.exchange(DataStreamMessage.clientUpdatesConfig(true, true, true, true, true, true));
|
|
1963
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.getState());
|
|
1132
1964
|
this.#protocol.context.logger.info("Protocol ready.");
|
|
1133
1965
|
} catch (err) {
|
|
1134
1966
|
if (this.#feedbackInterval) {
|
|
@@ -1140,9 +1972,11 @@ var device_default = class extends EventEmitter {
|
|
|
1140
1972
|
throw err;
|
|
1141
1973
|
}
|
|
1142
1974
|
}
|
|
1975
|
+
/** Subscribes the state tracker to DataStream events. */
|
|
1143
1976
|
#subscribe() {
|
|
1144
1977
|
this.#state[STATE_SUBSCRIBE_SYMBOL]();
|
|
1145
1978
|
}
|
|
1979
|
+
/** Unsubscribes the state tracker from DataStream events. */
|
|
1146
1980
|
#unsubscribe() {
|
|
1147
1981
|
try {
|
|
1148
1982
|
this.#state[STATE_UNSUBSCRIBE_SYMBOL]();
|
|
@@ -1154,140 +1988,640 @@ var device_default = class extends EventEmitter {
|
|
|
1154
1988
|
|
|
1155
1989
|
//#endregion
|
|
1156
1990
|
//#region src/companion-link/const.ts
|
|
1991
|
+
/** Symbol used to access the underlying Companion Link Protocol instance from a CompanionLinkDevice. */
|
|
1157
1992
|
const PROTOCOL$1 = Symbol("com.basmilius.companion-link:protocol");
|
|
1158
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
|
+
|
|
1159
2269
|
//#endregion
|
|
1160
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
|
+
*/
|
|
1161
2278
|
var device_default$1 = class extends EventEmitter {
|
|
2279
|
+
/** @returns The underlying Companion Link Protocol instance (accessed via symbol for internal use). */
|
|
1162
2280
|
get [PROTOCOL$1]() {
|
|
1163
2281
|
return this.#protocol;
|
|
1164
2282
|
}
|
|
2283
|
+
/** The mDNS discovery result used to connect to this device. */
|
|
1165
2284
|
get discoveryResult() {
|
|
1166
2285
|
return this.#discoveryResult;
|
|
1167
2286
|
}
|
|
2287
|
+
/** Updates the discovery result, e.g. when the device's address changes. */
|
|
1168
2288
|
set discoveryResult(discoveryResult) {
|
|
1169
2289
|
this.#discoveryResult = discoveryResult;
|
|
1170
2290
|
}
|
|
2291
|
+
/** Whether the Companion Link stream is currently connected. */
|
|
1171
2292
|
get isConnected() {
|
|
1172
2293
|
return this.#protocol?.stream?.isConnected ?? false;
|
|
1173
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
|
+
}
|
|
1174
2303
|
#credentials;
|
|
1175
2304
|
#disconnect = false;
|
|
1176
2305
|
#discoveryResult;
|
|
1177
2306
|
#heartbeatInterval;
|
|
1178
2307
|
#keys;
|
|
1179
2308
|
#protocol;
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
#onMediaControl = (data) => {
|
|
1187
|
-
this.emit("mediaControl", data);
|
|
1188
|
-
};
|
|
1189
|
-
#textInputState = {
|
|
1190
|
-
isActive: false,
|
|
1191
|
-
documentText: "",
|
|
1192
|
-
isSecure: false,
|
|
1193
|
-
keyboardType: 0,
|
|
1194
|
-
autocorrection: false,
|
|
1195
|
-
autocapitalization: false
|
|
1196
|
-
};
|
|
2309
|
+
#state;
|
|
2310
|
+
/**
|
|
2311
|
+
* Creates a new CompanionLinkDevice.
|
|
2312
|
+
*
|
|
2313
|
+
* @param discoveryResult - The mDNS discovery result for the target device.
|
|
2314
|
+
*/
|
|
1197
2315
|
constructor(discoveryResult) {
|
|
1198
2316
|
super();
|
|
1199
2317
|
this.#discoveryResult = discoveryResult;
|
|
1200
|
-
this.
|
|
1201
|
-
this.
|
|
1202
|
-
|
|
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
|
+
*/
|
|
1203
2329
|
async connect() {
|
|
1204
2330
|
if (!this.#credentials) throw new CredentialsError("Credentials are required to connect to a Companion Link device.");
|
|
1205
2331
|
if (this.#protocol) {
|
|
1206
|
-
this.#protocol.stream.off("close", this
|
|
1207
|
-
this.#protocol.stream.off("error", this
|
|
1208
|
-
this.#protocol.stream.off("timeout", this
|
|
2332
|
+
this.#protocol.stream.off("close", this.onClose);
|
|
2333
|
+
this.#protocol.stream.off("error", this.onError);
|
|
2334
|
+
this.#protocol.stream.off("timeout", this.onTimeout);
|
|
1209
2335
|
}
|
|
1210
2336
|
this.#disconnect = false;
|
|
1211
2337
|
this.#protocol = new Protocol$1(this.#discoveryResult);
|
|
1212
|
-
this.#protocol.stream.on("close", this
|
|
1213
|
-
this.#protocol.stream.on("error", this
|
|
1214
|
-
this.#protocol.stream.on("timeout", this
|
|
2338
|
+
this.#protocol.stream.on("close", this.onClose);
|
|
2339
|
+
this.#protocol.stream.on("error", this.onError);
|
|
2340
|
+
this.#protocol.stream.on("timeout", this.onTimeout);
|
|
1215
2341
|
await this.#protocol.connect();
|
|
1216
2342
|
this.#keys = await this.#protocol.verify.start(this.#credentials);
|
|
1217
2343
|
await this.#setup();
|
|
1218
2344
|
this.emit("connected");
|
|
1219
2345
|
}
|
|
2346
|
+
/** Gracefully disconnects from the device, clears heartbeat interval, and unsubscribes from events. */
|
|
1220
2347
|
async disconnect() {
|
|
1221
2348
|
this.#disconnect = true;
|
|
1222
2349
|
if (this.#heartbeatInterval) {
|
|
1223
2350
|
clearInterval(this.#heartbeatInterval);
|
|
1224
2351
|
this.#heartbeatInterval = void 0;
|
|
1225
2352
|
}
|
|
1226
|
-
|
|
2353
|
+
this.#state?.unsubscribe();
|
|
1227
2354
|
await this.#protocol.disconnect();
|
|
1228
2355
|
}
|
|
2356
|
+
/** Disconnects gracefully, swallowing any errors during cleanup. */
|
|
1229
2357
|
async disconnectSafely() {
|
|
1230
2358
|
try {
|
|
1231
2359
|
await this.disconnect();
|
|
1232
|
-
} catch
|
|
2360
|
+
} catch {}
|
|
1233
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
|
+
*/
|
|
1234
2368
|
async setCredentials(credentials) {
|
|
1235
2369
|
this.#credentials = credentials;
|
|
1236
2370
|
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Fetches the current attention state of the device (active, idle, screensaver, etc.).
|
|
2373
|
+
*
|
|
2374
|
+
* @returns The current attention state.
|
|
2375
|
+
*/
|
|
1237
2376
|
async getAttentionState() {
|
|
1238
2377
|
return await this.#protocol.getAttentionState();
|
|
1239
2378
|
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Fetches the list of apps that can be launched on the device.
|
|
2381
|
+
*
|
|
2382
|
+
* @returns Array of launchable app descriptors.
|
|
2383
|
+
*/
|
|
1240
2384
|
async getLaunchableApps() {
|
|
1241
2385
|
return await this.#protocol.getLaunchableApps();
|
|
1242
2386
|
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Fetches the list of user accounts configured on the device.
|
|
2389
|
+
*
|
|
2390
|
+
* @returns Array of user account descriptors.
|
|
2391
|
+
*/
|
|
1243
2392
|
async getUserAccounts() {
|
|
1244
2393
|
return await this.#protocol.getUserAccounts();
|
|
1245
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
|
+
*/
|
|
1246
2424
|
async launchApp(bundleId) {
|
|
1247
2425
|
await this.#protocol.launchApp(bundleId);
|
|
1248
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
|
+
*/
|
|
1249
2432
|
async launchUrl(url) {
|
|
1250
2433
|
await this.#protocol.launchUrl(url);
|
|
1251
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
|
+
*/
|
|
1252
2441
|
async mediaControlCommand(command, content) {
|
|
1253
2442
|
await this.#protocol.mediaControlCommand(command, content);
|
|
1254
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
|
+
*/
|
|
1255
2451
|
async pressButton(command, type, holdDelayMs) {
|
|
1256
2452
|
await this.#protocol.pressButton(command, type, holdDelayMs);
|
|
1257
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
|
+
*/
|
|
1258
2459
|
async switchUserAccount(accountId) {
|
|
1259
2460
|
await this.#protocol.switchUserAccount(accountId);
|
|
1260
2461
|
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Sets the text input field to the given text, replacing any existing content.
|
|
2464
|
+
*
|
|
2465
|
+
* @param text - The text to set.
|
|
2466
|
+
*/
|
|
1261
2467
|
async textSet(text) {
|
|
1262
2468
|
await this.#protocol.textInputCommand(text, true);
|
|
1263
2469
|
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Appends text to the current text input field content.
|
|
2472
|
+
*
|
|
2473
|
+
* @param text - The text to append.
|
|
2474
|
+
*/
|
|
1264
2475
|
async textAppend(text) {
|
|
1265
2476
|
await this.#protocol.textInputCommand(text, false);
|
|
1266
2477
|
}
|
|
2478
|
+
/** Clears the text input field. */
|
|
1267
2479
|
async textClear() {
|
|
1268
2480
|
await this.#protocol.textInputCommand("", true);
|
|
1269
2481
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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));
|
|
1275
2545
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
await this.#protocol.
|
|
1290
|
-
}
|
|
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
|
+
*/
|
|
1291
2625
|
async #setup() {
|
|
1292
2626
|
const keys = this.#keys;
|
|
1293
2627
|
this.#protocol.stream.enableEncryption(keys.accessoryToControllerKey, keys.controllerToAccessoryKey);
|
|
@@ -1298,158 +2632,144 @@ var device_default$1 = class extends EventEmitter {
|
|
|
1298
2632
|
await this.#protocol.touchStart();
|
|
1299
2633
|
await this.#protocol.tiStart();
|
|
1300
2634
|
this.#heartbeatInterval = setInterval(() => {
|
|
1301
|
-
|
|
2635
|
+
try {
|
|
2636
|
+
this.#protocol.noOp();
|
|
2637
|
+
} catch (err) {
|
|
1302
2638
|
this.#protocol.context.logger.error("Heartbeat failed", err);
|
|
1303
|
-
}
|
|
2639
|
+
}
|
|
1304
2640
|
}, 15e3);
|
|
1305
|
-
|
|
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();
|
|
1306
2651
|
} catch (err) {
|
|
1307
2652
|
clearInterval(this.#heartbeatInterval);
|
|
1308
2653
|
this.#heartbeatInterval = void 0;
|
|
1309
2654
|
throw err;
|
|
1310
2655
|
}
|
|
1311
2656
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
this.#protocol.
|
|
1315
|
-
this.#
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
this
|
|
1319
|
-
"_iMC",
|
|
1320
|
-
"SystemStatus",
|
|
1321
|
-
"TVSystemStatus"
|
|
1322
|
-
]);
|
|
1323
|
-
const state = await this.getAttentionState();
|
|
1324
|
-
this.emit("power", state);
|
|
1325
|
-
}
|
|
1326
|
-
async #unsubscribe() {
|
|
1327
|
-
this.#protocol.stream.off("_iMC", this.#onMediaControl);
|
|
1328
|
-
this.#protocol.stream.off("SystemStatus", this.onSystemStatus);
|
|
1329
|
-
this.#protocol.stream.off("TVSystemStatus", this.onTVSystemStatus);
|
|
1330
|
-
this.#protocol.stream.off("_tiStarted", this.#onTextInputStarted);
|
|
1331
|
-
this.#protocol.stream.off("_tiStopped", this.#onTextInputStopped);
|
|
1332
|
-
try {
|
|
1333
|
-
this.#protocol.deregisterInterests([
|
|
1334
|
-
"_iMC",
|
|
1335
|
-
"SystemStatus",
|
|
1336
|
-
"TVSystemStatus"
|
|
1337
|
-
]);
|
|
1338
|
-
} catch (_) {}
|
|
1339
|
-
}
|
|
1340
|
-
#onTextInputStarted = async (data) => {
|
|
1341
|
-
try {
|
|
1342
|
-
const payload = data;
|
|
1343
|
-
let documentText = "";
|
|
1344
|
-
let isSecure = false;
|
|
1345
|
-
let keyboardType = 0;
|
|
1346
|
-
let autocorrection = false;
|
|
1347
|
-
let autocapitalization = false;
|
|
1348
|
-
if (payload?._tiD) {
|
|
1349
|
-
const plistData = Plist.parse(Buffer.from(payload._tiD).buffer);
|
|
1350
|
-
documentText = plistData._tiDT ?? "";
|
|
1351
|
-
isSecure = plistData._tiSR ?? false;
|
|
1352
|
-
keyboardType = plistData._tiKT ?? 0;
|
|
1353
|
-
autocorrection = plistData._tiAC ?? false;
|
|
1354
|
-
autocapitalization = plistData._tiAP ?? false;
|
|
1355
|
-
}
|
|
1356
|
-
this.#textInputState = {
|
|
1357
|
-
isActive: true,
|
|
1358
|
-
documentText,
|
|
1359
|
-
isSecure,
|
|
1360
|
-
keyboardType,
|
|
1361
|
-
autocorrection,
|
|
1362
|
-
autocapitalization
|
|
1363
|
-
};
|
|
1364
|
-
this.emit("textInput", this.#textInputState);
|
|
1365
|
-
} catch (err) {
|
|
1366
|
-
this.#protocol.context.logger.error("Text input started parse error", err);
|
|
1367
|
-
}
|
|
1368
|
-
};
|
|
1369
|
-
#onTextInputStopped = async (_data) => {
|
|
1370
|
-
this.#textInputState = {
|
|
1371
|
-
isActive: false,
|
|
1372
|
-
documentText: "",
|
|
1373
|
-
isSecure: false,
|
|
1374
|
-
keyboardType: 0,
|
|
1375
|
-
autocorrection: false,
|
|
1376
|
-
autocapitalization: false
|
|
1377
|
-
};
|
|
1378
|
-
this.emit("textInput", this.#textInputState);
|
|
1379
|
-
};
|
|
1380
|
-
async onSystemStatus(data) {
|
|
1381
|
-
this.#protocol.context.logger.info("System Status", data);
|
|
1382
|
-
this.emit("power", convertAttentionState(data.state));
|
|
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);
|
|
1383
2664
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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);
|
|
2672
|
+
}
|
|
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();
|
|
1387
2677
|
}
|
|
1388
2678
|
};
|
|
1389
2679
|
|
|
1390
2680
|
//#endregion
|
|
1391
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
|
+
*/
|
|
1392
2687
|
var apple_tv_default = class extends EventEmitter {
|
|
2688
|
+
/** The underlying AirPlay device for direct protocol access. */
|
|
1393
2689
|
get airplay() {
|
|
1394
2690
|
return this.#airplay;
|
|
1395
2691
|
}
|
|
2692
|
+
/** The underlying Companion Link device for direct protocol access. */
|
|
1396
2693
|
get companionLink() {
|
|
1397
2694
|
return this.#companionLink;
|
|
1398
2695
|
}
|
|
2696
|
+
/** AirPlay remote controller for HID keys and commands. */
|
|
1399
2697
|
get remote() {
|
|
1400
2698
|
return this.#airplay.remote;
|
|
1401
2699
|
}
|
|
2700
|
+
/** AirPlay state tracker for now-playing, volume, and keyboard. */
|
|
1402
2701
|
get state() {
|
|
1403
2702
|
return this.#airplay.state;
|
|
1404
2703
|
}
|
|
2704
|
+
/** AirPlay volume controller. */
|
|
1405
2705
|
get volumeControl() {
|
|
1406
2706
|
return this.#airplay.volume;
|
|
1407
2707
|
}
|
|
2708
|
+
/** Bundle identifier of the currently playing app, or null. */
|
|
1408
2709
|
get bundleIdentifier() {
|
|
1409
2710
|
return this.#nowPlayingClient?.bundleIdentifier ?? null;
|
|
1410
2711
|
}
|
|
2712
|
+
/** Display name of the currently playing app, or null. */
|
|
1411
2713
|
get displayName() {
|
|
1412
2714
|
return this.#nowPlayingClient?.displayName ?? null;
|
|
1413
2715
|
}
|
|
2716
|
+
/** Whether both AirPlay and Companion Link are connected. */
|
|
1414
2717
|
get isConnected() {
|
|
1415
2718
|
return this.#airplay.isConnected && this.#companionLink.isConnected;
|
|
1416
2719
|
}
|
|
2720
|
+
/** Whether the active player is currently playing. */
|
|
1417
2721
|
get isPlaying() {
|
|
1418
2722
|
return this.#nowPlayingClient?.isPlaying ?? false;
|
|
1419
2723
|
}
|
|
2724
|
+
/** Current track title. */
|
|
1420
2725
|
get title() {
|
|
1421
2726
|
return this.#nowPlayingClient?.title ?? "";
|
|
1422
2727
|
}
|
|
2728
|
+
/** Current track artist. */
|
|
1423
2729
|
get artist() {
|
|
1424
2730
|
return this.#nowPlayingClient?.artist ?? "";
|
|
1425
2731
|
}
|
|
2732
|
+
/** Current track album. */
|
|
1426
2733
|
get album() {
|
|
1427
2734
|
return this.#nowPlayingClient?.album ?? "";
|
|
1428
2735
|
}
|
|
2736
|
+
/** Duration of the current track in seconds. */
|
|
1429
2737
|
get duration() {
|
|
1430
2738
|
return this.#nowPlayingClient?.duration ?? 0;
|
|
1431
2739
|
}
|
|
2740
|
+
/** Extrapolated elapsed time in seconds. */
|
|
1432
2741
|
get elapsedTime() {
|
|
1433
2742
|
return this.#nowPlayingClient?.elapsedTime ?? 0;
|
|
1434
2743
|
}
|
|
2744
|
+
/** Current playback queue from the active player. */
|
|
1435
2745
|
get playbackQueue() {
|
|
1436
2746
|
return this.#nowPlayingClient?.playbackQueue ?? null;
|
|
1437
2747
|
}
|
|
2748
|
+
/** Current playback state. */
|
|
1438
2749
|
get playbackState() {
|
|
1439
2750
|
return this.#nowPlayingClient?.playbackState ?? AirPlay.Proto.PlaybackState_Enum.Unknown;
|
|
1440
2751
|
}
|
|
2752
|
+
/** Timestamp of the last playback state update. */
|
|
1441
2753
|
get playbackStateTimestamp() {
|
|
1442
2754
|
return this.#nowPlayingClient?.playbackStateTimestamp ?? -1;
|
|
1443
2755
|
}
|
|
2756
|
+
/** Current volume level (0.0 - 1.0). */
|
|
1444
2757
|
get volume() {
|
|
1445
2758
|
return this.#airplay.state.volume ?? 0;
|
|
1446
2759
|
}
|
|
2760
|
+
/** @returns The currently active now-playing client, or null. */
|
|
1447
2761
|
get #nowPlayingClient() {
|
|
1448
2762
|
return this.#airplay.state.nowPlayingClient;
|
|
1449
2763
|
}
|
|
1450
2764
|
#airplay;
|
|
1451
2765
|
#companionLink;
|
|
1452
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
|
+
*/
|
|
1453
2773
|
constructor(airplayDiscoveryResult, companionLinkDiscoveryResult) {
|
|
1454
2774
|
super();
|
|
1455
2775
|
this.#airplay = new device_default(airplayDiscoveryResult);
|
|
@@ -1458,9 +2778,16 @@ var apple_tv_default = class extends EventEmitter {
|
|
|
1458
2778
|
this.#airplay.on("disconnected", (unexpected) => this.#onDisconnected(unexpected));
|
|
1459
2779
|
this.#companionLink.on("connected", () => this.#onConnected());
|
|
1460
2780
|
this.#companionLink.on("disconnected", (unexpected) => this.#onDisconnected(unexpected));
|
|
1461
|
-
this.#companionLink.on("
|
|
1462
|
-
this.#companionLink.on("
|
|
1463
|
-
}
|
|
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
|
+
*/
|
|
1464
2791
|
async connect(airplayCredentials, companionLinkCredentials) {
|
|
1465
2792
|
this.#airplay.setCredentials(airplayCredentials);
|
|
1466
2793
|
await this.#companionLink.setCredentials(companionLinkCredentials ?? airplayCredentials);
|
|
@@ -1473,81 +2800,148 @@ var apple_tv_default = class extends EventEmitter {
|
|
|
1473
2800
|
}
|
|
1474
2801
|
this.#disconnect = false;
|
|
1475
2802
|
}
|
|
2803
|
+
/** Disconnects both AirPlay and Companion Link protocols. */
|
|
1476
2804
|
async disconnect() {
|
|
1477
2805
|
await this.#airplay.disconnect();
|
|
1478
2806
|
await this.#companionLink.disconnect();
|
|
1479
2807
|
}
|
|
2808
|
+
/** Puts the Apple TV to sleep via a suspend HID key press. */
|
|
1480
2809
|
async turnOff() {
|
|
1481
2810
|
await this.#airplay.remote.suspend();
|
|
1482
2811
|
}
|
|
2812
|
+
/** Wakes the Apple TV from sleep via a wake HID key press. */
|
|
1483
2813
|
async turnOn() {
|
|
1484
2814
|
await this.#airplay.remote.wake();
|
|
1485
2815
|
}
|
|
2816
|
+
/** Sends a Pause command. */
|
|
1486
2817
|
async pause() {
|
|
1487
2818
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.Pause);
|
|
1488
2819
|
}
|
|
2820
|
+
/** Sends a TogglePlayPause command. */
|
|
1489
2821
|
async playPause() {
|
|
1490
2822
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.TogglePlayPause);
|
|
1491
2823
|
}
|
|
2824
|
+
/** Sends a Play command. */
|
|
1492
2825
|
async play() {
|
|
1493
2826
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.Play);
|
|
1494
2827
|
}
|
|
2828
|
+
/** Sends a Stop command. */
|
|
1495
2829
|
async stop() {
|
|
1496
2830
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.Stop);
|
|
1497
2831
|
}
|
|
2832
|
+
/** Sends a NextInContext command (next track/episode). */
|
|
1498
2833
|
async next() {
|
|
1499
2834
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.NextInContext);
|
|
1500
2835
|
}
|
|
2836
|
+
/** Sends a PreviousInContext command (previous track/episode). */
|
|
1501
2837
|
async previous() {
|
|
1502
2838
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.PreviousInContext);
|
|
1503
2839
|
}
|
|
2840
|
+
/** Decreases volume via HID volume down key. */
|
|
1504
2841
|
async volumeDown() {
|
|
1505
2842
|
await this.#airplay.remote.volumeDown();
|
|
1506
2843
|
}
|
|
2844
|
+
/** Toggles mute via HID mute key. */
|
|
1507
2845
|
async volumeMute() {
|
|
1508
2846
|
await this.#airplay.remote.mute();
|
|
1509
2847
|
}
|
|
2848
|
+
/** Increases volume via HID volume up key. */
|
|
1510
2849
|
async volumeUp() {
|
|
1511
2850
|
await this.#airplay.remote.volumeUp();
|
|
1512
2851
|
}
|
|
2852
|
+
/**
|
|
2853
|
+
* Fetches the current attention state via Companion Link.
|
|
2854
|
+
*
|
|
2855
|
+
* @returns The current attention state.
|
|
2856
|
+
*/
|
|
1513
2857
|
async getAttentionState() {
|
|
1514
2858
|
return await this.#companionLink.getAttentionState();
|
|
1515
2859
|
}
|
|
2860
|
+
/**
|
|
2861
|
+
* Fetches the list of launchable apps via Companion Link.
|
|
2862
|
+
*
|
|
2863
|
+
* @returns Array of launchable app descriptors.
|
|
2864
|
+
*/
|
|
1516
2865
|
async getLaunchableApps() {
|
|
1517
2866
|
return await this.#companionLink.getLaunchableApps();
|
|
1518
2867
|
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Fetches user accounts configured on the device via Companion Link.
|
|
2870
|
+
*
|
|
2871
|
+
* @returns Array of user account descriptors.
|
|
2872
|
+
*/
|
|
1519
2873
|
async getUserAccounts() {
|
|
1520
2874
|
return await this.#companionLink.getUserAccounts();
|
|
1521
2875
|
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Launches an app via Companion Link.
|
|
2878
|
+
*
|
|
2879
|
+
* @param bundleId - The bundle identifier of the app to launch.
|
|
2880
|
+
*/
|
|
1522
2881
|
async launchApp(bundleId) {
|
|
1523
2882
|
await this.#companionLink.launchApp(bundleId);
|
|
1524
2883
|
}
|
|
2884
|
+
/**
|
|
2885
|
+
* Switches user account via Companion Link.
|
|
2886
|
+
*
|
|
2887
|
+
* @param accountId - The ID of the user account to switch to.
|
|
2888
|
+
*/
|
|
1525
2889
|
async switchUserAccount(accountId) {
|
|
1526
2890
|
await this.#companionLink.switchUserAccount(accountId);
|
|
1527
2891
|
}
|
|
2892
|
+
/**
|
|
2893
|
+
* Sets the text input field to the given text via Companion Link.
|
|
2894
|
+
*
|
|
2895
|
+
* @param text - The text to set.
|
|
2896
|
+
*/
|
|
1528
2897
|
async textSet(text) {
|
|
1529
2898
|
await this.#companionLink.textSet(text);
|
|
1530
2899
|
}
|
|
2900
|
+
/**
|
|
2901
|
+
* Appends text to the text input field via Companion Link.
|
|
2902
|
+
*
|
|
2903
|
+
* @param text - The text to append.
|
|
2904
|
+
*/
|
|
1531
2905
|
async textAppend(text) {
|
|
1532
2906
|
await this.#companionLink.textAppend(text);
|
|
1533
2907
|
}
|
|
2908
|
+
/** Clears the text input field via Companion Link. */
|
|
1534
2909
|
async textClear() {
|
|
1535
2910
|
await this.#companionLink.textClear();
|
|
1536
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
|
+
*/
|
|
1537
2918
|
async getCommandInfo(command) {
|
|
1538
2919
|
const client = this.#airplay.state.nowPlayingClient;
|
|
1539
2920
|
if (!client) return null;
|
|
1540
2921
|
return client.supportedCommands.find((c) => c.command === command) ?? null;
|
|
1541
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
|
+
*/
|
|
1542
2929
|
async isCommandSupported(command) {
|
|
1543
2930
|
const client = this.#airplay.state.nowPlayingClient;
|
|
1544
2931
|
if (!client) return false;
|
|
1545
2932
|
return client.isCommandSupported(command);
|
|
1546
2933
|
}
|
|
2934
|
+
/** Emits 'connected' when both AirPlay and Companion Link are connected. */
|
|
1547
2935
|
async #onConnected() {
|
|
1548
2936
|
if (!this.#airplay.isConnected || !this.#companionLink.isConnected) return;
|
|
1549
2937
|
this.emit("connected");
|
|
1550
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
|
+
*/
|
|
1551
2945
|
async #onDisconnected(unexpected) {
|
|
1552
2946
|
if (this.#disconnect) return;
|
|
1553
2947
|
this.#disconnect = true;
|
|
@@ -1558,119 +2952,187 @@ var apple_tv_default = class extends EventEmitter {
|
|
|
1558
2952
|
|
|
1559
2953
|
//#endregion
|
|
1560
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
|
+
*/
|
|
1561
2960
|
var homepod_base_default = class extends EventEmitter {
|
|
2961
|
+
/** The underlying AirPlay device for direct protocol access. */
|
|
1562
2962
|
get airplay() {
|
|
1563
2963
|
return this.#airplay;
|
|
1564
2964
|
}
|
|
2965
|
+
/** AirPlay remote controller for HID keys and commands. */
|
|
1565
2966
|
get remote() {
|
|
1566
2967
|
return this.#airplay.remote;
|
|
1567
2968
|
}
|
|
2969
|
+
/** AirPlay state tracker for now-playing and volume. */
|
|
1568
2970
|
get state() {
|
|
1569
2971
|
return this.#airplay.state;
|
|
1570
2972
|
}
|
|
2973
|
+
/** AirPlay volume controller. */
|
|
1571
2974
|
get volumeControl() {
|
|
1572
2975
|
return this.#airplay.volume;
|
|
1573
2976
|
}
|
|
2977
|
+
/** Bundle identifier of the currently playing app, or null. */
|
|
1574
2978
|
get bundleIdentifier() {
|
|
1575
2979
|
return this.#nowPlayingClient?.bundleIdentifier ?? null;
|
|
1576
2980
|
}
|
|
2981
|
+
/** Display name of the currently playing app, or null. */
|
|
1577
2982
|
get displayName() {
|
|
1578
2983
|
return this.#nowPlayingClient?.displayName ?? null;
|
|
1579
2984
|
}
|
|
2985
|
+
/** Whether the AirPlay connection is active. */
|
|
1580
2986
|
get isConnected() {
|
|
1581
2987
|
return this.#airplay.isConnected;
|
|
1582
2988
|
}
|
|
2989
|
+
/** Whether the active player is currently playing. */
|
|
1583
2990
|
get isPlaying() {
|
|
1584
2991
|
return this.#nowPlayingClient?.isPlaying ?? false;
|
|
1585
2992
|
}
|
|
2993
|
+
/** Current track title. */
|
|
1586
2994
|
get title() {
|
|
1587
2995
|
return this.#nowPlayingClient?.title ?? "";
|
|
1588
2996
|
}
|
|
2997
|
+
/** Current track artist. */
|
|
1589
2998
|
get artist() {
|
|
1590
2999
|
return this.#nowPlayingClient?.artist ?? "";
|
|
1591
3000
|
}
|
|
3001
|
+
/** Current track album. */
|
|
1592
3002
|
get album() {
|
|
1593
3003
|
return this.#nowPlayingClient?.album ?? "";
|
|
1594
3004
|
}
|
|
3005
|
+
/** Duration of the current track in seconds. */
|
|
1595
3006
|
get duration() {
|
|
1596
3007
|
return this.#nowPlayingClient?.duration ?? 0;
|
|
1597
3008
|
}
|
|
3009
|
+
/** Extrapolated elapsed time in seconds. */
|
|
1598
3010
|
get elapsedTime() {
|
|
1599
3011
|
return this.#nowPlayingClient?.elapsedTime ?? 0;
|
|
1600
3012
|
}
|
|
3013
|
+
/** Current playback queue from the active player. */
|
|
1601
3014
|
get playbackQueue() {
|
|
1602
3015
|
return this.#nowPlayingClient?.playbackQueue ?? null;
|
|
1603
3016
|
}
|
|
3017
|
+
/** Current playback state. */
|
|
1604
3018
|
get playbackState() {
|
|
1605
3019
|
return this.#nowPlayingClient?.playbackState ?? AirPlay.Proto.PlaybackState_Enum.Unknown;
|
|
1606
3020
|
}
|
|
3021
|
+
/** Timestamp of the last playback state update. */
|
|
1607
3022
|
get playbackStateTimestamp() {
|
|
1608
3023
|
return this.#nowPlayingClient?.playbackStateTimestamp ?? -1;
|
|
1609
3024
|
}
|
|
3025
|
+
/** Current volume level (0.0 - 1.0). */
|
|
1610
3026
|
get volume() {
|
|
1611
3027
|
return this.#airplay.state.volume ?? 0;
|
|
1612
3028
|
}
|
|
3029
|
+
/** @returns The currently active now-playing client, or null. */
|
|
1613
3030
|
get #nowPlayingClient() {
|
|
1614
3031
|
return this.#airplay.state.nowPlayingClient;
|
|
1615
3032
|
}
|
|
1616
3033
|
#airplay;
|
|
1617
3034
|
#disconnect = false;
|
|
3035
|
+
/**
|
|
3036
|
+
* Creates a new HomePod base instance.
|
|
3037
|
+
*
|
|
3038
|
+
* @param discoveryResult - The mDNS discovery result for the AirPlay service.
|
|
3039
|
+
*/
|
|
1618
3040
|
constructor(discoveryResult) {
|
|
1619
3041
|
super();
|
|
1620
3042
|
this.#airplay = new device_default(discoveryResult);
|
|
1621
3043
|
this.#airplay.on("connected", () => this.#onConnected());
|
|
1622
3044
|
this.#airplay.on("disconnected", (unexpected) => this.#onDisconnected(unexpected));
|
|
1623
3045
|
}
|
|
3046
|
+
/** Connects to the HomePod via AirPlay (transient pairing). */
|
|
1624
3047
|
async connect() {
|
|
1625
3048
|
await this.#airplay.connect();
|
|
1626
3049
|
this.#disconnect = false;
|
|
1627
3050
|
}
|
|
3051
|
+
/** Disconnects from the HomePod. */
|
|
1628
3052
|
async disconnect() {
|
|
1629
3053
|
await this.#airplay.disconnect();
|
|
1630
3054
|
}
|
|
3055
|
+
/** Sends a Pause command. */
|
|
1631
3056
|
async pause() {
|
|
1632
3057
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.Pause);
|
|
1633
3058
|
}
|
|
3059
|
+
/** Sends a TogglePlayPause command. */
|
|
1634
3060
|
async playPause() {
|
|
1635
3061
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.TogglePlayPause);
|
|
1636
3062
|
}
|
|
3063
|
+
/** Sends a Play command. */
|
|
1637
3064
|
async play() {
|
|
1638
3065
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.Play);
|
|
1639
3066
|
}
|
|
3067
|
+
/** Sends a Stop command. */
|
|
1640
3068
|
async stop() {
|
|
1641
3069
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.Stop);
|
|
1642
3070
|
}
|
|
3071
|
+
/** Sends a NextInContext command (next track). */
|
|
1643
3072
|
async next() {
|
|
1644
3073
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.NextInContext);
|
|
1645
3074
|
}
|
|
3075
|
+
/** Sends a PreviousInContext command (previous track). */
|
|
1646
3076
|
async previous() {
|
|
1647
3077
|
await this.#airplay.sendCommand(AirPlay.Proto.Command.PreviousInContext);
|
|
1648
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
|
+
*/
|
|
1649
3085
|
async playUrl(url, position = 0) {
|
|
1650
3086
|
await this.#airplay.playUrl(url, position);
|
|
1651
3087
|
}
|
|
3088
|
+
/** Waits for the current URL playback to finish. */
|
|
1652
3089
|
async waitForPlaybackEnd() {
|
|
1653
3090
|
await this.#airplay.waitForPlaybackEnd();
|
|
1654
3091
|
}
|
|
3092
|
+
/** Stops the current URL playback and cleans up. */
|
|
1655
3093
|
stopPlayUrl() {
|
|
1656
3094
|
this.#airplay.stopPlayUrl();
|
|
1657
3095
|
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Streams audio from a source to the HomePod via RAOP/RTP.
|
|
3098
|
+
*
|
|
3099
|
+
* @param source - The audio source to stream.
|
|
3100
|
+
*/
|
|
1658
3101
|
async streamAudio(source) {
|
|
1659
3102
|
await this.#airplay.streamAudio(source);
|
|
1660
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
|
+
*/
|
|
1661
3110
|
async getCommandInfo(command) {
|
|
1662
3111
|
const client = this.#airplay.state.nowPlayingClient;
|
|
1663
3112
|
if (!client) return null;
|
|
1664
3113
|
return client.supportedCommands.find((c) => c.command === command) ?? null;
|
|
1665
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
|
+
*/
|
|
1666
3121
|
async isCommandSupported(command) {
|
|
1667
3122
|
const client = this.#airplay.state.nowPlayingClient;
|
|
1668
3123
|
if (!client) return false;
|
|
1669
3124
|
return client.isCommandSupported(command);
|
|
1670
3125
|
}
|
|
3126
|
+
/** Emits 'connected' when the AirPlay connection is established. */
|
|
1671
3127
|
async #onConnected() {
|
|
1672
3128
|
this.emit("connected");
|
|
1673
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
|
+
*/
|
|
1674
3136
|
async #onDisconnected(unexpected) {
|
|
1675
3137
|
if (this.#disconnect) return;
|
|
1676
3138
|
this.#disconnect = true;
|
|
@@ -1681,10 +3143,18 @@ var homepod_base_default = class extends EventEmitter {
|
|
|
1681
3143
|
|
|
1682
3144
|
//#endregion
|
|
1683
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
|
+
*/
|
|
1684
3150
|
var homepod_default = class extends homepod_base_default {};
|
|
1685
3151
|
|
|
1686
3152
|
//#endregion
|
|
1687
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
|
+
*/
|
|
1688
3158
|
var homepod_mini_default = class extends homepod_base_default {};
|
|
1689
3159
|
|
|
1690
3160
|
//#endregion
|