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