@corewire/airplay 0.0.0 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mrp.mjs ADDED
@@ -0,0 +1,2112 @@
1
+ import { t as __exportAll } from "./chunk-w6R9maHv.mjs";
2
+ import { $ as NotificationMessageSchema, A as sendPackedVirtualTouchEventMessage, At as ShuffleMode_Enum, B as remoteTextInputMessage, Bt as resolveIdentity, C as SetConnectionStateMessageSchema, Ct as AudioFadeMessageSchema, D as VirtualTouchEventSchema, Dt as DeviceClass_Enum, E as SendVirtualTouchEventMessageSchema, Et as deviceInfoMessage, F as SendButtonEventMessageSchema, Ft as ProtocolMessage_Type, G as playbackSessionMigrateEndMessage, H as registerHIDDeviceMessage, I as sendButtonEventMessage, It as getExtension$1, J as sendCommandResultMessage, K as HandlerReturnStatus_Enum, L as RequestGroupSessionMessageSchema, Lt as setExtension, M as sendHIDEventMessage, Mt as adjustVolumeMessage, N as SendCommandMessageSchema, Nt as ErrorCode_Enum, O as sendVirtualTouchEventMessage, Ot as PlaybackState_Enum, P as sendCommandMessage, Pt as ProtocolMessageSchema, Q as playbackQueueRequestMessage, R as requestGroupSessionMessage, S as setConversationDetectionEnabledMessage, St as clientUpdatesConfigMessage, T as setConnectionStateMessage, Tt as DeviceInfoMessageSchema, U as VirtualTouchDeviceDescriptorSchema, V as RegisterHIDDeviceMessageSchema, W as PlaybackSessionMigrateEndMessageSchema, X as playbackSessionMigrateBeginMessage, Y as PlaybackSessionMigrateBeginMessageSchema, Z as PlaybackQueueRequestMessageSchema, _ as SetListeningModeMessageSchema, _t as ConfigureConnectionMessageSchema, a as parseMessages, at as getVolumeResultMessage, b as setDiscoveryModeMessage, bt as Command, c as wakeDeviceMessage, ct as GetVolumeMessageSchema, d as SetVolumeMutedMessageSchema, dt as GetStateMessageSchema, et as notificationMessage, f as setVolumeMutedMessage, ft as getStateMessage, g as readyStateMessage, gt as ContentItemMetadata_MediaType, h as SetReadyStateMessageSchema, ht as getKeyboardSessionMessage, it as KeyboardState_Enum, j as SendHIDEventMessageSchema, jt as AdjustVolumeMessageSchema, k as SendPackedVirtualTouchEventMessageSchema, kt as RepeatMode_Enum, l as TextInputMessageSchema, lt as getVolumeMessage, m as setVolumeMessage, mt as getRemoteTextInputSessionMessage, n as decodeMessage, nt as ModifyOutputContextRequestType_Enum, o as proto_exports, ot as GetVolumeMutedMessageSchema, p as SetVolumeMessageSchema, pt as GetRemoteTextInputSessionMessageSchema, q as SendError_Enum, qt as uint16ToBE, rt as modifyOutputContextRequestMessage, s as WakeDeviceMessageSchema, st as getVolumeMutedMessage, t as describeProtocolMessage, tt as ModifyOutputContextRequestMessageSchema, u as textInputMessage, ut as VolumeCapabilities_Enum, v as setListeningModeMessage, vt as configureConnectionMessage, w as SetConnectionStateMessage_ConnectionState, wt as audioFadeMessage, x as SetConversationDetectionEnabledMessageSchema, xt as ClientUpdatesConfigMessageSchema, y as SetDiscoveryModeMessageSchema, yt as CommandOptionsSchema, z as RemoteTextInputMessageSchema, zt as create } from "./describe-CAEDGJLu.mjs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { EventEmitter } from "node:events";
5
+ import { CommandError, ConnectionClosedError, TimeoutError, waitFor } from "@corewire/core";
6
+ import { NSKeyedArchiver, Plist, RTI } from "@corewire/core/encoding";
7
+
8
+ //#region src/mrp/artworkUrl.ts
9
+ /** MRAnimatedArtwork serializes its NSURL with NSKeyedArchiver, not UTF-8. */
10
+ function decodeArtworkAssetURL(data) {
11
+ if (!data?.byteLength) return null;
12
+ try {
13
+ const root = NSKeyedArchiver.decode(Plist.parse(Uint8Array.from(data).buffer));
14
+ const resolve = (value, depth = 0) => {
15
+ if (!value || typeof value !== "object" || depth > 8) return null;
16
+ const object = value;
17
+ if (typeof object["NS.relative"] !== "string") return null;
18
+ const base = resolve(object["NS.base"], depth + 1);
19
+ return new URL(object["NS.relative"], base ?? void 0);
20
+ };
21
+ const url = resolve(root);
22
+ return url && (url.protocol === "https:" || url.protocol === "http:") ? url.href : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ //#endregion
29
+ //#region src/mrp/artwork.ts
30
+ /**
31
+ * The artwork of what plays now, from the sources MRP offers, in this order: a URL from the item
32
+ * metadata, image bytes inside the queue, the JPEG of the last SET_ARTWORK, and finally a queue
33
+ * request when the metadata promises artwork that has not arrived. A result is cached per
34
+ * artwork identifier.
35
+ */
36
+ var Artwork = class {
37
+ #queue;
38
+ #state;
39
+ #cached = null;
40
+ #lastIdentifier = null;
41
+ constructor(state, queue) {
42
+ this.#queue = queue;
43
+ this.#state = state;
44
+ }
45
+ /**
46
+ * Returns the artwork of the current item.
47
+ *
48
+ * @param width - In pixels.
49
+ * @param height - In pixels; -1 keeps the aspect ratio.
50
+ * @returns Null when there is no artwork.
51
+ */
52
+ async get(width = 600, height = -1) {
53
+ const player = this.#state.nowPlayingClient?.activePlayer;
54
+ if (!player) return null;
55
+ const identifier = player.artworkId;
56
+ if (identifier && identifier === this.#lastIdentifier && this.#cached) return this.#cached;
57
+ const available = this.#fromPlayer(player, identifier, width, height);
58
+ if (available) return this.#cache(identifier, available);
59
+ const setArtworkData = this.#state.artworkJpegData;
60
+ if (setArtworkData && setArtworkData.byteLength > 0) return this.#cache(identifier, {
61
+ url: null,
62
+ data: setArtworkData,
63
+ mimeType: "image/jpeg",
64
+ identifier,
65
+ width: 0,
66
+ height: 0
67
+ });
68
+ if (identifier) try {
69
+ await this.#queue.request(1, width, height < 0 ? 400 : height);
70
+ const fetched = this.#fromPlayer(player, identifier, width, height, true);
71
+ if (fetched) return this.#cache(identifier, fetched);
72
+ } catch {}
73
+ this.clear();
74
+ return null;
75
+ }
76
+ /** Asks for the available animated formats first, then for the asset URLs of those formats. */
77
+ async getAnimated(width = 600, height = -1) {
78
+ const item = await this.#queue.requestAssets({ includeAvailableArtworkFormats: true }, width, height);
79
+ if (!item) return [];
80
+ const formats = item.availableAnimatedArtworkFormats;
81
+ const assets = formats.length > 0 ? await this.#queue.requestAssets({ requestedAnimatedArtworkAssetURLFormats: formats }, width, height) : item;
82
+ if (!assets || assets.identifier !== item.identifier) return [];
83
+ return assets.animatedArtworks.map((artwork) => ({
84
+ format: artwork.type ?? "",
85
+ url: decodeArtworkAssetURL(artwork.assetFileURLData)
86
+ }));
87
+ }
88
+ /** Forgets the cached result, so the next {@link get} looks again. */
89
+ clear() {
90
+ this.#lastIdentifier = null;
91
+ this.#cached = null;
92
+ }
93
+ #cache(identifier, result) {
94
+ this.#lastIdentifier = identifier;
95
+ this.#cached = result;
96
+ return result;
97
+ }
98
+ /** A URL first and inline bytes second, or bytes first after a queue request delivered them. */
99
+ #fromPlayer(player, identifier, width, height, dataFirst = false) {
100
+ const fromUrl = () => {
101
+ const url = player.artworkUrl(width, height);
102
+ return url ? {
103
+ url,
104
+ data: null,
105
+ mimeType: guessMimeType(url),
106
+ identifier,
107
+ width,
108
+ height: height < 0 ? 0 : height
109
+ } : null;
110
+ };
111
+ const fromData = () => {
112
+ const data = player.currentItemArtwork;
113
+ return data && data.byteLength > 0 ? {
114
+ url: null,
115
+ data,
116
+ mimeType: player.currentItemMetadata?.artworkMIMEType || "image/jpeg",
117
+ identifier,
118
+ width: 0,
119
+ height: 0
120
+ } : null;
121
+ };
122
+ return dataFirst ? fromData() ?? fromUrl() : fromUrl() ?? fromData();
123
+ }
124
+ };
125
+ function guessMimeType(url) {
126
+ if (url.includes(".png")) return "image/png";
127
+ if (url.includes(".webp")) return "image/webp";
128
+ return "image/jpeg";
129
+ }
130
+
131
+ //#endregion
132
+ //#region src/mrp/messages.ts
133
+ var messages_exports = /* @__PURE__ */ __exportAll({
134
+ adjustVolume: () => adjustVolume,
135
+ audioFade: () => audioFade,
136
+ clientUpdatesConfig: () => clientUpdatesConfig,
137
+ configureConnection: () => configureConnection,
138
+ deviceInfo: () => deviceInfo,
139
+ getExtension: () => getExtension,
140
+ getKeyboardSession: () => getKeyboardSession,
141
+ getRemoteTextInputSession: () => getRemoteTextInputSession,
142
+ getState: () => getState,
143
+ getVolume: () => getVolume,
144
+ getVolumeMuted: () => getVolumeMuted,
145
+ modifyOutputContext: () => modifyOutputContext,
146
+ notification: () => notification,
147
+ playbackQueueRequest: () => playbackQueueRequest,
148
+ playbackSessionMigrateBegin: () => playbackSessionMigrateBegin,
149
+ playbackSessionMigrateEnd: () => playbackSessionMigrateEnd,
150
+ protocol: () => protocol,
151
+ registerHIDDevice: () => registerHIDDevice,
152
+ remoteTextInput: () => remoteTextInput,
153
+ requestGroupSession: () => requestGroupSession,
154
+ sendButtonEvent: () => sendButtonEvent,
155
+ sendCommand: () => sendCommand,
156
+ sendCommandWithPlaybackPosition: () => sendCommandWithPlaybackPosition,
157
+ sendCommandWithPlaybackRate: () => sendCommandWithPlaybackRate,
158
+ sendCommandWithRepeatMode: () => sendCommandWithRepeatMode,
159
+ sendCommandWithShuffleMode: () => sendCommandWithShuffleMode,
160
+ sendCommandWithSkipInterval: () => sendCommandWithSkipInterval,
161
+ sendCommandWithSleepTimer: () => sendCommandWithSleepTimer,
162
+ sendHIDEvent: () => sendHIDEvent,
163
+ sendPackedVirtualTouchEvent: () => sendPackedVirtualTouchEvent,
164
+ sendVirtualTouchEvent: () => sendVirtualTouchEvent,
165
+ setConnectionState: () => setConnectionState,
166
+ setConversationDetectionEnabled: () => setConversationDetectionEnabled,
167
+ setDiscoveryMode: () => setDiscoveryMode,
168
+ setListeningMode: () => setListeningMode,
169
+ setReadyState: () => setReadyState,
170
+ setVolume: () => setVolume,
171
+ setVolumeMuted: () => setVolumeMuted,
172
+ textInput: () => textInput,
173
+ wakeDevice: () => wakeDevice
174
+ });
175
+ /** A ProtocolMessage without extension, with a random `identifier` that the data channel matches replies on. */
176
+ function protocol(type, errorCode = 0) {
177
+ return create(ProtocolMessageSchema, {
178
+ type,
179
+ errorCode,
180
+ identifier: randomUUID().toUpperCase(),
181
+ uniqueIdentifier: randomUUID().toUpperCase()
182
+ });
183
+ }
184
+ /** Subscribes to state changes. Send early in the session to receive now-playing, volume and artwork updates. */
185
+ function clientUpdatesConfig(artworkUpdates = true, nowPlayingUpdates = true, volumeUpdates = true, keyboardUpdates = false, outputDeviceUpdates = false, systemEndpointUpdates = true, subscribedPlayerPaths = []) {
186
+ const protocolMessage = protocol(16);
187
+ const message = create(ClientUpdatesConfigMessageSchema, {
188
+ artworkUpdates,
189
+ nowPlayingUpdates,
190
+ volumeUpdates,
191
+ keyboardUpdates,
192
+ outputDeviceUpdates,
193
+ systemEndpointUpdates,
194
+ subscribedPlayerPaths
195
+ });
196
+ setExtension(protocolMessage, clientUpdatesConfigMessage, message);
197
+ return [protocolMessage, clientUpdatesConfigMessage];
198
+ }
199
+ /** CONFIGURE_CONNECTION, which sets the group ID of this connection. */
200
+ function configureConnection(groupId) {
201
+ const protocolMessage = protocol(120);
202
+ const message = create(ConfigureConnectionMessageSchema, { groupID: groupId });
203
+ setExtension(protocolMessage, configureConnectionMessage, message);
204
+ return [protocolMessage, configureConnectionMessage];
205
+ }
206
+ /**
207
+ * DEVICE_INFO, the first message of a session, which introduces this controller to the receiver.
208
+ *
209
+ * @param pairingId - The pairing identifier from pair-verify.
210
+ */
211
+ function deviceInfo(pairingId, identity) {
212
+ const protocolMessage = protocol(15);
213
+ const message = create(DeviceInfoMessageSchema, {
214
+ uniqueIdentifier: pairingId.toString(),
215
+ name: identity.name,
216
+ localizedModelName: "iPhone",
217
+ systemBuildVersion: identity.osBuildVersion,
218
+ applicationBundleIdentifier: identity.applicationBundleIdentifier,
219
+ applicationBundleVersion: identity.applicationBundleVersion,
220
+ protocolVersion: 1,
221
+ lastSupportedMessageType: 139,
222
+ supportsSystemPairing: true,
223
+ allowsPairing: true,
224
+ systemMediaApplication: "com.apple.TVMusic",
225
+ supportsACL: true,
226
+ supportsSharedQueue: true,
227
+ supportsExtendedMotion: true,
228
+ sharedQueueVersion: 3,
229
+ deviceClass: 1,
230
+ logicalDeviceCount: 1,
231
+ modelID: identity.model,
232
+ clusterType: 0,
233
+ isClusterAware: true,
234
+ isGroupLeader: false,
235
+ isProxyGroupPlayer: false,
236
+ groupContainsDiscoverableGroupLeader: 0,
237
+ supportsOutputContextSync: true,
238
+ computerName: identity.name
239
+ });
240
+ setExtension(protocolMessage, deviceInfoMessage, message);
241
+ return [protocolMessage, deviceInfoMessage];
242
+ }
243
+ /**
244
+ * MODIFY_OUTPUT_CONTEXT_REQUEST, which changes the devices of a multiroom group.
245
+ *
246
+ * @param settingDevices - Replaces the whole output context.
247
+ */
248
+ function modifyOutputContext(addingDevices = [], removingDevices = [], settingDevices = []) {
249
+ const protocolMessage = protocol(48);
250
+ const message = create(ModifyOutputContextRequestMessageSchema, {
251
+ outputContextType: 1,
252
+ addingOutputDeviceUIDs: addingDevices,
253
+ removingOutputDeviceUIDs: removingDevices,
254
+ settingOutputDeviceUIDs: settingDevices,
255
+ clusterAwareAddingOutputDeviceUIDs: addingDevices,
256
+ clusterAwareRemovingOutputDeviceUIDs: removingDevices,
257
+ clusterAwareSettingOutputDeviceUIDs: settingDevices
258
+ });
259
+ setExtension(protocolMessage, modifyOutputContextRequestMessage, message);
260
+ return [protocolMessage, modifyOutputContextRequestMessage];
261
+ }
262
+ /** GET_STATE, which asks for the current playback state. */
263
+ function getState() {
264
+ const protocolMessage = protocol(3);
265
+ const message = create(GetStateMessageSchema, {});
266
+ setExtension(protocolMessage, getStateMessage, message);
267
+ return [protocolMessage, getStateMessage];
268
+ }
269
+ /** GET_VOLUME, which asks for the volume of an output device. */
270
+ function getVolume(outputDeviceUID) {
271
+ const protocolMessage = protocol(49);
272
+ const message = create(GetVolumeMessageSchema, { outputDeviceUID });
273
+ setExtension(protocolMessage, getVolumeMessage, message);
274
+ return [protocolMessage, getVolumeMessage];
275
+ }
276
+ /** GET_VOLUME_MUTED, which asks whether an output device is muted. */
277
+ function getVolumeMuted(outputDeviceUID) {
278
+ const protocolMessage = protocol(126);
279
+ const message = create(GetVolumeMutedMessageSchema, { outputDeviceUID });
280
+ setExtension(protocolMessage, getVolumeMutedMessage, message);
281
+ return [protocolMessage, getVolumeMutedMessage];
282
+ }
283
+ /** NOTIFICATION, which sends a named notification to the receiver. */
284
+ function notification(notification) {
285
+ const protocolMessage = protocol(11);
286
+ const message = create(NotificationMessageSchema, { notification });
287
+ setExtension(protocolMessage, notificationMessage, message);
288
+ return [protocolMessage, notificationMessage];
289
+ }
290
+ /**
291
+ * PLAYBACK_QUEUE_REQUEST, which asks for queue metadata and assets. Animated artwork formats
292
+ * have to be requested explicitly.
293
+ *
294
+ * @param location - Index of the first item.
295
+ * @param artworkWidth - In pixels.
296
+ * @param artworkHeight - In pixels; -1 keeps the aspect ratio.
297
+ */
298
+ function playbackQueueRequest(location, length, artworkWidth = 600, artworkHeight = -1, options = {}) {
299
+ const protocolMessage = protocol(32);
300
+ const message = create(PlaybackQueueRequestMessageSchema, {
301
+ location,
302
+ length,
303
+ artworkWidth,
304
+ artworkHeight,
305
+ returnContentItemAssetsInUserCompletion: true,
306
+ includeMetadata: true,
307
+ includeLanguageOptions: false,
308
+ includeInfo: true,
309
+ includeLyrics: true,
310
+ includeSections: true,
311
+ includeAlignments: true,
312
+ includeParticipants: true,
313
+ isLegacyNowPlayingInfoRequest: false,
314
+ ...options
315
+ });
316
+ setExtension(protocolMessage, playbackQueueRequestMessage, message);
317
+ return [protocolMessage, playbackQueueRequestMessage];
318
+ }
319
+ /** GET_KEYBOARD_SESSION, which asks whether a text field is active; the answer is a KEYBOARD message. */
320
+ function getKeyboardSession() {
321
+ const protocolMessage = protocol(24);
322
+ setExtension(protocolMessage, getKeyboardSessionMessage, "");
323
+ return [protocolMessage, getKeyboardSessionMessage];
324
+ }
325
+ /** Requests an RTI session; the receiver supplies a RemoteTextInputMessage. */
326
+ function getRemoteTextInputSession() {
327
+ const message = protocol(68);
328
+ setExtension(message, getRemoteTextInputSessionMessage, create(GetRemoteTextInputSessionMessageSchema));
329
+ return [message, getRemoteTextInputSessionMessage];
330
+ }
331
+ /**
332
+ * REMOTE_TEXT_INPUT, which carries one RTI text operation.
333
+ *
334
+ * @param data - The archived RTI operation.
335
+ * @param version - The version of the RTI session it edits.
336
+ */
337
+ function remoteTextInput(data, version = 1n) {
338
+ const message = protocol(67);
339
+ setExtension(message, remoteTextInputMessage, create(RemoteTextInputMessageSchema, {
340
+ version,
341
+ data
342
+ }));
343
+ return [message, remoteTextInputMessage];
344
+ }
345
+ /**
346
+ * TEXT_INPUT, which sends text to the active keyboard session.
347
+ *
348
+ * @param actionType - For example done, search or send.
349
+ */
350
+ function textInput(text, actionType) {
351
+ const protocolMessage = protocol(25);
352
+ const message = create(TextInputMessageSchema, {
353
+ timestamp: Date.now() / 1e3,
354
+ text,
355
+ actionType
356
+ });
357
+ setExtension(protocolMessage, textInputMessage, message);
358
+ return [protocolMessage, textInputMessage];
359
+ }
360
+ /**
361
+ * SEND_BUTTON_EVENT, a USB HID button press or release.
362
+ *
363
+ * @param usagePage - For example 0x01 for Generic Desktop, 0x0c for Consumer.
364
+ * @param buttonDown - `true` for key down, `false` for key up.
365
+ */
366
+ function sendButtonEvent(usagePage, usage, buttonDown) {
367
+ const protocolMessage = protocol(39);
368
+ const message = create(SendButtonEventMessageSchema, {
369
+ usagePage,
370
+ usage,
371
+ buttonDown
372
+ });
373
+ setExtension(protocolMessage, sendButtonEventMessage, message);
374
+ return [protocolMessage, sendButtonEventMessage];
375
+ }
376
+ /** @param skipInterval - Seconds. */
377
+ function sendCommandWithSkipInterval(command, skipInterval) {
378
+ return sendCommand(command, create(CommandOptionsSchema, { skipInterval }));
379
+ }
380
+ /** @param playbackPosition - Seconds from the start. */
381
+ function sendCommandWithPlaybackPosition(command, playbackPosition) {
382
+ return sendCommand(command, create(CommandOptionsSchema, { playbackPosition }));
383
+ }
384
+ /** @param playbackRate - 1 is normal speed, 2 is double speed. */
385
+ function sendCommandWithPlaybackRate(command, playbackRate) {
386
+ return sendCommand(command, create(CommandOptionsSchema, { playbackRate }));
387
+ }
388
+ function sendCommandWithShuffleMode(command, shuffleMode) {
389
+ return sendCommand(command, create(CommandOptionsSchema, { shuffleMode }));
390
+ }
391
+ function sendCommandWithRepeatMode(command, repeatMode) {
392
+ return sendCommand(command, create(CommandOptionsSchema, { repeatMode }));
393
+ }
394
+ /** SEND_COMMAND, a playback command such as play, pause or next track. */
395
+ function sendCommand(command, options) {
396
+ const protocolMessage = protocol(1);
397
+ const message = create(SendCommandMessageSchema, {
398
+ command,
399
+ options
400
+ });
401
+ setExtension(protocolMessage, sendCommandMessage, message);
402
+ return [protocolMessage, sendCommandMessage];
403
+ }
404
+ /**
405
+ * A Pause command that carries a sleep timer.
406
+ *
407
+ * @param seconds - Timer duration; 0 cancels a running timer.
408
+ * @param stopMode - 0 stops playback, 1 pauses, 2 waits for the end of the track, 3 for the end of the queue.
409
+ */
410
+ function sendCommandWithSleepTimer(seconds, stopMode = 0) {
411
+ return sendCommand(2, create(CommandOptionsSchema, {
412
+ sleepTimerTime: BigInt(seconds),
413
+ sleepTimerStopMode: stopMode
414
+ }));
415
+ }
416
+ /**
417
+ * REGISTER_HID_DEVICE, which claims a virtual touch device. The Apple TV answers with a
418
+ * REGISTER_HID_DEVICE_RESULT that holds the device identifier {@link sendVirtualTouchEvent} has
419
+ * to carry; an unclaimed identifier makes it drop the session.
420
+ *
421
+ * @param absolute - Whether coordinates are absolute positions instead of relative movement.
422
+ * @param integratedDisplay - Whether the surface sits on top of a display, as on a phone.
423
+ */
424
+ function registerHIDDevice(screenWidth = 1e3, screenHeight = 1e3, absolute = true, integratedDisplay = false) {
425
+ const protocolMessage = protocol(6);
426
+ const message = create(RegisterHIDDeviceMessageSchema, { deviceDescriptor: create(VirtualTouchDeviceDescriptorSchema, {
427
+ absolute,
428
+ integratedDisplay,
429
+ screenSizeWidth: screenWidth,
430
+ screenSizeHeight: screenHeight
431
+ }) });
432
+ setExtension(protocolMessage, registerHIDDeviceMessage, message);
433
+ return [protocolMessage, registerHIDDeviceMessage];
434
+ }
435
+ /**
436
+ * SEND_PACKED_VIRTUAL_TOUCH_EVENT, the packed form of a touch event: the five values as 16-bit
437
+ * little endian integers in one byte array, in the order x, y, phase, device identifier, finger.
438
+ *
439
+ * @param phase - 1 began, 2 moved, 3 stationary, 4 ended, 5 cancelled.
440
+ * @param deviceId - The identifier {@link registerHIDDevice} handed out.
441
+ * @param finger - Finger index for multi-touch.
442
+ */
443
+ function sendPackedVirtualTouchEvent(x, y, phase, deviceId, finger) {
444
+ const protocolMessage = protocol(43);
445
+ const data = Buffer.alloc(10);
446
+ data.writeUInt16LE(x, 0);
447
+ data.writeUInt16LE(y, 2);
448
+ data.writeUInt16LE(phase, 4);
449
+ data.writeUInt16LE(deviceId, 6);
450
+ data.writeUInt16LE(finger, 8);
451
+ const message = create(SendPackedVirtualTouchEventMessageSchema, { data });
452
+ setExtension(protocolMessage, sendPackedVirtualTouchEventMessage, message);
453
+ return [protocolMessage, sendPackedVirtualTouchEventMessage];
454
+ }
455
+ /**
456
+ * SEND_VIRTUAL_TOUCH_EVENT, a touch on the virtual touchpad of {@link registerHIDDevice}.
457
+ *
458
+ * @param phase - 1 began, 2 moved, 3 stationary, 4 ended, 5 cancelled.
459
+ * @param finger - Finger index for multi-touch.
460
+ * @param deviceId - The identifier {@link registerHIDDevice} handed out.
461
+ */
462
+ function sendVirtualTouchEvent(x, y, phase, finger, deviceId = 1) {
463
+ const protocolMessage = protocol(10);
464
+ const message = create(SendVirtualTouchEventMessageSchema, {
465
+ virtualDeviceID: BigInt(deviceId),
466
+ event: create(VirtualTouchEventSchema, {
467
+ x: BigInt(x),
468
+ y: BigInt(y),
469
+ phase,
470
+ finger
471
+ })
472
+ });
473
+ setExtension(protocolMessage, sendVirtualTouchEventMessage, message);
474
+ return [protocolMessage, sendVirtualTouchEventMessage];
475
+ }
476
+ /**
477
+ * SEND_HID_EVENT, a raw USB HID event report for a navigation or media key.
478
+ *
479
+ * @param usePage - For example 0x01 for Generic Desktop, 0x0c for Consumer.
480
+ * @param down - `true` for key down, `false` for key up.
481
+ */
482
+ function sendHIDEvent(usePage, usage, down) {
483
+ const protocolMessage = protocol(8);
484
+ const message = create(SendHIDEventMessageSchema, { hidEventData: Buffer.concat([
485
+ Buffer.from("438922cf08020000", "hex"),
486
+ Buffer.from("0000000000000000010000000000000002000000200000000300000001000000000000", "hex"),
487
+ Buffer.concat([
488
+ uint16ToBE(usePage),
489
+ uint16ToBE(usage),
490
+ uint16ToBE(down ? 1 : 0)
491
+ ]),
492
+ Buffer.from("0000000000000001000000", "hex")
493
+ ]) });
494
+ setExtension(protocolMessage, sendHIDEventMessage, message);
495
+ return [protocolMessage, sendHIDEventMessage];
496
+ }
497
+ /** SET_CONNECTION_STATE, which tells the receiver our connection state. */
498
+ function setConnectionState(state = 2) {
499
+ const protocolMessage = protocol(38);
500
+ const message = create(SetConnectionStateMessageSchema, { state });
501
+ setExtension(protocolMessage, setConnectionStateMessage, message);
502
+ return [protocolMessage, setConnectionStateMessage];
503
+ }
504
+ /** SET_READY_STATE, which tells the receiver this controller is ready. */
505
+ function setReadyState() {
506
+ const protocolMessage = protocol(36);
507
+ const message = create(SetReadyStateMessageSchema, {});
508
+ setExtension(protocolMessage, readyStateMessage, message);
509
+ return [protocolMessage, readyStateMessage];
510
+ }
511
+ /**
512
+ * SET_VOLUME, which sets the volume of an output device.
513
+ *
514
+ * @param volume - 0 to 1.
515
+ */
516
+ function setVolume(outputDeviceUID, volume) {
517
+ const protocolMessage = protocol(51);
518
+ const message = create(SetVolumeMessageSchema, {
519
+ outputDeviceUID,
520
+ volume
521
+ });
522
+ setExtension(protocolMessage, setVolumeMessage, message);
523
+ return [protocolMessage, setVolumeMessage];
524
+ }
525
+ /** SET_VOLUME_MUTED, which mutes or unmutes an output device. */
526
+ function setVolumeMuted(outputDeviceUID, isMuted) {
527
+ const protocolMessage = protocol(128);
528
+ const message = create(SetVolumeMutedMessageSchema, {
529
+ outputDeviceUID,
530
+ isMuted
531
+ });
532
+ setExtension(protocolMessage, setVolumeMutedMessage, message);
533
+ return [protocolMessage, setVolumeMutedMessage];
534
+ }
535
+ /** SET_CONVERSATION_DETECTION_ENABLED, which turns conversation detection of a HomePod on or off: it ducks the volume while people talk. */
536
+ function setConversationDetectionEnabled(enabled, outputDeviceUID) {
537
+ const protocolMessage = protocol(130);
538
+ const message = create(SetConversationDetectionEnabledMessageSchema, {
539
+ enabled,
540
+ outputDeviceUID
541
+ });
542
+ setExtension(protocolMessage, setConversationDetectionEnabledMessage, message);
543
+ return [protocolMessage, setConversationDetectionEnabledMessage];
544
+ }
545
+ /**
546
+ * ADJUST_VOLUME (extension field 97), a relative volume step that needs no current volume.
547
+ *
548
+ * @param adjustment - For example IncrementSmall or DecrementSmall.
549
+ */
550
+ function adjustVolume(adjustment, outputDeviceUID) {
551
+ const protocolMessage = protocol(125);
552
+ const message = create(AdjustVolumeMessageSchema, {
553
+ adjustment,
554
+ outputDeviceUID
555
+ });
556
+ setExtension(protocolMessage, adjustVolumeMessage, message);
557
+ return [protocolMessage, adjustVolumeMessage];
558
+ }
559
+ /**
560
+ * AUDIO_FADE, which asks the receiver to fade its audio.
561
+ *
562
+ * @param fadeType - 0 fades out, 1 fades in.
563
+ * @param playerPath - Targets one player instead of the active one.
564
+ */
565
+ function audioFade(fadeType, playerPath) {
566
+ const protocolMessage = protocol(106);
567
+ const message = create(AudioFadeMessageSchema, {
568
+ fadeType,
569
+ playerPath
570
+ });
571
+ setExtension(protocolMessage, audioFadeMessage, message);
572
+ return [protocolMessage, audioFadeMessage];
573
+ }
574
+ /**
575
+ * SET_LISTENING_MODE, which sets the listening mode of a HomePod.
576
+ *
577
+ * @param listeningMode - For example `Default`, `Vivid` or `LateNight`.
578
+ */
579
+ function setListeningMode(listeningMode, outputDeviceUID) {
580
+ const protocolMessage = protocol(110);
581
+ const message = create(SetListeningModeMessageSchema, {
582
+ listeningMode,
583
+ outputDeviceUID
584
+ });
585
+ setExtension(protocolMessage, setListeningModeMessage, message);
586
+ return [protocolMessage, setListeningModeMessage];
587
+ }
588
+ /** SET_DISCOVERY_MODE, which turns device discovery on or off. */
589
+ function setDiscoveryMode(mode) {
590
+ const protocolMessage = protocol(101);
591
+ const message = create(SetDiscoveryModeMessageSchema, { mode });
592
+ setExtension(protocolMessage, setDiscoveryModeMessage, message);
593
+ return [protocolMessage, setDiscoveryModeMessage];
594
+ }
595
+ /** REQUEST_GROUP_SESSION, which starts a SharePlay group session. */
596
+ function requestGroupSession() {
597
+ const protocolMessage = protocol(132);
598
+ const message = create(RequestGroupSessionMessageSchema, {});
599
+ setExtension(protocolMessage, requestGroupSessionMessage, message);
600
+ return [protocolMessage, requestGroupSessionMessage];
601
+ }
602
+ /** PLAYBACK_SESSION_MIGRATE_BEGIN, which starts moving playback to another device. */
603
+ function playbackSessionMigrateBegin(playerPath) {
604
+ const protocolMessage = protocol(75);
605
+ const message = create(PlaybackSessionMigrateBeginMessageSchema, { playerPath });
606
+ setExtension(protocolMessage, playbackSessionMigrateBeginMessage, message);
607
+ return [protocolMessage, playbackSessionMigrateBeginMessage];
608
+ }
609
+ /** PLAYBACK_SESSION_MIGRATE_END, which completes moving playback to another device. */
610
+ function playbackSessionMigrateEnd(playerPath) {
611
+ const protocolMessage = protocol(76);
612
+ const message = create(PlaybackSessionMigrateEndMessageSchema, { playerPath });
613
+ setExtension(protocolMessage, playbackSessionMigrateEndMessage, message);
614
+ return [protocolMessage, playbackSessionMigrateEndMessage];
615
+ }
616
+ /** WAKE_DEVICE, which wakes a sleeping Apple TV or HomePod. */
617
+ function wakeDevice() {
618
+ const protocolMessage = protocol(41);
619
+ const message = create(WakeDeviceMessageSchema, {});
620
+ setExtension(protocolMessage, wakeDeviceMessage, message);
621
+ return [protocolMessage, wakeDeviceMessage];
622
+ }
623
+ /** Reads a protobuf extension of a ProtocolMessage, typed by its descriptor. */
624
+ function getExtension(message, extension) {
625
+ return getExtension$1(message, extension);
626
+ }
627
+
628
+ //#endregion
629
+ //#region src/mrp/commands.ts
630
+ /**
631
+ * Milliseconds a command waits for its result. The Music app on tvOS answers Play after about
632
+ * 9 s while playback starts within a second, and the receiver handles commands one at a time.
633
+ */
634
+ const COMMAND_TIMEOUT = 15e3;
635
+ /** The receiver refused a SendCommand. */
636
+ var SendCommandError = class extends CommandError {
637
+ sendError;
638
+ handlerReturnStatus;
639
+ constructor(sendError, handlerReturnStatus) {
640
+ super(`SendCommand failed: sendError=${SendError_Enum[sendError]}, handlerReturnStatus=${HandlerReturnStatus_Enum[handlerReturnStatus]}`);
641
+ this.name = "SendCommandError";
642
+ this.sendError = sendError;
643
+ this.handlerReturnStatus = handlerReturnStatus;
644
+ }
645
+ };
646
+ /**
647
+ * Playback commands to the now-playing app, each a SEND_COMMAND exchange. A result with a send
648
+ * error throws {@link SendCommandError}; a reply without a result counts as success.
649
+ */
650
+ var Commands = class {
651
+ #channel;
652
+ constructor(channel) {
653
+ this.#channel = channel;
654
+ }
655
+ /** @throws {SendCommandError} When the receiver reports a send error. */
656
+ async send(command, options) {
657
+ return await this.#exchange(sendCommand(command, options));
658
+ }
659
+ async play() {
660
+ await this.send(1);
661
+ }
662
+ async pause() {
663
+ await this.send(2);
664
+ }
665
+ async togglePlayPause() {
666
+ await this.send(3);
667
+ }
668
+ async stop() {
669
+ await this.send(4);
670
+ }
671
+ async nextTrack() {
672
+ await this.send(5);
673
+ }
674
+ async previousTrack() {
675
+ await this.send(6);
676
+ }
677
+ /** @param interval - Seconds. */
678
+ async skipForward(interval = 15) {
679
+ await this.#exchange(sendCommandWithSkipInterval(18, interval));
680
+ }
681
+ /** @param interval - Seconds. */
682
+ async skipBackward(interval = 15) {
683
+ await this.#exchange(sendCommandWithSkipInterval(19, interval));
684
+ }
685
+ /** @param position - Seconds from the start. */
686
+ async seekToPosition(position) {
687
+ await this.#exchange(sendCommandWithPlaybackPosition(45, position));
688
+ }
689
+ async setShuffleMode(mode) {
690
+ await this.#exchange(sendCommandWithShuffleMode(47, mode));
691
+ }
692
+ async setRepeatMode(mode) {
693
+ await this.#exchange(sendCommandWithRepeatMode(46, mode));
694
+ }
695
+ /** @param rate - 1 is normal speed. */
696
+ async changePlaybackRate(rate) {
697
+ await this.#exchange(sendCommandWithPlaybackRate(20, rate));
698
+ }
699
+ async advanceShuffleMode() {
700
+ await this.send(7);
701
+ }
702
+ async advanceRepeatMode() {
703
+ await this.send(8);
704
+ }
705
+ async beginFastForward() {
706
+ await this.send(9);
707
+ }
708
+ async endFastForward() {
709
+ await this.send(10);
710
+ }
711
+ async beginRewind() {
712
+ await this.send(11);
713
+ }
714
+ async endRewind() {
715
+ await this.send(12);
716
+ }
717
+ async nextChapter() {
718
+ await this.send(25);
719
+ }
720
+ async previousChapter() {
721
+ await this.send(26);
722
+ }
723
+ async likeTrack() {
724
+ await this.send(22);
725
+ }
726
+ async dislikeTrack() {
727
+ await this.send(23);
728
+ }
729
+ async bookmarkTrack() {
730
+ await this.send(24);
731
+ }
732
+ async addNowPlayingItemToLibrary() {
733
+ await this.send(49);
734
+ }
735
+ /**
736
+ * Sets a sleep timer. It goes out as an option on a Pause command.
737
+ *
738
+ * @param seconds - Timer duration; 0 cancels a running timer.
739
+ * @param stopMode - 0 stops playback, 1 pauses, 2 waits for the end of the track, 3 for the end of the queue.
740
+ */
741
+ async setSleepTimer(seconds, stopMode = 0) {
742
+ await this.#exchange(sendCommandWithSleepTimer(seconds, stopMode));
743
+ }
744
+ async #exchange(message) {
745
+ const response = await this.#channel.exchange(message, COMMAND_TIMEOUT);
746
+ let result;
747
+ try {
748
+ result = getExtension(response, sendCommandResultMessage);
749
+ } catch {
750
+ return;
751
+ }
752
+ if (!result) return;
753
+ if (result.sendError !== 0) throw new SendCommandError(result.sendError, result.handlerReturnStatus);
754
+ return result;
755
+ }
756
+ };
757
+
758
+ //#endregion
759
+ //#region src/mrp/hid.ts
760
+ /** USB HID usage page of the navigation keys. */
761
+ const GENERIC_DESKTOP = 1;
762
+ /** USB HID usage page of the media keys. */
763
+ const CONSUMER = 12;
764
+ /**
765
+ * Keys as USB HID events: Generic Desktop (0x01) for navigation, Consumer (0x0c) for media.
766
+ *
767
+ * Every event goes out with `send()`: the receiver never replies to a HID event, so waiting for one
768
+ * would time out on key-down and key-up would never be sent. Touch is not here: tvOS drops the
769
+ * session on a virtual touch event over MRP.
770
+ */
771
+ var Hid = class {
772
+ #channel;
773
+ #state;
774
+ constructor(channel, state) {
775
+ this.#channel = channel;
776
+ this.#state = state;
777
+ }
778
+ async up() {
779
+ await this.press(GENERIC_DESKTOP, 140);
780
+ }
781
+ async down() {
782
+ await this.press(GENERIC_DESKTOP, 141);
783
+ }
784
+ async left() {
785
+ await this.press(GENERIC_DESKTOP, 139);
786
+ }
787
+ async right() {
788
+ await this.press(GENERIC_DESKTOP, 138);
789
+ }
790
+ async menu() {
791
+ await this.press(GENERIC_DESKTOP, 134);
792
+ }
793
+ async select() {
794
+ await this.press(GENERIC_DESKTOP, 137);
795
+ }
796
+ async home() {
797
+ await this.press(CONSUMER, 64);
798
+ }
799
+ async suspend() {
800
+ await this.press(GENERIC_DESKTOP, 130);
801
+ }
802
+ async wake() {
803
+ await this.press(GENERIC_DESKTOP, 131);
804
+ }
805
+ async play() {
806
+ await this.press(CONSUMER, 176);
807
+ }
808
+ async pause() {
809
+ await this.press(CONSUMER, 177);
810
+ }
811
+ /** Pause when the now-playing client plays, play otherwise. */
812
+ async playPause() {
813
+ if (this.#state.nowPlayingClient?.isPlaying) await this.pause();
814
+ else await this.play();
815
+ }
816
+ async stop() {
817
+ await this.press(CONSUMER, 183);
818
+ }
819
+ async next() {
820
+ await this.press(CONSUMER, 181);
821
+ }
822
+ async previous() {
823
+ await this.press(CONSUMER, 182);
824
+ }
825
+ async volumeUp() {
826
+ await this.press(CONSUMER, 233);
827
+ }
828
+ async volumeDown() {
829
+ await this.press(CONSUMER, 234);
830
+ }
831
+ async mute() {
832
+ await this.press(CONSUMER, 226);
833
+ }
834
+ async topMenu() {
835
+ await this.press(CONSUMER, 96);
836
+ }
837
+ async channelUp() {
838
+ await this.press(CONSUMER, 156);
839
+ }
840
+ async channelDown() {
841
+ await this.press(CONSUMER, 157);
842
+ }
843
+ /** Two presses 150 ms apart. */
844
+ async doublePress(usePage, usage) {
845
+ await this.press(usePage, usage);
846
+ await waitFor(150);
847
+ await this.press(usePage, usage);
848
+ }
849
+ /**
850
+ * Holds a key down.
851
+ *
852
+ * @param duration - Hold time in milliseconds.
853
+ */
854
+ async longPress(usePage, usage, duration = 1e3) {
855
+ this.#channel.send(sendHIDEvent(usePage, usage, true));
856
+ await waitFor(duration);
857
+ this.#channel.send(sendHIDEvent(usePage, usage, false));
858
+ }
859
+ /** Key-down, 25 ms, key-up. */
860
+ async press(usePage, usage) {
861
+ this.#channel.send(sendHIDEvent(usePage, usage, true));
862
+ await waitFor(25);
863
+ this.#channel.send(sendHIDEvent(usePage, usage, false));
864
+ }
865
+ };
866
+
867
+ //#endregion
868
+ //#region src/mrp/keyboard.ts
869
+ /** How long the receiver gets to hand out an RTI text session, in milliseconds. */
870
+ const TEXT_SESSION_TIMEOUT = 5e3;
871
+ /**
872
+ * Text input over MRP. Every operation asks the receiver for its current RTI text session first,
873
+ * then sends the edit for that session. Operations run one at a time, in call order.
874
+ */
875
+ var Keyboard = class {
876
+ #channel;
877
+ #queue = Promise.resolve();
878
+ constructor(channel) {
879
+ this.#channel = channel;
880
+ }
881
+ /** Replaces the text in the focused field. */
882
+ async set(text) {
883
+ await this.#enqueue(text, true);
884
+ }
885
+ async append(text) {
886
+ await this.#enqueue(text, false);
887
+ }
888
+ async clear() {
889
+ await this.#enqueue("", true);
890
+ }
891
+ /** Asks for the keyboard session; the answer arrives as a KEYBOARD message on the state. */
892
+ async getSession() {
893
+ this.#channel.send(getKeyboardSession());
894
+ }
895
+ #enqueue(text, clear) {
896
+ const operation = this.#queue.then(async () => {
897
+ if (!this.#channel.isConnected) throw new ConnectionClosedError();
898
+ const { session, version } = await this.#requestSession();
899
+ if (!this.#channel.isConnected) throw new ConnectionClosedError();
900
+ if (clear) this.#channel.send(remoteTextInput(new Uint8Array(RTI.encodeOperation(session.uuid, "", true)), version));
901
+ if (text) this.#channel.send(remoteTextInput(new Uint8Array(RTI.encodeOperation(session.uuid, text, false)), version));
902
+ });
903
+ this.#queue = operation.catch(() => {});
904
+ return operation;
905
+ }
906
+ #requestSession() {
907
+ const channel = this.#channel;
908
+ return new Promise((resolve, reject) => {
909
+ const cleanup = () => {
910
+ clearTimeout(timer);
911
+ channel.off("remoteTextInput", onSession);
912
+ channel.off("closed", onClose);
913
+ channel.off("error", onError);
914
+ };
915
+ const onError = (error) => {
916
+ cleanup();
917
+ reject(error);
918
+ };
919
+ const onClose = () => onError(new ConnectionClosedError());
920
+ const onSession = (message) => {
921
+ try {
922
+ if (!message.data?.byteLength) throw new CommandError("No active AirPlay text input session.");
923
+ if (message.version !== 1n) throw new CommandError("Unsupported AirPlay text input version.");
924
+ const session = RTI.decodeSession(message.data);
925
+ cleanup();
926
+ resolve({
927
+ session,
928
+ version: message.version
929
+ });
930
+ } catch (error) {
931
+ onError(error instanceof Error ? error : new Error(String(error)));
932
+ }
933
+ };
934
+ const timer = setTimeout(() => onError(new TimeoutError("AirPlay did not provide an RTI text session.")), TEXT_SESSION_TIMEOUT);
935
+ channel.on("remoteTextInput", onSession);
936
+ channel.on("closed", onClose);
937
+ channel.on("error", onError);
938
+ try {
939
+ channel.send(getRemoteTextInputSession());
940
+ } catch (error) {
941
+ onError(error instanceof Error ? error : new Error(String(error)));
942
+ }
943
+ });
944
+ }
945
+ };
946
+
947
+ //#endregion
948
+ //#region src/mrp/outputDevices.ts
949
+ /** The output context of the receiver: which devices play along, and per-device audio settings. */
950
+ var OutputDevices = class {
951
+ #channel;
952
+ #state;
953
+ constructor(channel, state) {
954
+ this.#channel = channel;
955
+ this.#state = state;
956
+ }
957
+ /** Adds devices to the output context. */
958
+ async add(deviceUIDs) {
959
+ await this.#channel.exchange(modifyOutputContext(deviceUIDs));
960
+ }
961
+ /** Removes devices from the output context. */
962
+ async remove(deviceUIDs) {
963
+ await this.#channel.exchange(modifyOutputContext([], deviceUIDs));
964
+ }
965
+ /** Replaces the output context with these devices. */
966
+ async set(deviceUIDs) {
967
+ await this.#channel.exchange(modifyOutputContext([], [], deviceUIDs));
968
+ }
969
+ /**
970
+ * Turns conversation detection of the active output device on or off (HomePod).
971
+ *
972
+ * @throws {Error} When no output device is active.
973
+ */
974
+ async setConversationDetectionEnabled(enabled) {
975
+ const outputDeviceUID = this.#state.outputDeviceUID;
976
+ if (!outputDeviceUID) throw new Error("No output device active.");
977
+ this.#channel.send(setConversationDetectionEnabled(enabled, outputDeviceUID));
978
+ }
979
+ /** Sets the listening mode of the active output device (HomePod), e.g. `Default`, `Vivid`, `LateNight`. Does nothing without one. */
980
+ async setListeningMode(mode) {
981
+ const outputDeviceUID = this.#state.outputDeviceUID;
982
+ if (outputDeviceUID) this.#channel.send(setListeningMode(mode, outputDeviceUID));
983
+ }
984
+ };
985
+
986
+ //#endregion
987
+ //#region src/mrp/playbackQueue.ts
988
+ /**
989
+ * Requests for the playback queue. The receiver answers with a SET_STATE carrying the queue, so
990
+ * the result lands in the state; these only wait for the reply.
991
+ */
992
+ var PlaybackQueue = class {
993
+ #channel;
994
+ #state;
995
+ constructor(channel, state) {
996
+ this.#channel = channel;
997
+ this.#state = state;
998
+ }
999
+ /** Requests the first `length` items of the queue. */
1000
+ async request(length, artworkWidth, artworkHeight) {
1001
+ await this.#channel.exchange(playbackQueueRequest(0, length, artworkWidth, artworkHeight));
1002
+ }
1003
+ /**
1004
+ * Requests extra assets of the current item, such as artwork formats or lyrics, and returns
1005
+ * the item once they arrived.
1006
+ *
1007
+ * @returns Null without a current item, or when the track changed while waiting.
1008
+ * @throws {Error} When the receiver answers with an error code.
1009
+ */
1010
+ async requestAssets(options = {}, width = 600, height = -1) {
1011
+ const player = this.#state.nowPlayingClient?.activePlayer;
1012
+ const identifier = player?.currentItem?.identifier;
1013
+ if (!identifier) return null;
1014
+ const response = await this.#channel.exchange(playbackQueueRequest(0, 1, width, height, {
1015
+ ...options,
1016
+ playerPath: player.playbackQueue?.resolvedPlayerPath
1017
+ }));
1018
+ if (response.errorCode) throw new Error(`Content item request failed: ${response.errorDescription || response.errorCode}`);
1019
+ if (this.#state.nowPlayingClient?.activePlayer !== player || player.currentItem?.identifier !== identifier) return null;
1020
+ return player.currentItem;
1021
+ }
1022
+ };
1023
+
1024
+ //#endregion
1025
+ //#region src/mrp/additionalMetadata.ts
1026
+ const cache = /* @__PURE__ */ new WeakMap();
1027
+ /**
1028
+ * Reads `TVRAdditionalMetadata` from `ContentItemMetadata.nowPlayingInfoData`. The Apple TV app
1029
+ * sends season, episode, genre and rating only there. Results are cached per data buffer; a
1030
+ * buffer that does not decode yields null.
1031
+ */
1032
+ function decodeAdditionalMetadata(data) {
1033
+ if (!data?.byteLength) return null;
1034
+ if (cache.has(data)) return cache.get(data) ?? null;
1035
+ const metadata = parse(data);
1036
+ cache.set(data, metadata);
1037
+ return metadata;
1038
+ }
1039
+ function parse(data) {
1040
+ try {
1041
+ const extra = NSKeyedArchiver.decode(Plist.parse(Uint8Array.from(data).buffer))?.["TVRAdditionalMetadata"];
1042
+ if (!extra || typeof extra !== "object" || Array.isArray(extra)) return null;
1043
+ const entries = extra;
1044
+ return {
1045
+ seasonNumber: toNumber(entries["avkt/com.apple.avkit.seasonNumber"]),
1046
+ episodeNumber: toNumber(entries["avkt/com.apple.avkit.episodeNumber"]),
1047
+ episodeTitle: toText(entries["mdta/com.apple.hls.episode-title"]),
1048
+ genre: toText(entries["mdta/com.apple.hls.genre"]),
1049
+ contentRating: toText(entries["mdta/com.apple.hls.rating-display-name"])
1050
+ };
1051
+ } catch {
1052
+ return null;
1053
+ }
1054
+ }
1055
+ function toNumber(value) {
1056
+ const number = typeof value === "string" ? Number(value) : value;
1057
+ return typeof number === "number" && Number.isInteger(number) && number > 0 ? number : 0;
1058
+ }
1059
+ function toText(value) {
1060
+ return typeof value === "string" ? value : "";
1061
+ }
1062
+
1063
+ //#endregion
1064
+ //#region src/mrp/player.ts
1065
+ /** Seconds between the Cocoa epoch (2001-01-01) and the Unix epoch (1970-01-01). */
1066
+ const COCOA_EPOCH_OFFSET = 978307200;
1067
+ /** The player the receiver reports when no specific player is active. */
1068
+ const DEFAULT_PLAYER_ID = "MediaRemote-DefaultPlayer";
1069
+ /** The artwork CDN serves HEIC by default, which most browsers cannot show; a `.jpg` extension makes it return JPEG. */
1070
+ function convertArtworkUrl(url) {
1071
+ return url.replace(/\.heic(\b|$)/gi, ".jpg");
1072
+ }
1073
+ /**
1074
+ * Moves an elapsed time from its snapshot to now at the playback rate, never below 0.
1075
+ *
1076
+ * @param elapsed - Seconds at the moment of the snapshot.
1077
+ * @param cocoaTimestamp - Moment of the snapshot, in seconds since the Cocoa epoch.
1078
+ */
1079
+ function extrapolateElapsed(elapsed, cocoaTimestamp, rate) {
1080
+ if (!rate) return elapsed;
1081
+ const timestampUnix = cocoaTimestamp + COCOA_EPOCH_OFFSET;
1082
+ const delta = (Date.now() / 1e3 - timestampUnix) * rate;
1083
+ return Math.max(0, elapsed + delta);
1084
+ }
1085
+ /**
1086
+ * One player of a now-playing app; an app can have several, such as picture-in-picture. Tracks
1087
+ * the now-playing metadata and playback state, and extrapolates the elapsed time from the Cocoa
1088
+ * timestamp and the playback rate.
1089
+ */
1090
+ var Player = class {
1091
+ /** The player identifier, such as a player path. */
1092
+ get identifier() {
1093
+ return this.#identifier;
1094
+ }
1095
+ get displayName() {
1096
+ return this.#displayName;
1097
+ }
1098
+ /** Whether this is `MediaRemote-DefaultPlayer`, the player of an app without a specific one. */
1099
+ get isDefaultPlayer() {
1100
+ return this.#identifier === DEFAULT_PLAYER_ID;
1101
+ }
1102
+ get nowPlayingInfo() {
1103
+ return this.#nowPlayingInfo;
1104
+ }
1105
+ get playbackQueue() {
1106
+ return this.#playbackQueue;
1107
+ }
1108
+ get playbackState() {
1109
+ return this.#playbackState;
1110
+ }
1111
+ /** The timestamp of the last playback state update; an older update is ignored. */
1112
+ get playbackStateTimestamp() {
1113
+ return this.#playbackStateTimestamp;
1114
+ }
1115
+ get supportedCommands() {
1116
+ return this.#supportedCommands;
1117
+ }
1118
+ get title() {
1119
+ return this.#nowPlayingInfo?.title || this.currentItemMetadata?.title || "";
1120
+ }
1121
+ get artist() {
1122
+ return this.#nowPlayingInfo?.artist || this.currentItemMetadata?.trackArtistName || "";
1123
+ }
1124
+ get album() {
1125
+ return this.#nowPlayingInfo?.album || this.currentItemMetadata?.albumName || "";
1126
+ }
1127
+ get genre() {
1128
+ return this.currentItemMetadata?.genre || this.#additionalMetadata?.genre || "";
1129
+ }
1130
+ get seriesName() {
1131
+ return this.currentItemMetadata?.seriesName || "";
1132
+ }
1133
+ /** The season number, or 0 when unknown. */
1134
+ get seasonNumber() {
1135
+ return this.currentItemMetadata?.seasonNumber || this.#additionalMetadata?.seasonNumber || 0;
1136
+ }
1137
+ /** The episode number, or 0 when unknown. */
1138
+ get episodeNumber() {
1139
+ return this.currentItemMetadata?.episodeNumber || this.#additionalMetadata?.episodeNumber || 0;
1140
+ }
1141
+ /** The episode title. Only the Apple TV app sends it, in its archived now-playing info. */
1142
+ get episodeTitle() {
1143
+ return this.#additionalMetadata?.episodeTitle || "";
1144
+ }
1145
+ /** The localized content rating, such as `12+`. */
1146
+ get contentRating() {
1147
+ return this.currentItemMetadata?.localizedContentRating || this.#additionalMetadata?.contentRating || "";
1148
+ }
1149
+ get mediaType() {
1150
+ return this.currentItemMetadata?.mediaType ?? 0;
1151
+ }
1152
+ /** The content identifier of the current item, such as a store ID. */
1153
+ get contentIdentifier() {
1154
+ return this.currentItemMetadata?.contentIdentifier || "";
1155
+ }
1156
+ /** The duration of the current item in seconds. */
1157
+ get duration() {
1158
+ return this.#nowPlayingInfo?.duration || this.currentItemMetadata?.duration || 0;
1159
+ }
1160
+ /** The playback rate, where 1 is normal speed, 0 is paused and 2 is double speed. */
1161
+ get playbackRate() {
1162
+ return this.#nowPlayingInfo?.playbackRate ?? this.currentItemMetadata?.playbackRate ?? 0;
1163
+ }
1164
+ get isPlaying() {
1165
+ return this.playbackState === 1;
1166
+ }
1167
+ /** The shuffle mode, read from the info of the ChangeShuffleMode command. */
1168
+ get shuffleMode() {
1169
+ return this.#supportedCommands.find((commandInfo) => commandInfo.command === 47)?.shuffleMode ?? 0;
1170
+ }
1171
+ /** The repeat mode, read from the info of the ChangeRepeatMode command. */
1172
+ get repeatMode() {
1173
+ return this.#supportedCommands.find((commandInfo) => commandInfo.command === 46)?.repeatMode ?? 0;
1174
+ }
1175
+ /**
1176
+ * The elapsed time in seconds, extrapolated from the newer timestamp of the now-playing info
1177
+ * and the content item. Metadata after a track change can report rate 0 during playback, so the
1178
+ * player's rate fills in. A paused player can still report rate 1, so its position stays put.
1179
+ */
1180
+ get elapsedTime() {
1181
+ const info = this.#nowPlayingInfo;
1182
+ const metadata = this.currentItemMetadata;
1183
+ const infoValid = Number.isFinite(info?.elapsedTime) && Number.isFinite(info?.timestamp) && info.timestamp !== 0;
1184
+ const metadataValid = Number.isFinite(metadata?.elapsedTime) && Number.isFinite(metadata?.elapsedTimeTimestamp) && metadata.elapsedTimeTimestamp !== 0;
1185
+ if (!infoValid && !metadataValid) return info?.elapsedTime || metadata?.elapsedTime || 0;
1186
+ const newest = metadataValid && (!infoValid || metadata.elapsedTimeTimestamp > info.timestamp) ? {
1187
+ elapsed: metadata.elapsedTime,
1188
+ timestamp: metadata.elapsedTimeTimestamp,
1189
+ rate: metadata.playbackRate
1190
+ } : {
1191
+ elapsed: info.elapsedTime,
1192
+ timestamp: info.timestamp,
1193
+ rate: info.playbackRate
1194
+ };
1195
+ if (this.#playbackState !== 1 && this.#playbackState !== 0) return newest.elapsed;
1196
+ return extrapolateElapsed(newest.elapsed, newest.timestamp, newest.rate || this.playbackRate);
1197
+ }
1198
+ /** The item at the queue location, or the first item when the location is out of range. */
1199
+ get currentItem() {
1200
+ if (!this.#playbackQueue || this.#playbackQueue.contentItems.length === 0) return null;
1201
+ return this.#playbackQueue.contentItems[this.#playbackQueue.location] ?? this.#playbackQueue.contentItems[0] ?? null;
1202
+ }
1203
+ get currentItemMetadata() {
1204
+ return this.currentItem?.metadata ?? null;
1205
+ }
1206
+ /** An identifier that changes with the artwork, or null when the metadata shows no sign of artwork. */
1207
+ get artworkId() {
1208
+ const metadata = this.currentItemMetadata;
1209
+ if (!metadata) return null;
1210
+ if (!metadata.artworkAvailable && !metadata.artworkURL && !metadata.artworkIdentifier) return null;
1211
+ if (metadata.artworkIdentifier) return metadata.artworkIdentifier;
1212
+ if (metadata.contentIdentifier) return metadata.contentIdentifier;
1213
+ return this.currentItem?.identifier ?? null;
1214
+ }
1215
+ /**
1216
+ * The artwork URL of the current item, from the metadata `artworkURL`, the remote artworks or
1217
+ * the artwork identifier as a URL template, in that order.
1218
+ *
1219
+ * @param width - In pixels; only a template uses it.
1220
+ * @param height - In pixels; -1 keeps the aspect ratio.
1221
+ */
1222
+ artworkUrl(width = 600, height = -1) {
1223
+ const metadata = this.currentItemMetadata;
1224
+ if (metadata?.artworkURL) return convertArtworkUrl(metadata.artworkURL);
1225
+ const item = this.currentItem;
1226
+ if (item?.remoteArtworks.length > 0 && item.remoteArtworks[0].artworkURLString) return convertArtworkUrl(item.remoteArtworks[0].artworkURLString);
1227
+ if (!metadata?.artworkIdentifier) return null;
1228
+ const url = metadata.artworkIdentifier.replace("{w}", String(width < 1 ? 999999 : width)).replace("{h}", String(height < 1 ? 999999 : height)).replace("{c}", "bb").replace("{f}", "jpg");
1229
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
1230
+ return null;
1231
+ }
1232
+ /** The image bytes embedded in the current item, or null. */
1233
+ get currentItemArtwork() {
1234
+ const item = this.currentItem;
1235
+ if (!item) return null;
1236
+ if (item.artworkData?.byteLength > 0) return item.artworkData;
1237
+ if (item.dataArtworks.length > 0 && item.dataArtworks[0].imageData?.byteLength > 0) return item.dataArtworks[0].imageData;
1238
+ return null;
1239
+ }
1240
+ /** The artwork URL at 600 pixels wide, or null. */
1241
+ get currentItemArtworkUrl() {
1242
+ return this.artworkUrl();
1243
+ }
1244
+ get currentItemLyrics() {
1245
+ return this.currentItem?.lyrics ?? null;
1246
+ }
1247
+ get #additionalMetadata() {
1248
+ return decodeAdditionalMetadata(this.currentItemMetadata?.nowPlayingInfoData);
1249
+ }
1250
+ #identifier;
1251
+ #displayName;
1252
+ #nowPlayingInfo = null;
1253
+ #playbackQueue = null;
1254
+ #playbackState;
1255
+ #playbackStateTimestamp = 0;
1256
+ #supportedCommands = [];
1257
+ constructor(identifier, displayName) {
1258
+ this.#identifier = identifier;
1259
+ this.#displayName = displayName;
1260
+ this.#playbackState = 0;
1261
+ }
1262
+ findCommand(command) {
1263
+ return this.#supportedCommands.find((commandInfo) => commandInfo.command === command) ?? null;
1264
+ }
1265
+ /** Whether the command is in the list and not disabled. */
1266
+ isCommandSupported(command) {
1267
+ const info = this.findCommand(command);
1268
+ return info != null && info.enabled !== false;
1269
+ }
1270
+ setNowPlayingInfo(nowPlayingInfo) {
1271
+ this.#nowPlayingInfo = nowPlayingInfo;
1272
+ }
1273
+ setPlaybackQueue(playbackQueue) {
1274
+ this.#playbackQueue = playbackQueue;
1275
+ }
1276
+ /** Ignores an update with a timestamp older than the current one, so stale state never wins. */
1277
+ setPlaybackState(playbackState, playbackStateTimestamp) {
1278
+ if (playbackStateTimestamp < this.#playbackStateTimestamp) return;
1279
+ this.#playbackState = playbackState;
1280
+ this.#playbackStateTimestamp = playbackStateTimestamp;
1281
+ }
1282
+ setSupportedCommands(supportedCommands) {
1283
+ this.#supportedCommands = supportedCommands;
1284
+ }
1285
+ /** Merges the fields an update carries into the queue item with the same identifier. */
1286
+ updateContentItem(item) {
1287
+ if (!this.#playbackQueue) return;
1288
+ const existing = this.#playbackQueue.contentItems.find((contentItem) => contentItem.identifier === item.identifier);
1289
+ if (!existing) return;
1290
+ if (item.metadata != null && existing.metadata != null) {
1291
+ for (const [key, value] of Object.entries(item.metadata)) if (value != null && !key.startsWith("$")) existing.metadata[key] = value;
1292
+ } else if (item.metadata != null) existing.metadata = item.metadata;
1293
+ if (item.artworkData != null) existing.artworkData = item.artworkData;
1294
+ if (item.dataArtworks.length > 0) existing.dataArtworks = item.dataArtworks;
1295
+ if (item.remoteArtworks.length > 0) existing.remoteArtworks = item.remoteArtworks;
1296
+ if (item.animatedArtworks.length > 0) existing.animatedArtworks = item.animatedArtworks;
1297
+ if (item.availableAnimatedArtworkFormats.length > 0) existing.availableAnimatedArtworkFormats = item.availableAnimatedArtworkFormats;
1298
+ if (item.animatedArtworkPreviewFrames.length > 0) existing.animatedArtworkPreviewFrames = item.animatedArtworkPreviewFrames;
1299
+ if (item.lyrics != null) existing.lyrics = item.lyrics;
1300
+ if (item.info != null) existing.info = item.info;
1301
+ }
1302
+ };
1303
+
1304
+ //#endregion
1305
+ //#region src/mrp/client.ts
1306
+ /**
1307
+ * A now-playing app on the receiver, keyed by bundle identifier, with one or more players. The
1308
+ * now-playing getters read from the active player.
1309
+ */
1310
+ var Client = class {
1311
+ /** The bundle identifier of the app, such as `com.apple.TVMusic`. */
1312
+ get bundleIdentifier() {
1313
+ return this.#bundleIdentifier;
1314
+ }
1315
+ get displayName() {
1316
+ return this.#displayName;
1317
+ }
1318
+ /** The players of this app, keyed by player identifier. */
1319
+ get players() {
1320
+ return this.#players;
1321
+ }
1322
+ /** The player set active, or the default player when none is set; null when that player does not exist. */
1323
+ get activePlayer() {
1324
+ return this.#players.get(this.#activePlayerId ?? "MediaRemote-DefaultPlayer") ?? null;
1325
+ }
1326
+ get nowPlayingInfo() {
1327
+ return this.activePlayer?.nowPlayingInfo ?? null;
1328
+ }
1329
+ get playbackQueue() {
1330
+ return this.activePlayer?.playbackQueue ?? null;
1331
+ }
1332
+ get playbackState() {
1333
+ return this.activePlayer?.playbackState ?? 0;
1334
+ }
1335
+ /** The timestamp of the last playback state update, or -1 without an active player. */
1336
+ get playbackStateTimestamp() {
1337
+ return this.activePlayer?.playbackStateTimestamp ?? -1;
1338
+ }
1339
+ /** The commands of the active player, followed by the client defaults it does not list. */
1340
+ get supportedCommands() {
1341
+ const playerCommands = this.activePlayer?.supportedCommands ?? [];
1342
+ if (playerCommands.length === 0) return this.#defaultSupportedCommands;
1343
+ if (this.#defaultSupportedCommands.length === 0) return playerCommands;
1344
+ const playerCommandSet = new Set(playerCommands.map((commandInfo) => commandInfo.command));
1345
+ const merged = [...playerCommands];
1346
+ for (const commandInfo of this.#defaultSupportedCommands) if (!playerCommandSet.has(commandInfo.command)) merged.push(commandInfo);
1347
+ return merged;
1348
+ }
1349
+ get title() {
1350
+ return this.activePlayer?.title ?? "";
1351
+ }
1352
+ get artist() {
1353
+ return this.activePlayer?.artist ?? "";
1354
+ }
1355
+ get album() {
1356
+ return this.activePlayer?.album ?? "";
1357
+ }
1358
+ get genre() {
1359
+ return this.activePlayer?.genre ?? "";
1360
+ }
1361
+ get seriesName() {
1362
+ return this.activePlayer?.seriesName ?? "";
1363
+ }
1364
+ get seasonNumber() {
1365
+ return this.activePlayer?.seasonNumber ?? 0;
1366
+ }
1367
+ get episodeNumber() {
1368
+ return this.activePlayer?.episodeNumber ?? 0;
1369
+ }
1370
+ get episodeTitle() {
1371
+ return this.activePlayer?.episodeTitle ?? "";
1372
+ }
1373
+ get contentRating() {
1374
+ return this.activePlayer?.contentRating ?? "";
1375
+ }
1376
+ get mediaType() {
1377
+ return this.activePlayer?.mediaType ?? 0;
1378
+ }
1379
+ get contentIdentifier() {
1380
+ return this.activePlayer?.contentIdentifier ?? "";
1381
+ }
1382
+ /** The duration of the current item in seconds. */
1383
+ get duration() {
1384
+ return this.activePlayer?.duration ?? 0;
1385
+ }
1386
+ /** The playback rate, where 1 is normal speed and 0 is paused. */
1387
+ get playbackRate() {
1388
+ return this.activePlayer?.playbackRate ?? 0;
1389
+ }
1390
+ get isPlaying() {
1391
+ return this.activePlayer?.isPlaying ?? false;
1392
+ }
1393
+ get shuffleMode() {
1394
+ return this.activePlayer?.shuffleMode ?? 0;
1395
+ }
1396
+ get repeatMode() {
1397
+ return this.activePlayer?.repeatMode ?? 0;
1398
+ }
1399
+ /** The elapsed time in seconds, extrapolated to now. */
1400
+ get elapsedTime() {
1401
+ return this.activePlayer?.elapsedTime ?? 0;
1402
+ }
1403
+ /** An identifier that changes with the artwork, or null without artwork. */
1404
+ get artworkId() {
1405
+ return this.activePlayer?.artworkId ?? null;
1406
+ }
1407
+ /**
1408
+ * The artwork URL of the current item; see {@link Player.artworkUrl}.
1409
+ *
1410
+ * @param width - In pixels.
1411
+ * @param height - In pixels; -1 keeps the aspect ratio.
1412
+ */
1413
+ artworkUrl(width = 600, height = -1) {
1414
+ return this.activePlayer?.artworkUrl(width, height) ?? null;
1415
+ }
1416
+ get currentItem() {
1417
+ return this.activePlayer?.currentItem ?? null;
1418
+ }
1419
+ get currentItemMetadata() {
1420
+ return this.activePlayer?.currentItemMetadata ?? null;
1421
+ }
1422
+ /** The image bytes embedded in the current item, or null. */
1423
+ get currentItemArtwork() {
1424
+ return this.activePlayer?.currentItemArtwork ?? null;
1425
+ }
1426
+ /** The artwork URL at 600 pixels wide, or null. */
1427
+ get currentItemArtworkUrl() {
1428
+ return this.activePlayer?.currentItemArtworkUrl ?? null;
1429
+ }
1430
+ get currentItemLyrics() {
1431
+ return this.activePlayer?.currentItemLyrics ?? null;
1432
+ }
1433
+ #bundleIdentifier;
1434
+ #displayName;
1435
+ #players = /* @__PURE__ */ new Map();
1436
+ #activePlayerId = null;
1437
+ #defaultSupportedCommands = [];
1438
+ constructor(bundleIdentifier, displayName) {
1439
+ this.#bundleIdentifier = bundleIdentifier;
1440
+ this.#displayName = displayName;
1441
+ }
1442
+ /**
1443
+ * Returns the player with this identifier, created when it does not exist yet.
1444
+ *
1445
+ * @param displayName - The name of a new player; the identifier when omitted.
1446
+ */
1447
+ getOrCreatePlayer(identifier, displayName) {
1448
+ let player = this.#players.get(identifier);
1449
+ if (!player) {
1450
+ player = new Player(identifier, displayName ?? identifier);
1451
+ this.#players.set(identifier, player);
1452
+ }
1453
+ return player;
1454
+ }
1455
+ setActivePlayer(identifier) {
1456
+ this.#activePlayerId = identifier;
1457
+ }
1458
+ /** Removes a player; removing the active one makes the default player active. */
1459
+ removePlayer(identifier) {
1460
+ this.#players.delete(identifier);
1461
+ if (this.#activePlayerId === identifier) this.#activePlayerId = null;
1462
+ }
1463
+ /** Sets the commands that complete the list of the active player. */
1464
+ setDefaultSupportedCommands(supportedCommands) {
1465
+ this.#defaultSupportedCommands = supportedCommands;
1466
+ }
1467
+ /** Finds a command in the list of the active player first, then in the defaults. */
1468
+ findCommand(command) {
1469
+ const playerCommand = this.activePlayer?.findCommand(command) ?? null;
1470
+ if (playerCommand) return playerCommand;
1471
+ return this.#defaultSupportedCommands.find((commandInfo) => commandInfo.command === command) ?? null;
1472
+ }
1473
+ /** Whether {@link findCommand} finds the command and it is not disabled. */
1474
+ isCommandSupported(command) {
1475
+ const info = this.findCommand(command);
1476
+ return info != null && info.enabled !== false;
1477
+ }
1478
+ updateDisplayName(displayName) {
1479
+ this.#displayName = displayName;
1480
+ }
1481
+ };
1482
+
1483
+ //#endregion
1484
+ //#region src/mrp/state.ts
1485
+ /**
1486
+ * The MRP state of a receiver as a model: clients and their players, what plays now, volume,
1487
+ * keyboard, output devices and cluster. It derives everything from the messages it is given with
1488
+ * {@link apply} and does no I/O, so recorded traffic feeds it as well as a live data channel.
1489
+ */
1490
+ var MediaRemoteState = class extends EventEmitter {
1491
+ /** The now-playing apps, keyed by bundle identifier. */
1492
+ get clients() {
1493
+ return this.#clients;
1494
+ }
1495
+ /** Whether a text input session is open on the receiver. */
1496
+ get isKeyboardActive() {
1497
+ return this.#keyboardState === 2 || this.#keyboardState === 3 || this.#keyboardState === 4;
1498
+ }
1499
+ get keyboardAttributes() {
1500
+ return this.#keyboardAttributes;
1501
+ }
1502
+ get keyboardState() {
1503
+ return this.#keyboardState;
1504
+ }
1505
+ /** The app that plays now, or null when the receiver named none. */
1506
+ get nowPlayingClient() {
1507
+ return this.#nowPlayingClientBundleIdentifier ? this.#clients[this.#nowPlayingClientBundleIdentifier] ?? null : null;
1508
+ }
1509
+ /** The device that volume commands address; the cluster ID for a clustered HomePod pair. */
1510
+ get outputDeviceUID() {
1511
+ return this.#outputDeviceUID;
1512
+ }
1513
+ /** The devices in the current AirPlay group. */
1514
+ get outputDevices() {
1515
+ return this.#outputDevices;
1516
+ }
1517
+ /** The identifier of the multiroom cluster, or null outside one. */
1518
+ get clusterID() {
1519
+ return this.#clusterID;
1520
+ }
1521
+ /** The cluster type code, 0 outside a cluster. */
1522
+ get clusterType() {
1523
+ return this.#clusterType;
1524
+ }
1525
+ get isClusterAware() {
1526
+ return this.#isClusterAware;
1527
+ }
1528
+ get isClusterLeader() {
1529
+ return this.#isClusterLeader;
1530
+ }
1531
+ /** The playback queue participants, such as SharePlay users. */
1532
+ get participants() {
1533
+ return this.#participants;
1534
+ }
1535
+ /** The JPEG bytes of the last SET_ARTWORK, or null before one arrived. */
1536
+ get artworkJpegData() {
1537
+ return this.#artworkJpegData;
1538
+ }
1539
+ /** The volume, from 0 to 1. */
1540
+ get volume() {
1541
+ return this.#volume;
1542
+ }
1543
+ get volumeAvailable() {
1544
+ return this.#volumeAvailable;
1545
+ }
1546
+ get volumeCapabilities() {
1547
+ return this.#volumeCapabilities;
1548
+ }
1549
+ get volumeMuted() {
1550
+ return this.#volumeMuted;
1551
+ }
1552
+ #clients;
1553
+ #keyboardAttributes;
1554
+ #keyboardState;
1555
+ #nowPlayingClientBundleIdentifier;
1556
+ #nowPlayingSnapshot;
1557
+ #outputDeviceUID;
1558
+ #outputDevices = [];
1559
+ #clusterID;
1560
+ #clusterType;
1561
+ #isClusterAware;
1562
+ #isClusterLeader;
1563
+ #participants;
1564
+ #artworkJpegData;
1565
+ #volume;
1566
+ #volumeAvailable;
1567
+ #volumeCapabilities;
1568
+ #volumeMuted;
1569
+ constructor() {
1570
+ super();
1571
+ this.clear();
1572
+ }
1573
+ /**
1574
+ * Applies one decoded message. Messages the state does not track are ignored.
1575
+ *
1576
+ * @example
1577
+ * const decoded = decodeMessage(protocolMessage);
1578
+ * decoded && state.apply(...decoded);
1579
+ */
1580
+ apply(...[name, message]) {
1581
+ switch (name) {
1582
+ case "keyboard": return this.#onKeyboard(message);
1583
+ case "deviceInfo":
1584
+ case "deviceInfoUpdate": return this.#updateDeviceInfo(message);
1585
+ case "removeClient": return this.#onRemoveClient(message);
1586
+ case "removePlayer": return this.#onRemovePlayer(message);
1587
+ case "sendLyricsEvent": return this.#onSendLyricsEvent(message);
1588
+ case "setArtwork": return this.#onSetArtwork(message);
1589
+ case "setDefaultSupportedCommands": return this.#onSetDefaultSupportedCommands(message);
1590
+ case "setNowPlayingClient": return this.#onSetNowPlayingClient(message);
1591
+ case "setNowPlayingPlayer": return this.#onSetNowPlayingPlayer(message);
1592
+ case "setState": return this.#onSetState(message);
1593
+ case "updateClient": return this.#onUpdateClient(message);
1594
+ case "updateContentItem": return this.#onUpdateContentItem(message);
1595
+ case "updateContentItemArtwork": return this.#onUpdateContentItemArtwork(message);
1596
+ case "updatePlayer": return this.#onUpdatePlayer(message);
1597
+ case "playerClientParticipantsUpdate": return this.#onPlayerClientParticipantsUpdate(message);
1598
+ case "updateOutputDevice": return this.#onUpdateOutputDevice(message);
1599
+ case "volumeControlAvailability": return this.#onVolumeControlAvailability(message);
1600
+ case "volumeControlCapabilitiesDidChange": return this.#onVolumeControlCapabilitiesDidChange(message);
1601
+ case "volumeDidChange": return this.#onVolumeDidChange(message);
1602
+ case "volumeMutedDidChange": return this.#onVolumeMutedDidChange(message);
1603
+ }
1604
+ }
1605
+ /**
1606
+ * Resets every value to its initial one. A state that outlives a session is cleared when the
1607
+ * next one connects, not when the last one ends.
1608
+ */
1609
+ clear() {
1610
+ this.#clients = {};
1611
+ this.#keyboardAttributes = null;
1612
+ this.#keyboardState = 0;
1613
+ this.#nowPlayingClientBundleIdentifier = null;
1614
+ this.#nowPlayingSnapshot = null;
1615
+ this.#outputDeviceUID = null;
1616
+ this.#outputDevices = [];
1617
+ this.#clusterID = null;
1618
+ this.#clusterType = 0;
1619
+ this.#isClusterAware = false;
1620
+ this.#isClusterLeader = false;
1621
+ this.#participants = [];
1622
+ this.#artworkJpegData = null;
1623
+ this.#volume = 0;
1624
+ this.#volumeAvailable = false;
1625
+ this.#volumeCapabilities = 0;
1626
+ this.#volumeMuted = false;
1627
+ }
1628
+ #onKeyboard(message) {
1629
+ this.#keyboardState = message.state;
1630
+ this.#keyboardAttributes = message.attributes ?? null;
1631
+ this.emit("keyboardChanged", this.#keyboardState, this.#keyboardAttributes);
1632
+ }
1633
+ #onRemoveClient(message) {
1634
+ if (!message.client?.bundleIdentifier) return;
1635
+ if (!(message.client.bundleIdentifier in this.#clients)) return;
1636
+ const wasActive = this.#nowPlayingClientBundleIdentifier === message.client.bundleIdentifier;
1637
+ delete this.#clients[message.client.bundleIdentifier];
1638
+ if (wasActive) this.#nowPlayingClientBundleIdentifier = null;
1639
+ this.emit("clients", this.#clients);
1640
+ if (wasActive) {
1641
+ this.#emitActivePlayerChanged();
1642
+ this.#emitNowPlayingChangedIfNeeded();
1643
+ }
1644
+ }
1645
+ #onSendLyricsEvent(message) {
1646
+ if (message.event) this.emit("lyricsEvent", message.event, message.playerPath);
1647
+ }
1648
+ #onSetArtwork(message) {
1649
+ if (message.jpegData?.byteLength > 0) this.#artworkJpegData = message.jpegData;
1650
+ const client = this.nowPlayingClient;
1651
+ const player = client?.activePlayer;
1652
+ if (client && player) this.emit("artworkChanged", client, player);
1653
+ }
1654
+ #onSetDefaultSupportedCommands(message) {
1655
+ if (!message.playerPath?.client?.bundleIdentifier || !message.supportedCommands) return;
1656
+ this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName).setDefaultSupportedCommands(message.supportedCommands.supportedCommands);
1657
+ }
1658
+ #onSetNowPlayingClient(message) {
1659
+ const oldBundleId = this.#nowPlayingClientBundleIdentifier;
1660
+ this.#nowPlayingClientBundleIdentifier = message.client?.bundleIdentifier ?? null;
1661
+ if (message.client?.bundleIdentifier && message.client?.displayName) this.#client(message.client.bundleIdentifier, message.client.displayName);
1662
+ if (oldBundleId !== this.#nowPlayingClientBundleIdentifier) this.#emitActivePlayerChanged();
1663
+ this.#emitNowPlayingChangedIfNeeded();
1664
+ }
1665
+ #onSetNowPlayingPlayer(message) {
1666
+ if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) {
1667
+ const client = this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName);
1668
+ const oldActiveId = client.activePlayer?.identifier;
1669
+ client.getOrCreatePlayer(message.playerPath.player.identifier, message.playerPath.player.displayName);
1670
+ client.setActivePlayer(message.playerPath.player.identifier);
1671
+ if (oldActiveId !== message.playerPath.player.identifier) this.#emitActivePlayerChanged();
1672
+ }
1673
+ this.#emitNowPlayingChangedIfNeeded();
1674
+ }
1675
+ /** One SET_STATE can carry playback state, now-playing info, commands and queue at once; only the active player of the active client emits. */
1676
+ #onSetState(message) {
1677
+ const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
1678
+ if (!bundleIdentifier) return;
1679
+ const client = this.#client(bundleIdentifier, message.displayName);
1680
+ const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
1681
+ const player = client.getOrCreatePlayer(playerIdentifier, message.playerPath?.player?.displayName);
1682
+ const isActive = bundleIdentifier === this.#nowPlayingClientBundleIdentifier && player === client.activePlayer;
1683
+ if (message.playbackState) {
1684
+ const oldState = player.playbackState;
1685
+ player.setPlaybackState(message.playbackState, message.playbackStateTimestamp);
1686
+ if (isActive && oldState !== player.playbackState) this.emit("playbackStateChanged", client, player, oldState, player.playbackState);
1687
+ }
1688
+ if (message.nowPlayingInfo) player.setNowPlayingInfo(message.nowPlayingInfo);
1689
+ if (message.supportedCommands) {
1690
+ player.setSupportedCommands(message.supportedCommands.supportedCommands);
1691
+ if (isActive) this.emit("supportedCommandsChanged", client, player, player.supportedCommands);
1692
+ }
1693
+ if (message.playbackQueue) {
1694
+ player.setPlaybackQueue(message.playbackQueue);
1695
+ if (isActive) this.emit("playbackQueueChanged", client, player);
1696
+ }
1697
+ if (isActive) this.#emitNowPlayingChangedIfNeeded();
1698
+ }
1699
+ #onUpdateContentItem(message) {
1700
+ const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
1701
+ if (!bundleIdentifier) return;
1702
+ const client = this.#client(bundleIdentifier, message.playerPath?.client?.displayName ?? "");
1703
+ const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
1704
+ const player = client.getOrCreatePlayer(playerIdentifier, message.playerPath?.player?.displayName);
1705
+ for (const item of message.contentItems) player.updateContentItem(item);
1706
+ if (bundleIdentifier === this.#nowPlayingClientBundleIdentifier) this.#emitNowPlayingChangedIfNeeded();
1707
+ }
1708
+ #onUpdateContentItemArtwork(message) {
1709
+ const bundleIdentifier = message.playerPath?.client?.bundleIdentifier;
1710
+ if (bundleIdentifier) {
1711
+ const client = this.#client(bundleIdentifier, message.playerPath?.client?.displayName ?? "");
1712
+ const playerIdentifier = message.playerPath?.player?.identifier || "MediaRemote-DefaultPlayer";
1713
+ const player = client.getOrCreatePlayer(playerIdentifier, message.playerPath?.player?.displayName);
1714
+ for (const item of message.contentItems) player.updateContentItem(item);
1715
+ if (bundleIdentifier === this.#nowPlayingClientBundleIdentifier) this.#emitNowPlayingChangedIfNeeded();
1716
+ }
1717
+ const client = this.nowPlayingClient;
1718
+ const player = client?.activePlayer;
1719
+ if (client && player) this.emit("artworkChanged", client, player);
1720
+ }
1721
+ #onUpdatePlayer(message) {
1722
+ if (!message.playerPath?.client?.bundleIdentifier || !message.playerPath?.player?.identifier) return;
1723
+ this.#client(message.playerPath.client.bundleIdentifier, message.playerPath.client.displayName).getOrCreatePlayer(message.playerPath.player.identifier, message.playerPath.player.displayName);
1724
+ }
1725
+ #onRemovePlayer(message) {
1726
+ if (message.playerPath?.client?.bundleIdentifier && message.playerPath?.player?.identifier) {
1727
+ const client = this.#clients[message.playerPath.client.bundleIdentifier];
1728
+ if (client) client.removePlayer(message.playerPath.player.identifier);
1729
+ }
1730
+ if (message.playerPath?.client?.bundleIdentifier === this.#nowPlayingClientBundleIdentifier) {
1731
+ this.#emitActivePlayerChanged();
1732
+ this.#emitNowPlayingChangedIfNeeded();
1733
+ }
1734
+ }
1735
+ #onUpdateClient(message) {
1736
+ if (!message.client?.bundleIdentifier) return;
1737
+ this.#client(message.client.bundleIdentifier, message.client.displayName);
1738
+ this.emit("clients", this.#clients);
1739
+ }
1740
+ #onPlayerClientParticipantsUpdate(message) {
1741
+ this.#participants = message.participants ?? [];
1742
+ this.emit("participantsChanged", this.#participants);
1743
+ }
1744
+ /** Prefers the cluster-aware list when the receiver sends one. */
1745
+ #onUpdateOutputDevice(message) {
1746
+ this.#outputDevices = message.clusterAwareOutputDevices?.length > 0 ? message.clusterAwareOutputDevices : message.outputDevices;
1747
+ this.emit("outputDevicesChanged", this.#outputDevices);
1748
+ }
1749
+ #onVolumeControlAvailability(message) {
1750
+ this.#volumeAvailable = message.volumeControlAvailable;
1751
+ this.#volumeCapabilities = message.volumeCapabilities;
1752
+ this.emit("volumeControlChanged", this.#volumeAvailable, this.#volumeCapabilities);
1753
+ }
1754
+ #onVolumeControlCapabilitiesDidChange(message) {
1755
+ if (!message.capabilities) return;
1756
+ this.#volumeAvailable = message.capabilities.volumeControlAvailable;
1757
+ this.#volumeCapabilities = message.capabilities.volumeCapabilities;
1758
+ this.emit("volumeControlChanged", this.#volumeAvailable, this.#volumeCapabilities);
1759
+ }
1760
+ #onVolumeDidChange(message) {
1761
+ this.#volume = message.volume;
1762
+ this.emit("volumeChanged", message.volume);
1763
+ }
1764
+ #onVolumeMutedDidChange(message) {
1765
+ this.#volumeMuted = message.isMuted;
1766
+ this.emit("volumeMutedChanged", this.#volumeMuted);
1767
+ }
1768
+ /** The cluster ID wins over the device UID, so volume commands address a clustered HomePod pair as one device. */
1769
+ #updateDeviceInfo(message) {
1770
+ const previousClusterID = this.#clusterID;
1771
+ const previousIsLeader = this.#isClusterLeader;
1772
+ this.#outputDeviceUID = message.clusterID || message.deviceUID || message.uniqueIdentifier || null;
1773
+ this.#clusterID = message.clusterID || null;
1774
+ this.#clusterType = message.clusterType ?? 0;
1775
+ this.#isClusterAware = message.isClusterAware ?? false;
1776
+ this.#isClusterLeader = message.isClusterLeader ?? false;
1777
+ if (this.#clusterID !== previousClusterID || this.#isClusterLeader !== previousIsLeader) this.emit("clusterChanged", this.#clusterID, this.#isClusterLeader);
1778
+ }
1779
+ /** Gets or creates the client; a non-empty display name replaces the known one. */
1780
+ #client(bundleIdentifier, displayName) {
1781
+ if (bundleIdentifier in this.#clients) {
1782
+ const client = this.#clients[bundleIdentifier];
1783
+ if (displayName) client.updateDisplayName(displayName);
1784
+ return client;
1785
+ }
1786
+ const client = new Client(bundleIdentifier, displayName);
1787
+ this.#clients[bundleIdentifier] = client;
1788
+ this.emit("clients", this.#clients);
1789
+ return client;
1790
+ }
1791
+ #createNowPlayingSnapshot() {
1792
+ const client = this.nowPlayingClient;
1793
+ const player = client?.activePlayer ?? null;
1794
+ const metadata = player?.currentItemMetadata;
1795
+ return {
1796
+ bundleIdentifier: client?.bundleIdentifier ?? null,
1797
+ displayName: client?.displayName ?? null,
1798
+ playerIdentifier: player?.identifier ?? null,
1799
+ playbackState: player?.playbackState ?? 0,
1800
+ title: player?.title ?? "",
1801
+ artist: player?.artist ?? "",
1802
+ trackArtist: metadata?.trackArtistName ?? "",
1803
+ album: player?.album ?? "",
1804
+ genre: player?.genre ?? "",
1805
+ duration: player?.duration ?? 0,
1806
+ playbackRate: player?.playbackRate ?? 0,
1807
+ shuffleMode: player?.shuffleMode ?? 0,
1808
+ repeatMode: player?.repeatMode ?? 0,
1809
+ mediaType: player?.mediaType ?? 0,
1810
+ seriesName: player?.seriesName ?? "",
1811
+ seasonNumber: player?.seasonNumber ?? 0,
1812
+ episodeNumber: player?.episodeNumber ?? 0,
1813
+ episodeTitle: player?.episodeTitle ?? "",
1814
+ contentRating: player?.contentRating ?? "",
1815
+ contentIdentifier: player?.contentIdentifier ?? "",
1816
+ storeAlbumId: String(metadata?.iTunesStoreAlbumIdentifier ?? ""),
1817
+ lyricsId: String(metadata?.lyricsAdamID ?? ""),
1818
+ artworkId: player?.artworkId ?? null,
1819
+ hasArtworkUrl: player?.artworkUrl() != null,
1820
+ hasArtworkData: player?.currentItemArtwork != null,
1821
+ isAlwaysLive: player?.nowPlayingInfo?.isAlwaysLive ?? false,
1822
+ isAdvertisement: player?.nowPlayingInfo?.isAdvertisement ?? false
1823
+ };
1824
+ }
1825
+ #emitActivePlayerChanged() {
1826
+ const client = this.nowPlayingClient;
1827
+ this.emit("activePlayerChanged", client, client?.activePlayer ?? null);
1828
+ }
1829
+ #emitNowPlayingChangedIfNeeded() {
1830
+ const snapshot = this.#createNowPlayingSnapshot();
1831
+ const previous = this.#nowPlayingSnapshot;
1832
+ if (previous && this.#snapshotsEqual(previous, snapshot)) return;
1833
+ this.#nowPlayingSnapshot = snapshot;
1834
+ const client = this.nowPlayingClient;
1835
+ this.emit("nowPlayingChanged", client, client?.activePlayer ?? null);
1836
+ }
1837
+ #snapshotsEqual(a, b) {
1838
+ return a.bundleIdentifier === b.bundleIdentifier && a.displayName === b.displayName && a.playerIdentifier === b.playerIdentifier && a.playbackState === b.playbackState && a.title === b.title && a.artist === b.artist && a.trackArtist === b.trackArtist && 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.episodeTitle === b.episodeTitle && a.contentRating === b.contentRating && a.contentIdentifier === b.contentIdentifier && a.storeAlbumId === b.storeAlbumId && a.lyricsId === b.lyricsId && a.artworkId === b.artworkId && a.hasArtworkUrl === b.hasArtworkUrl && a.hasArtworkData === b.hasArtworkData && a.isAlwaysLive === b.isAlwaysLive && a.isAdvertisement === b.isAdvertisement;
1839
+ }
1840
+ };
1841
+
1842
+ //#endregion
1843
+ //#region src/mrp/volume.ts
1844
+ /** One step of {@link Volume.up} and {@link Volume.down} on the 0 to 1 scale. */
1845
+ const VOLUME_STEP = .05;
1846
+ /** Time between the steps of a fade, in milliseconds. */
1847
+ const FADE_STEP_INTERVAL = 50;
1848
+ /**
1849
+ * Volume of the receiver on a 0 to 1 scale. Absolute volume goes to the output device the state
1850
+ * names; a receiver that only supports relative volume gets the HID volume keys instead.
1851
+ */
1852
+ var Volume = class {
1853
+ #channel;
1854
+ #context;
1855
+ #hid;
1856
+ #state;
1857
+ constructor(channel, context, state, hid) {
1858
+ this.#channel = channel;
1859
+ this.#context = context;
1860
+ this.#hid = hid;
1861
+ this.#state = state;
1862
+ }
1863
+ /** @throws {CommandError} When the receiver has no volume control. */
1864
+ async down() {
1865
+ switch (this.#state.volumeCapabilities) {
1866
+ case 2:
1867
+ case 3:
1868
+ await this.set(Math.max(0, this.#state.volume - VOLUME_STEP));
1869
+ break;
1870
+ case 1:
1871
+ await this.#hid.volumeDown();
1872
+ break;
1873
+ default: throw new CommandError("Volume control is not available.");
1874
+ }
1875
+ }
1876
+ /** @throws {CommandError} When the receiver has no volume control. */
1877
+ async up() {
1878
+ switch (this.#state.volumeCapabilities) {
1879
+ case 2:
1880
+ case 3:
1881
+ await this.set(Math.min(1, Math.max(0, this.#state.volume + VOLUME_STEP)));
1882
+ break;
1883
+ case 1:
1884
+ await this.#hid.volumeUp();
1885
+ break;
1886
+ default: throw new CommandError("Volume control is not available.");
1887
+ }
1888
+ }
1889
+ /** @throws {CommandError} When no output device is active or the receiver answers otherwise. */
1890
+ async get() {
1891
+ return await this.#getVolume(this.#requireOutputDevice(), "Failed to get volume.");
1892
+ }
1893
+ /**
1894
+ * Sets an absolute volume, clamped to 0 to 1.
1895
+ *
1896
+ * @throws {CommandError} When no output device is active or absolute volume is not available.
1897
+ */
1898
+ async set(volume) {
1899
+ const outputDeviceUID = this.#requireOutputDevice();
1900
+ this.#requireAbsolute();
1901
+ volume = Math.min(1, Math.max(0, volume));
1902
+ this.#context.logger.info(`Setting volume to ${volume} for device ${outputDeviceUID}`);
1903
+ await this.#channel.exchange(setVolume(outputDeviceUID, volume));
1904
+ }
1905
+ /** @throws {CommandError} When no output device is active. */
1906
+ async mute() {
1907
+ await this.#channel.exchange(setVolumeMuted(this.#requireOutputDevice(), true));
1908
+ }
1909
+ /** @throws {CommandError} When no output device is active. */
1910
+ async unmute() {
1911
+ await this.#channel.exchange(setVolumeMuted(this.#requireOutputDevice(), false));
1912
+ }
1913
+ /** @throws {CommandError} When no output device is active. */
1914
+ async toggleMute() {
1915
+ if (this.#state.volumeMuted) await this.unmute();
1916
+ else await this.mute();
1917
+ }
1918
+ /** Sets the volume of one output device in a group, clamped to 0 to 1. */
1919
+ async setForDevice(outputDeviceUID, volume) {
1920
+ volume = Math.min(1, Math.max(0, volume));
1921
+ this.#context.logger.info(`Setting volume to ${volume} for output device ${outputDeviceUID}`);
1922
+ await this.#channel.exchange(setVolume(outputDeviceUID, volume));
1923
+ }
1924
+ /** @throws {CommandError} When the receiver answers otherwise. */
1925
+ async getForDevice(outputDeviceUID) {
1926
+ return await this.#getVolume(outputDeviceUID, "Failed to get volume for output device.");
1927
+ }
1928
+ async muteDevice(outputDeviceUID) {
1929
+ await this.#channel.exchange(setVolumeMuted(outputDeviceUID, true));
1930
+ }
1931
+ async unmuteDevice(outputDeviceUID) {
1932
+ await this.#channel.exchange(setVolumeMuted(outputDeviceUID, false));
1933
+ }
1934
+ /**
1935
+ * Steps the volume of an output device up or down by a small, medium or large amount.
1936
+ *
1937
+ * @param outputDeviceUID - The active output device when omitted.
1938
+ * @throws {CommandError} When no output device is given or active.
1939
+ */
1940
+ async adjust(adjustment, outputDeviceUID) {
1941
+ const uid = outputDeviceUID ?? this.#state.outputDeviceUID;
1942
+ if (!uid) throw new CommandError("No output device active.");
1943
+ await this.#channel.exchange(adjustVolume(adjustment, uid));
1944
+ }
1945
+ /** Asks the receiver to fade its audio: 0 fades out, 1 fades in. */
1946
+ async audioFade(fadeType) {
1947
+ this.#channel.send(audioFade(fadeType));
1948
+ }
1949
+ /**
1950
+ * Fades to a volume in steps of {@link FADE_STEP_INTERVAL} ms; the last step sets the target exactly.
1951
+ *
1952
+ * @param durationMs - Duration of the fade in milliseconds.
1953
+ * @throws {CommandError} When no output device is active or absolute volume is not available.
1954
+ */
1955
+ async fade(targetVolume, durationMs) {
1956
+ this.#requireOutputDevice();
1957
+ this.#requireAbsolute();
1958
+ targetVolume = Math.min(1, Math.max(0, targetVolume));
1959
+ const startVolume = this.#state.volume;
1960
+ const steps = Math.max(1, Math.floor(durationMs / FADE_STEP_INTERVAL));
1961
+ const stepDuration = durationMs / steps;
1962
+ const volumeDelta = (targetVolume - startVolume) / steps;
1963
+ for (let i = 1; i <= steps; i++) {
1964
+ const volume = i === steps ? targetVolume : Math.min(1, Math.max(0, startVolume + volumeDelta * i));
1965
+ await this.set(volume);
1966
+ if (i < steps) await waitFor(stepDuration);
1967
+ }
1968
+ }
1969
+ async #getVolume(outputDeviceUID, failure) {
1970
+ const response = await this.#channel.exchange(getVolume(outputDeviceUID));
1971
+ if (response.type === 50) return getExtension(response, getVolumeResultMessage).volume;
1972
+ throw new CommandError(failure);
1973
+ }
1974
+ #requireAbsolute() {
1975
+ if (![2, 3].includes(this.#state.volumeCapabilities)) throw new CommandError("Absolute volume control is not available.");
1976
+ }
1977
+ #requireOutputDevice() {
1978
+ const outputDeviceUID = this.#state.outputDeviceUID;
1979
+ if (!outputDeviceUID) throw new CommandError("No output device active.");
1980
+ return outputDeviceUID;
1981
+ }
1982
+ };
1983
+
1984
+ //#endregion
1985
+ //#region src/mrp/mediaRemote.ts
1986
+ /** The messages the state derives something from. */
1987
+ const STATE_MESSAGES = [
1988
+ "keyboard",
1989
+ "deviceInfo",
1990
+ "deviceInfoUpdate",
1991
+ "removeClient",
1992
+ "removePlayer",
1993
+ "sendLyricsEvent",
1994
+ "setArtwork",
1995
+ "setDefaultSupportedCommands",
1996
+ "setNowPlayingClient",
1997
+ "setNowPlayingPlayer",
1998
+ "setState",
1999
+ "updateClient",
2000
+ "updateContentItem",
2001
+ "updateContentItemArtwork",
2002
+ "updatePlayer",
2003
+ "playerClientParticipantsUpdate",
2004
+ "updateOutputDevice",
2005
+ "volumeControlAvailability",
2006
+ "volumeControlCapabilitiesDidChange",
2007
+ "volumeDidChange",
2008
+ "volumeMutedDidChange"
2009
+ ];
2010
+ /**
2011
+ * Remote control of a receiver over MRP, on the data channel of an AirPlay session. It owns the
2012
+ * state the messages feed and the parts that send: keys, commands, volume, text input, output
2013
+ * devices, the playback queue and artwork. It lives as long as its session.
2014
+ */
2015
+ var MediaRemote = class MediaRemote {
2016
+ /** The MRP data channel this remote sends on. */
2017
+ get channel() {
2018
+ return this.#channel;
2019
+ }
2020
+ artwork;
2021
+ commands;
2022
+ hid;
2023
+ keyboard;
2024
+ outputDevices;
2025
+ playbackQueue;
2026
+ state;
2027
+ volume;
2028
+ #channel;
2029
+ #session;
2030
+ #lastArtworkId = null;
2031
+ /**
2032
+ * Opens remote control on the session and runs the MRP startup: DEVICE_INFO as an exchange,
2033
+ * then the connection state, the update subscription and GET_STATE, each sent without waiting.
2034
+ * The state listens from before the data channel connects, so it misses nothing the receiver
2035
+ * sends right away. A failed startup ends the session.
2036
+ *
2037
+ * @throws {SetupError} When a SETUP fails.
2038
+ * @throws {TimeoutError} When DEVICE_INFO gets no reply.
2039
+ * @throws {ConnectionClosedError} When the session ends during setup.
2040
+ */
2041
+ static async open(session, options = {}) {
2042
+ const state = options.state ?? new MediaRemoteState();
2043
+ let remote;
2044
+ const { events } = await session.openRemoteControl({ subscribe: (data) => {
2045
+ remote = new MediaRemote(session, data, state);
2046
+ } });
2047
+ try {
2048
+ await remote.#start();
2049
+ if (session.signal.aborted) throw new ConnectionClosedError("AirPlay setup was interrupted.");
2050
+ if (!session.control.isConnected || !remote.#channel.isConnected || !events.isConnected) throw new ConnectionClosedError("An AirPlay stream closed during setup.");
2051
+ } catch (err) {
2052
+ if (session.signal.aborted) throw err instanceof ConnectionClosedError ? err : new ConnectionClosedError("AirPlay setup was interrupted.");
2053
+ session.context.logger.error("[device]", "Setup failed, cleaning up", err);
2054
+ await session.close();
2055
+ throw err;
2056
+ }
2057
+ session.context.logger.info("Protocol ready.");
2058
+ return remote;
2059
+ }
2060
+ /** Use {@link open}. */
2061
+ constructor(session, channel, state) {
2062
+ this.#channel = channel;
2063
+ this.#session = session;
2064
+ this.state = state;
2065
+ this.hid = new Hid(channel, state);
2066
+ this.commands = new Commands(channel);
2067
+ this.volume = new Volume(channel, session.context, state, this.hid);
2068
+ this.keyboard = new Keyboard(channel);
2069
+ this.outputDevices = new OutputDevices(channel, state);
2070
+ this.playbackQueue = new PlaybackQueue(channel, state);
2071
+ this.artwork = new Artwork(state, this.playbackQueue);
2072
+ this.#feedState();
2073
+ }
2074
+ /** Wakes the receiver from sleep with a WAKE_DEVICE message. */
2075
+ async wake() {
2076
+ this.#channel.send(wakeDevice());
2077
+ }
2078
+ /** Forwards every message the state tracks, until the session ends. */
2079
+ #feedState() {
2080
+ const signal = this.#session.signal;
2081
+ if (signal.aborted) return;
2082
+ const channel = this.#channel;
2083
+ const listeners = STATE_MESSAGES.map((name) => {
2084
+ const listener = (message) => this.state.apply(...[name, message]);
2085
+ channel.on(name, listener);
2086
+ return [name, listener];
2087
+ });
2088
+ signal.addEventListener("abort", () => {
2089
+ for (const [name, listener] of listeners) channel.off(name, listener);
2090
+ }, { once: true });
2091
+ }
2092
+ /** Fetches the playback queue whenever the artwork changes, so its bytes or URL arrive. */
2093
+ #onNowPlayingChanged(player) {
2094
+ const artworkId = player?.artworkId ?? null;
2095
+ if (artworkId === this.#lastArtworkId) return;
2096
+ this.#lastArtworkId = artworkId;
2097
+ this.playbackQueue.request(1).catch(() => {});
2098
+ }
2099
+ async #start() {
2100
+ const channel = this.#channel;
2101
+ await channel.exchange(deviceInfo(this.#session.pairingId, resolveIdentity(this.#session.context)));
2102
+ channel.send(setConnectionState());
2103
+ channel.send(clientUpdatesConfig(true, true, true, true));
2104
+ channel.send(getState());
2105
+ const listener = (_client, player) => this.#onNowPlayingChanged(player);
2106
+ this.state.on("nowPlayingChanged", listener);
2107
+ this.#session.signal.addEventListener("abort", () => this.state.off("nowPlayingChanged", listener), { once: true });
2108
+ }
2109
+ };
2110
+
2111
+ //#endregion
2112
+ export { Artwork, Client, Commands, DEFAULT_PLAYER_ID, Hid, Keyboard, MediaRemote, MediaRemoteState, messages_exports as Messages, OutputDevices, PlaybackQueue, Player, proto_exports as Proto, SendCommandError, Volume, decodeMessage, describeProtocolMessage, parseMessages };