@basmilius/apple-devices 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +528 -345
- package/dist/index.mjs +404 -47
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,151 @@
|
|
|
1
1
|
import * as AirPlay from "@basmilius/apple-airplay";
|
|
2
|
-
import {
|
|
2
|
+
import { DataStreamMessage, Proto, Protocol } from "@basmilius/apple-airplay";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
|
-
import { CommandError, CredentialsError, waitFor } from "@basmilius/apple-common";
|
|
4
|
+
import { AirPlayFeatureFlags, CommandError, CredentialsError, waitFor } from "@basmilius/apple-common";
|
|
5
5
|
import { MediaControlFlag, Protocol as Protocol$1, convertAttentionState } from "@basmilius/apple-companion-link";
|
|
6
6
|
import { NSKeyedArchiver, Plist } from "@basmilius/apple-encoding";
|
|
7
7
|
|
|
8
|
+
//#region src/airplay/const.ts
|
|
9
|
+
/** Interval in milliseconds between periodic feedback requests to keep the AirPlay session alive. */
|
|
10
|
+
const FEEDBACK_INTERVAL = 2e3;
|
|
11
|
+
/** Symbol used to access the underlying AirPlay Protocol instance from an AirPlayDevice. */
|
|
12
|
+
const PROTOCOL = Symbol("com.basmilius.airplay:protocol");
|
|
13
|
+
/** Symbol used to subscribe AirPlayState to DataStream events. */
|
|
14
|
+
const STATE_SUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:subscribe");
|
|
15
|
+
/** Symbol used to unsubscribe AirPlayState from DataStream events. */
|
|
16
|
+
const STATE_UNSUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:unsubscribe");
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/airplay/artwork.ts
|
|
20
|
+
/**
|
|
21
|
+
* Unified artwork controller for an AirPlay device.
|
|
22
|
+
*
|
|
23
|
+
* Provides a single `get()` method that resolves artwork from all available
|
|
24
|
+
* sources in priority order:
|
|
25
|
+
* 1. URL from now-playing metadata (artworkURL, remoteArtworks, template)
|
|
26
|
+
* 2. Inline binary data from the playback queue (artworkData, dataArtworks)
|
|
27
|
+
* 3. JPEG data from SET_ARTWORK_MESSAGE
|
|
28
|
+
* 4. Fetches the playback queue if artwork is expected but not yet available
|
|
29
|
+
*/
|
|
30
|
+
var Artwork = class {
|
|
31
|
+
get #protocol() {
|
|
32
|
+
return this.#device[PROTOCOL];
|
|
33
|
+
}
|
|
34
|
+
get #state() {
|
|
35
|
+
return this.#device.state;
|
|
36
|
+
}
|
|
37
|
+
#device;
|
|
38
|
+
#lastIdentifier = null;
|
|
39
|
+
#cached = null;
|
|
40
|
+
constructor(device) {
|
|
41
|
+
this.#device = device;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Gets the current artwork for the active now-playing item.
|
|
45
|
+
*
|
|
46
|
+
* Tries all available sources in priority order and returns a unified result.
|
|
47
|
+
* Results are cached by artwork identifier — subsequent calls for the same
|
|
48
|
+
* track return the cached result without additional requests.
|
|
49
|
+
*
|
|
50
|
+
* @param width - Desired artwork width in pixels (default: 600).
|
|
51
|
+
* @param height - Desired artwork height in pixels (-1 for proportional).
|
|
52
|
+
* @returns The artwork result, or null if no artwork is available.
|
|
53
|
+
*/
|
|
54
|
+
async get(width = 600, height = -1) {
|
|
55
|
+
const player = this.#state.nowPlayingClient?.activePlayer;
|
|
56
|
+
if (!player) return null;
|
|
57
|
+
const identifier = player.artworkId;
|
|
58
|
+
if (identifier && identifier === this.#lastIdentifier && this.#cached) return this.#cached;
|
|
59
|
+
const url = player.artworkUrl(width, height);
|
|
60
|
+
if (url) return this.#cache(identifier, {
|
|
61
|
+
url,
|
|
62
|
+
data: null,
|
|
63
|
+
mimeType: guessMimeType(url),
|
|
64
|
+
identifier,
|
|
65
|
+
width,
|
|
66
|
+
height: height < 0 ? 0 : height
|
|
67
|
+
});
|
|
68
|
+
const inlineData = player.currentItemArtwork;
|
|
69
|
+
if (inlineData && inlineData.byteLength > 0) {
|
|
70
|
+
const metadata = player.currentItemMetadata;
|
|
71
|
+
return this.#cache(identifier, {
|
|
72
|
+
url: null,
|
|
73
|
+
data: inlineData,
|
|
74
|
+
mimeType: metadata?.artworkMIMEType || "image/jpeg",
|
|
75
|
+
identifier,
|
|
76
|
+
width: 0,
|
|
77
|
+
height: 0
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const setArtworkData = this.#state.artworkJpegData;
|
|
81
|
+
if (setArtworkData && setArtworkData.byteLength > 0) return this.#cache(identifier, {
|
|
82
|
+
url: null,
|
|
83
|
+
data: setArtworkData,
|
|
84
|
+
mimeType: "image/jpeg",
|
|
85
|
+
identifier,
|
|
86
|
+
width: 0,
|
|
87
|
+
height: 0
|
|
88
|
+
});
|
|
89
|
+
if (identifier) try {
|
|
90
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.playbackQueueRequest(0, 1, width, height < 0 ? 400 : height));
|
|
91
|
+
const fetchedData = player.currentItemArtwork;
|
|
92
|
+
if (fetchedData && fetchedData.byteLength > 0) {
|
|
93
|
+
const metadata = player.currentItemMetadata;
|
|
94
|
+
return this.#cache(identifier, {
|
|
95
|
+
url: null,
|
|
96
|
+
data: fetchedData,
|
|
97
|
+
mimeType: metadata?.artworkMIMEType || "image/jpeg",
|
|
98
|
+
identifier,
|
|
99
|
+
width: 0,
|
|
100
|
+
height: 0
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const retryUrl = player.artworkUrl(width, height);
|
|
104
|
+
if (retryUrl) return this.#cache(identifier, {
|
|
105
|
+
url: retryUrl,
|
|
106
|
+
data: null,
|
|
107
|
+
mimeType: guessMimeType(retryUrl),
|
|
108
|
+
identifier,
|
|
109
|
+
width,
|
|
110
|
+
height: height < 0 ? 0 : height
|
|
111
|
+
});
|
|
112
|
+
} catch {}
|
|
113
|
+
this.#lastIdentifier = null;
|
|
114
|
+
this.#cached = null;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** Clears the cached artwork, forcing a fresh fetch on the next `get()` call. */
|
|
118
|
+
clear() {
|
|
119
|
+
this.#lastIdentifier = null;
|
|
120
|
+
this.#cached = null;
|
|
121
|
+
}
|
|
122
|
+
#cache(identifier, result) {
|
|
123
|
+
this.#lastIdentifier = identifier;
|
|
124
|
+
this.#cached = result;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const guessMimeType = (url) => {
|
|
129
|
+
if (url.includes(".png")) return "image/png";
|
|
130
|
+
if (url.includes(".webp")) return "image/webp";
|
|
131
|
+
return "image/jpeg";
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
8
135
|
//#region src/airplay/player.ts
|
|
9
136
|
/** Offset in seconds between the Cocoa epoch (2001-01-01) and the Unix epoch (1970-01-01). */
|
|
10
137
|
const COCOA_EPOCH_OFFSET = 978307200;
|
|
11
138
|
/** Default player identifier used by the Apple TV when no specific player is active. */
|
|
12
139
|
const DEFAULT_PLAYER_ID = "MediaRemote-DefaultPlayer";
|
|
13
140
|
/**
|
|
141
|
+
* Converts an artwork URL to a browser-compatible format.
|
|
142
|
+
* Apple's CDN serves HEIC by default, which most browsers can't display.
|
|
143
|
+
* Replacing the extension with .jpg makes the CDN return JPEG instead.
|
|
144
|
+
*/
|
|
145
|
+
const convertArtworkUrl = (url) => {
|
|
146
|
+
return url.replace(/\.heic(\b|$)/gi, ".jpg");
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
14
149
|
* Extrapolates the current elapsed time based on a snapshot timestamp and playback rate.
|
|
15
150
|
* Compensates for the time passed since the timestamp was recorded, scaled by the playback rate.
|
|
16
151
|
*
|
|
@@ -53,15 +188,9 @@ var Player = class {
|
|
|
53
188
|
return this.#playbackQueue;
|
|
54
189
|
}
|
|
55
190
|
/**
|
|
56
|
-
* Effective playback state.
|
|
57
|
-
* reports Playing but the playback rate is 0 (effectively paused).
|
|
191
|
+
* Effective playback state.
|
|
58
192
|
*/
|
|
59
193
|
get playbackState() {
|
|
60
|
-
if (this.#playbackState === Proto.PlaybackState_Enum.Playing && this.playbackRate === 0) return Proto.PlaybackState_Enum.Paused;
|
|
61
|
-
return this.#playbackState;
|
|
62
|
-
}
|
|
63
|
-
/** The raw playback state as reported by the Apple TV, without corrections. */
|
|
64
|
-
get rawPlaybackState() {
|
|
65
194
|
return this.#playbackState;
|
|
66
195
|
}
|
|
67
196
|
/** Timestamp of the last playback state update, used to discard stale updates. */
|
|
@@ -177,11 +306,11 @@ var Player = class {
|
|
|
177
306
|
*/
|
|
178
307
|
artworkUrl(width = 600, height = -1) {
|
|
179
308
|
const metadata = this.currentItemMetadata;
|
|
180
|
-
if (metadata?.artworkURL) return metadata.artworkURL;
|
|
309
|
+
if (metadata?.artworkURL) return convertArtworkUrl(metadata.artworkURL);
|
|
181
310
|
const item = this.currentItem;
|
|
182
|
-
if (item?.remoteArtworks.length > 0 && item.remoteArtworks[0].artworkURLString) return item.remoteArtworks[0].artworkURLString;
|
|
311
|
+
if (item?.remoteArtworks.length > 0 && item.remoteArtworks[0].artworkURLString) return convertArtworkUrl(item.remoteArtworks[0].artworkURLString);
|
|
183
312
|
if (metadata?.artworkIdentifier) try {
|
|
184
|
-
const url = metadata.artworkIdentifier.replace("{w}", String(width < 1 ? 999999 : width)).replace("{h}", String(height < 1 ? 999999 : height)).replace("{c}", "bb").replace("{f}", "
|
|
313
|
+
const url = metadata.artworkIdentifier.replace("{w}", String(width < 1 ? 999999 : width)).replace("{h}", String(height < 1 ? 999999 : height)).replace("{c}", "bb").replace("{f}", "jpg");
|
|
185
314
|
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
186
315
|
} catch {}
|
|
187
316
|
return null;
|
|
@@ -532,17 +661,6 @@ var Client = class {
|
|
|
532
661
|
}
|
|
533
662
|
};
|
|
534
663
|
|
|
535
|
-
//#endregion
|
|
536
|
-
//#region src/airplay/const.ts
|
|
537
|
-
/** Interval in milliseconds between periodic feedback requests to keep the AirPlay session alive. */
|
|
538
|
-
const FEEDBACK_INTERVAL = 2e3;
|
|
539
|
-
/** Symbol used to access the underlying AirPlay Protocol instance from an AirPlayDevice. */
|
|
540
|
-
const PROTOCOL = Symbol("com.basmilius.airplay:protocol");
|
|
541
|
-
/** Symbol used to subscribe AirPlayState to DataStream events. */
|
|
542
|
-
const STATE_SUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:subscribe");
|
|
543
|
-
/** Symbol used to unsubscribe AirPlayState from DataStream events. */
|
|
544
|
-
const STATE_UNSUBSCRIBE_SYMBOL = Symbol("com.basmilius.airplay:unsubscribe");
|
|
545
|
-
|
|
546
664
|
//#endregion
|
|
547
665
|
//#region src/airplay/remote.ts
|
|
548
666
|
/**
|
|
@@ -797,6 +915,16 @@ var remote_default = class {
|
|
|
797
915
|
await this.#sendCommand(Proto.Command.AddNowPlayingItemToLibrary);
|
|
798
916
|
}
|
|
799
917
|
/**
|
|
918
|
+
* Sets a sleep timer that will stop playback after the specified duration.
|
|
919
|
+
* The timer works by attaching sleep timer options to a Pause command.
|
|
920
|
+
*
|
|
921
|
+
* @param seconds - Timer duration in seconds. Use 0 to cancel an active timer.
|
|
922
|
+
* @param stopMode - Stop mode: 0 = stop playback, 1 = pause, 2 = end of track, 3 = end of queue.
|
|
923
|
+
*/
|
|
924
|
+
async commandSetSleepTimer(seconds, stopMode = 0) {
|
|
925
|
+
await this.#sendCommandRaw(DataStreamMessage.sendCommandWithSleepTimer(seconds, stopMode));
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
800
928
|
* Sets the text input field to the given text, replacing any existing content.
|
|
801
929
|
*
|
|
802
930
|
* @param text - The text to set.
|
|
@@ -1035,6 +1163,14 @@ var state_default = class extends EventEmitter {
|
|
|
1035
1163
|
get isClusterLeader() {
|
|
1036
1164
|
return this.#isClusterLeader;
|
|
1037
1165
|
}
|
|
1166
|
+
/** Current playback queue participants (e.g. SharePlay users). */
|
|
1167
|
+
get participants() {
|
|
1168
|
+
return this.#participants;
|
|
1169
|
+
}
|
|
1170
|
+
/** Raw JPEG artwork data from the last SET_ARTWORK_MESSAGE, or null. */
|
|
1171
|
+
get artworkJpegData() {
|
|
1172
|
+
return this.#artworkJpegData;
|
|
1173
|
+
}
|
|
1038
1174
|
/** Current volume level (0.0 - 1.0). */
|
|
1039
1175
|
get volume() {
|
|
1040
1176
|
return this.#volume;
|
|
@@ -1063,6 +1199,8 @@ var state_default = class extends EventEmitter {
|
|
|
1063
1199
|
#clusterType;
|
|
1064
1200
|
#isClusterAware;
|
|
1065
1201
|
#isClusterLeader;
|
|
1202
|
+
#participants;
|
|
1203
|
+
#artworkJpegData;
|
|
1066
1204
|
#volume;
|
|
1067
1205
|
#volumeAvailable;
|
|
1068
1206
|
#volumeCapabilities;
|
|
@@ -1095,6 +1233,7 @@ var state_default = class extends EventEmitter {
|
|
|
1095
1233
|
this.onUpdateContentItem = this.onUpdateContentItem.bind(this);
|
|
1096
1234
|
this.onUpdateContentItemArtwork = this.onUpdateContentItemArtwork.bind(this);
|
|
1097
1235
|
this.onUpdatePlayer = this.onUpdatePlayer.bind(this);
|
|
1236
|
+
this.onPlayerClientParticipantsUpdate = this.onPlayerClientParticipantsUpdate.bind(this);
|
|
1098
1237
|
this.onUpdateOutputDevice = this.onUpdateOutputDevice.bind(this);
|
|
1099
1238
|
this.onVolumeControlAvailability = this.onVolumeControlAvailability.bind(this);
|
|
1100
1239
|
this.onVolumeControlCapabilitiesDidChange = this.onVolumeControlCapabilitiesDidChange.bind(this);
|
|
@@ -1122,6 +1261,7 @@ var state_default = class extends EventEmitter {
|
|
|
1122
1261
|
this.#dataStream.on("updateContentItem", this.onUpdateContentItem);
|
|
1123
1262
|
this.#dataStream.on("updateContentItemArtwork", this.onUpdateContentItemArtwork);
|
|
1124
1263
|
this.#dataStream.on("updatePlayer", this.onUpdatePlayer);
|
|
1264
|
+
this.#dataStream.on("playerClientParticipantsUpdate", this.onPlayerClientParticipantsUpdate);
|
|
1125
1265
|
this.#dataStream.on("updateOutputDevice", this.onUpdateOutputDevice);
|
|
1126
1266
|
this.#dataStream.on("volumeControlAvailability", this.onVolumeControlAvailability);
|
|
1127
1267
|
this.#dataStream.on("volumeControlCapabilitiesDidChange", this.onVolumeControlCapabilitiesDidChange);
|
|
@@ -1151,6 +1291,7 @@ var state_default = class extends EventEmitter {
|
|
|
1151
1291
|
dataStream.off("updateContentItem", this.onUpdateContentItem);
|
|
1152
1292
|
dataStream.off("updateContentItemArtwork", this.onUpdateContentItemArtwork);
|
|
1153
1293
|
dataStream.off("updatePlayer", this.onUpdatePlayer);
|
|
1294
|
+
dataStream.off("playerClientParticipantsUpdate", this.onPlayerClientParticipantsUpdate);
|
|
1154
1295
|
dataStream.off("updateOutputDevice", this.onUpdateOutputDevice);
|
|
1155
1296
|
dataStream.off("volumeControlAvailability", this.onVolumeControlAvailability);
|
|
1156
1297
|
dataStream.off("volumeControlCapabilitiesDidChange", this.onVolumeControlCapabilitiesDidChange);
|
|
@@ -1170,6 +1311,8 @@ var state_default = class extends EventEmitter {
|
|
|
1170
1311
|
this.#clusterType = 0;
|
|
1171
1312
|
this.#isClusterAware = false;
|
|
1172
1313
|
this.#isClusterLeader = false;
|
|
1314
|
+
this.#participants = [];
|
|
1315
|
+
this.#artworkJpegData = null;
|
|
1173
1316
|
this.#volume = 0;
|
|
1174
1317
|
this.#volumeAvailable = false;
|
|
1175
1318
|
this.#volumeCapabilities = Proto.VolumeCapabilities_Enum.None;
|
|
@@ -1268,6 +1411,7 @@ var state_default = class extends EventEmitter {
|
|
|
1268
1411
|
* @param message - The set artwork message.
|
|
1269
1412
|
*/
|
|
1270
1413
|
onSetArtwork(message) {
|
|
1414
|
+
if (message.jpegData?.byteLength > 0) this.#artworkJpegData = message.jpegData;
|
|
1271
1415
|
this.emit("setArtwork", message);
|
|
1272
1416
|
}
|
|
1273
1417
|
/**
|
|
@@ -1410,6 +1554,15 @@ var state_default = class extends EventEmitter {
|
|
|
1410
1554
|
*
|
|
1411
1555
|
* @param message - The update output device message.
|
|
1412
1556
|
*/
|
|
1557
|
+
/**
|
|
1558
|
+
* Handles playback queue participant updates (e.g. SharePlay users).
|
|
1559
|
+
*
|
|
1560
|
+
* @param message - The participants update message.
|
|
1561
|
+
*/
|
|
1562
|
+
onPlayerClientParticipantsUpdate(message) {
|
|
1563
|
+
this.#participants = message.participants ?? [];
|
|
1564
|
+
this.emit("playerClientParticipantsUpdate", message);
|
|
1565
|
+
}
|
|
1413
1566
|
onUpdateOutputDevice(message) {
|
|
1414
1567
|
this.#outputDevices = message.clusterAwareOutputDevices?.length > 0 ? message.clusterAwareOutputDevices : message.outputDevices;
|
|
1415
1568
|
this.emit("updateOutputDevice", message);
|
|
@@ -1459,11 +1612,14 @@ var state_default = class extends EventEmitter {
|
|
|
1459
1612
|
* @param message - The device info message.
|
|
1460
1613
|
*/
|
|
1461
1614
|
#updateDeviceInfo(message) {
|
|
1615
|
+
const previousClusterID = this.#clusterID;
|
|
1616
|
+
const previousIsLeader = this.#isClusterLeader;
|
|
1462
1617
|
this.#outputDeviceUID = message.clusterID || message.deviceUID || message.uniqueIdentifier || null;
|
|
1463
1618
|
this.#clusterID = message.clusterID || null;
|
|
1464
1619
|
this.#clusterType = message.clusterType ?? 0;
|
|
1465
1620
|
this.#isClusterAware = message.isClusterAware ?? false;
|
|
1466
1621
|
this.#isClusterLeader = message.isClusterLeader ?? false;
|
|
1622
|
+
if (this.#clusterID !== previousClusterID || this.#isClusterLeader !== previousIsLeader) this.emit("clusterChanged", this.#clusterID, this.#isClusterLeader);
|
|
1467
1623
|
}
|
|
1468
1624
|
/**
|
|
1469
1625
|
* Gets or creates a Client for the given bundle identifier.
|
|
@@ -1547,6 +1703,8 @@ var state_default = class extends EventEmitter {
|
|
|
1547
1703
|
//#region src/airplay/volume.ts
|
|
1548
1704
|
/** Volume adjustment step size as a fraction (0.05 = 5%). */
|
|
1549
1705
|
const VOLUME_STEP = .05;
|
|
1706
|
+
/** Minimum interval between volume fade steps in milliseconds. */
|
|
1707
|
+
const FADE_STEP_INTERVAL = 50;
|
|
1550
1708
|
/**
|
|
1551
1709
|
* Smart volume controller for an AirPlay device.
|
|
1552
1710
|
* Automatically chooses between absolute volume (set a specific level) and
|
|
@@ -1633,6 +1791,106 @@ var volume_default = class {
|
|
|
1633
1791
|
this.#protocol.context.logger.info(`Setting volume to ${volume} for device ${this.#state.outputDeviceUID}`);
|
|
1634
1792
|
await this.#protocol.dataStream.exchange(DataStreamMessage.setVolume(this.#state.outputDeviceUID, volume));
|
|
1635
1793
|
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Mutes the output device.
|
|
1796
|
+
*
|
|
1797
|
+
* @throws CommandError when no output device is active.
|
|
1798
|
+
*/
|
|
1799
|
+
async mute() {
|
|
1800
|
+
if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
|
|
1801
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.setVolumeMuted(this.#state.outputDeviceUID, true));
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Unmutes the output device.
|
|
1805
|
+
*
|
|
1806
|
+
* @throws CommandError when no output device is active.
|
|
1807
|
+
*/
|
|
1808
|
+
async unmute() {
|
|
1809
|
+
if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
|
|
1810
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.setVolumeMuted(this.#state.outputDeviceUID, false));
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Toggles the mute state of the output device.
|
|
1814
|
+
*
|
|
1815
|
+
* @throws CommandError when no output device is active.
|
|
1816
|
+
*/
|
|
1817
|
+
async toggleMute() {
|
|
1818
|
+
if (this.#state.volumeMuted) await this.unmute();
|
|
1819
|
+
else await this.mute();
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Sets the volume for a specific output device in a speaker group.
|
|
1823
|
+
* Use this to control individual speakers when multiple devices are grouped.
|
|
1824
|
+
*
|
|
1825
|
+
* @param outputDeviceUID - The unique identifier of the target output device.
|
|
1826
|
+
* @param volume - The desired volume level (clamped to 0.0 - 1.0).
|
|
1827
|
+
*/
|
|
1828
|
+
async setForDevice(outputDeviceUID, volume) {
|
|
1829
|
+
volume = Math.min(1, Math.max(0, volume));
|
|
1830
|
+
this.#protocol.context.logger.info(`Setting volume to ${volume} for output device ${outputDeviceUID}`);
|
|
1831
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.setVolume(outputDeviceUID, volume));
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Fetches the volume for a specific output device in a speaker group.
|
|
1835
|
+
*
|
|
1836
|
+
* @param outputDeviceUID - The unique identifier of the target output device.
|
|
1837
|
+
* @returns The volume level as a float between 0.0 and 1.0.
|
|
1838
|
+
*/
|
|
1839
|
+
async getForDevice(outputDeviceUID) {
|
|
1840
|
+
const response = await this.#protocol.dataStream.exchange(DataStreamMessage.getVolume(outputDeviceUID));
|
|
1841
|
+
if (response.type === Proto.ProtocolMessage_Type.GET_VOLUME_RESULT_MESSAGE) return DataStreamMessage.getExtension(response, Proto.getVolumeResultMessage).volume;
|
|
1842
|
+
throw new CommandError("Failed to get volume for output device.");
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* Mutes a specific output device in a speaker group.
|
|
1846
|
+
*
|
|
1847
|
+
* @param outputDeviceUID - The unique identifier of the target output device.
|
|
1848
|
+
*/
|
|
1849
|
+
async muteDevice(outputDeviceUID) {
|
|
1850
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.setVolumeMuted(outputDeviceUID, true));
|
|
1851
|
+
}
|
|
1852
|
+
/**
|
|
1853
|
+
* Unmutes a specific output device in a speaker group.
|
|
1854
|
+
*
|
|
1855
|
+
* @param outputDeviceUID - The unique identifier of the target output device.
|
|
1856
|
+
*/
|
|
1857
|
+
async unmuteDevice(outputDeviceUID) {
|
|
1858
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.setVolumeMuted(outputDeviceUID, false));
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Adjusts the volume by a relative increment/decrement on a specific output device.
|
|
1862
|
+
* This is the method Apple uses internally in Music.app for volume changes.
|
|
1863
|
+
*
|
|
1864
|
+
* @param adjustment - The volume adjustment to apply (IncrementSmall/Medium/Large, DecrementSmall/Medium/Large).
|
|
1865
|
+
* @param outputDeviceUID - Optional UID of the target output device. Defaults to the active device.
|
|
1866
|
+
*/
|
|
1867
|
+
async adjust(adjustment, outputDeviceUID) {
|
|
1868
|
+
const uid = outputDeviceUID ?? this.#state.outputDeviceUID;
|
|
1869
|
+
if (!uid) throw new CommandError("No output device active.");
|
|
1870
|
+
await this.#protocol.dataStream.exchange(DataStreamMessage.adjustVolume(adjustment, uid));
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Smoothly fades the volume to a target level over a given duration.
|
|
1874
|
+
* Uses linear interpolation with absolute volume set calls.
|
|
1875
|
+
*
|
|
1876
|
+
* @param targetVolume - The target volume level (0.0 - 1.0).
|
|
1877
|
+
* @param durationMs - The fade duration in milliseconds.
|
|
1878
|
+
* @throws CommandError when absolute volume control is not available.
|
|
1879
|
+
*/
|
|
1880
|
+
async fade(targetVolume, durationMs) {
|
|
1881
|
+
if (!this.#state.outputDeviceUID) throw new CommandError("No output device active.");
|
|
1882
|
+
if (![Proto.VolumeCapabilities_Enum.Absolute, Proto.VolumeCapabilities_Enum.Both].includes(this.#state.volumeCapabilities)) throw new CommandError("Absolute volume control is not available.");
|
|
1883
|
+
targetVolume = Math.min(1, Math.max(0, targetVolume));
|
|
1884
|
+
const startVolume = this.#state.volume;
|
|
1885
|
+
const steps = Math.max(1, Math.floor(durationMs / FADE_STEP_INTERVAL));
|
|
1886
|
+
const stepDuration = durationMs / steps;
|
|
1887
|
+
const volumeDelta = (targetVolume - startVolume) / steps;
|
|
1888
|
+
for (let i = 1; i <= steps; i++) {
|
|
1889
|
+
const volume = i === steps ? targetVolume : Math.min(1, Math.max(0, startVolume + volumeDelta * i));
|
|
1890
|
+
await this.set(volume);
|
|
1891
|
+
if (i < steps) await waitFor(stepDuration);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1636
1894
|
};
|
|
1637
1895
|
|
|
1638
1896
|
//#endregion
|
|
@@ -1663,15 +1921,15 @@ var device_default = class extends EventEmitter {
|
|
|
1663
1921
|
get capabilities() {
|
|
1664
1922
|
const has = (f) => this.#protocol?.hasReceiverFeature(f) ?? false;
|
|
1665
1923
|
return {
|
|
1666
|
-
supportsAudio: has(
|
|
1667
|
-
supportsBufferedAudio: has(
|
|
1668
|
-
supportsPTP: has(
|
|
1669
|
-
supportsRFC2198Redundancy: has(
|
|
1670
|
-
supportsHangdogRemoteControl: has(
|
|
1671
|
-
supportsUnifiedMediaControl: has(
|
|
1672
|
-
supportsTransientPairing: has(
|
|
1673
|
-
supportsSystemPairing: has(
|
|
1674
|
-
supportsCoreUtilsPairing: has(
|
|
1924
|
+
supportsAudio: has(AirPlayFeatureFlags.SupportsAirPlayAudio),
|
|
1925
|
+
supportsBufferedAudio: has(AirPlayFeatureFlags.SupportsBufferedAudio),
|
|
1926
|
+
supportsPTP: has(AirPlayFeatureFlags.SupportsPTP),
|
|
1927
|
+
supportsRFC2198Redundancy: has(AirPlayFeatureFlags.SupportsRFC2198Redundancy),
|
|
1928
|
+
supportsHangdogRemoteControl: has(AirPlayFeatureFlags.SupportsHangdogRemoteControl),
|
|
1929
|
+
supportsUnifiedMediaControl: has(AirPlayFeatureFlags.SupportsUnifiedMediaControl),
|
|
1930
|
+
supportsTransientPairing: has(AirPlayFeatureFlags.SupportsHKPairingAndAccessControl),
|
|
1931
|
+
supportsSystemPairing: has(AirPlayFeatureFlags.SupportsSystemPairing),
|
|
1932
|
+
supportsCoreUtilsPairing: has(AirPlayFeatureFlags.SupportsCoreUtilsPairingAndEncryption)
|
|
1675
1933
|
};
|
|
1676
1934
|
}
|
|
1677
1935
|
/** Whether the control stream TCP connection is currently active. */
|
|
@@ -1682,6 +1940,10 @@ var device_default = class extends EventEmitter {
|
|
|
1682
1940
|
get receiverInfo() {
|
|
1683
1941
|
return this.#protocol?.receiverInfo;
|
|
1684
1942
|
}
|
|
1943
|
+
/** The Artwork controller for fetching now-playing artwork from all sources. */
|
|
1944
|
+
get artwork() {
|
|
1945
|
+
return this.#artwork;
|
|
1946
|
+
}
|
|
1685
1947
|
/** The Remote controller for HID keys, SendCommand, text input, and touch. */
|
|
1686
1948
|
get remote() {
|
|
1687
1949
|
return this.#remote;
|
|
@@ -1702,6 +1964,7 @@ var device_default = class extends EventEmitter {
|
|
|
1702
1964
|
set timingServer(timingServer) {
|
|
1703
1965
|
this.#timingServer = timingServer;
|
|
1704
1966
|
}
|
|
1967
|
+
#artwork;
|
|
1705
1968
|
#remote;
|
|
1706
1969
|
#state;
|
|
1707
1970
|
#volume;
|
|
@@ -1711,6 +1974,7 @@ var device_default = class extends EventEmitter {
|
|
|
1711
1974
|
#identity;
|
|
1712
1975
|
#feedbackInterval;
|
|
1713
1976
|
#keys;
|
|
1977
|
+
#lastArtworkId = null;
|
|
1714
1978
|
#playUrlProtocol;
|
|
1715
1979
|
#prevDataStream;
|
|
1716
1980
|
#prevEventStream;
|
|
@@ -1726,10 +1990,12 @@ var device_default = class extends EventEmitter {
|
|
|
1726
1990
|
super();
|
|
1727
1991
|
this.#discoveryResult = discoveryResult;
|
|
1728
1992
|
this.#identity = identity;
|
|
1993
|
+
this.#artwork = new Artwork(this);
|
|
1729
1994
|
this.#remote = new remote_default(this);
|
|
1730
1995
|
this.#state = new state_default(this);
|
|
1731
1996
|
this.onClose = this.onClose.bind(this);
|
|
1732
1997
|
this.onError = this.onError.bind(this);
|
|
1998
|
+
this.onNowPlayingChanged = this.onNowPlayingChanged.bind(this);
|
|
1733
1999
|
this.onTimeout = this.onTimeout.bind(this);
|
|
1734
2000
|
this.#volume = new volume_default(this);
|
|
1735
2001
|
}
|
|
@@ -1879,6 +2145,37 @@ var device_default = class extends EventEmitter {
|
|
|
1879
2145
|
await this.#protocol.setupAudioStream(source);
|
|
1880
2146
|
}
|
|
1881
2147
|
/**
|
|
2148
|
+
* Sets the audio listening mode on the device (HomePod).
|
|
2149
|
+
*
|
|
2150
|
+
* @param mode - Listening mode string (e.g. 'Default', 'Vivid', 'LateNight').
|
|
2151
|
+
*/
|
|
2152
|
+
async setListeningMode(mode) {
|
|
2153
|
+
const uid = this.state.outputDeviceUID;
|
|
2154
|
+
if (uid) await this.#protocol.dataStream.send(DataStreamMessage.setListeningMode(mode, uid));
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Sets the audio routing mode on the receiver via the control stream.
|
|
2158
|
+
*
|
|
2159
|
+
* @param mode - Audio mode (e.g. 'default', 'moviePlayback', 'spoken').
|
|
2160
|
+
*/
|
|
2161
|
+
async setAudioMode(mode) {
|
|
2162
|
+
await this.#protocol.controlStream.setAudioMode(mode);
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Triggers an audio fade on the device.
|
|
2166
|
+
*
|
|
2167
|
+
* @param fadeType - The fade type (0 = fade out, 1 = fade in).
|
|
2168
|
+
*/
|
|
2169
|
+
async audioFade(fadeType) {
|
|
2170
|
+
await this.#protocol.dataStream.send(DataStreamMessage.audioFade(fadeType));
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Wakes the device from sleep via the DataStream.
|
|
2174
|
+
*/
|
|
2175
|
+
async wake() {
|
|
2176
|
+
await this.#protocol.dataStream.send(DataStreamMessage.wakeDevice());
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
1882
2179
|
* Requests the playback queue from the device.
|
|
1883
2180
|
*
|
|
1884
2181
|
* @param length - Maximum number of queue items to retrieve.
|
|
@@ -1928,6 +2225,14 @@ var device_default = class extends EventEmitter {
|
|
|
1928
2225
|
onError(err) {
|
|
1929
2226
|
this.#protocol.context.logger.error("AirPlay error", err);
|
|
1930
2227
|
}
|
|
2228
|
+
/** Handles now-playing changes to auto-fetch artwork on track changes. */
|
|
2229
|
+
onNowPlayingChanged(_client, player) {
|
|
2230
|
+
const artworkId = player?.artworkId ?? null;
|
|
2231
|
+
if (artworkId !== this.#lastArtworkId) {
|
|
2232
|
+
this.#lastArtworkId = artworkId;
|
|
2233
|
+
this.requestPlaybackQueue(1).catch(() => {});
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
1931
2236
|
/** Handles stream timeout events by destroying the control stream. */
|
|
1932
2237
|
onTimeout() {
|
|
1933
2238
|
this.#protocol.context.logger.error("AirPlay timeout");
|
|
@@ -1958,9 +2263,11 @@ var device_default = class extends EventEmitter {
|
|
|
1958
2263
|
if (this.#feedbackInterval) clearInterval(this.#feedbackInterval);
|
|
1959
2264
|
this.#feedbackInterval = setInterval(async () => await this.#feedback(), FEEDBACK_INTERVAL);
|
|
1960
2265
|
await this.#protocol.dataStream.exchange(DataStreamMessage.deviceInfo(keys.pairingId, this.#protocol.context.identity));
|
|
1961
|
-
|
|
1962
|
-
|
|
2266
|
+
this.#protocol.dataStream.send(DataStreamMessage.setConnectionState());
|
|
2267
|
+
this.#protocol.dataStream.send(DataStreamMessage.clientUpdatesConfig(true, true, true, true));
|
|
1963
2268
|
await this.#protocol.dataStream.exchange(DataStreamMessage.getState());
|
|
2269
|
+
this.#lastArtworkId = null;
|
|
2270
|
+
this.#state.on("nowPlayingChanged", this.onNowPlayingChanged);
|
|
1964
2271
|
this.#protocol.context.logger.info("Protocol ready.");
|
|
1965
2272
|
} catch (err) {
|
|
1966
2273
|
if (this.#feedbackInterval) {
|
|
@@ -1979,6 +2286,7 @@ var device_default = class extends EventEmitter {
|
|
|
1979
2286
|
/** Unsubscribes the state tracker from DataStream events. */
|
|
1980
2287
|
#unsubscribe() {
|
|
1981
2288
|
try {
|
|
2289
|
+
this.#state.off("nowPlayingChanged", this.onNowPlayingChanged);
|
|
1982
2290
|
this.#state[STATE_UNSUBSCRIBE_SYMBOL]();
|
|
1983
2291
|
} catch (err) {
|
|
1984
2292
|
this.#protocol.context.logger.error("State unsubscribe error", err);
|
|
@@ -2677,6 +2985,33 @@ var device_default$1 = class extends EventEmitter {
|
|
|
2677
2985
|
}
|
|
2678
2986
|
};
|
|
2679
2987
|
|
|
2988
|
+
//#endregion
|
|
2989
|
+
//#region src/utils.ts
|
|
2990
|
+
/**
|
|
2991
|
+
* Gets the CommandInfo for a specific command from the active now-playing client.
|
|
2992
|
+
*
|
|
2993
|
+
* @param state - The AirPlay state tracker.
|
|
2994
|
+
* @param command - The command to look up.
|
|
2995
|
+
* @returns The command info, or null if no client is active or command not found.
|
|
2996
|
+
*/
|
|
2997
|
+
function getCommandInfo(state, command) {
|
|
2998
|
+
const client = state.nowPlayingClient;
|
|
2999
|
+
if (!client) return null;
|
|
3000
|
+
return client.supportedCommands.find((c) => c.command === command) ?? null;
|
|
3001
|
+
}
|
|
3002
|
+
/**
|
|
3003
|
+
* Checks whether a command is supported and enabled by the active now-playing client.
|
|
3004
|
+
*
|
|
3005
|
+
* @param state - The AirPlay state tracker.
|
|
3006
|
+
* @param command - The command to check.
|
|
3007
|
+
* @returns True if supported and enabled, false otherwise.
|
|
3008
|
+
*/
|
|
3009
|
+
function isCommandSupported(state, command) {
|
|
3010
|
+
const client = state.nowPlayingClient;
|
|
3011
|
+
if (!client) return false;
|
|
3012
|
+
return client.isCommandSupported(command);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
2680
3015
|
//#endregion
|
|
2681
3016
|
//#region src/model/apple-tv.ts
|
|
2682
3017
|
/**
|
|
@@ -2916,9 +3251,7 @@ var apple_tv_default = class extends EventEmitter {
|
|
|
2916
3251
|
* @returns The command info, or null if no client is active or command not found.
|
|
2917
3252
|
*/
|
|
2918
3253
|
async getCommandInfo(command) {
|
|
2919
|
-
|
|
2920
|
-
if (!client) return null;
|
|
2921
|
-
return client.supportedCommands.find((c) => c.command === command) ?? null;
|
|
3254
|
+
return getCommandInfo(this.#airplay.state, command);
|
|
2922
3255
|
}
|
|
2923
3256
|
/**
|
|
2924
3257
|
* Checks whether a command is supported and enabled by the active player.
|
|
@@ -2927,9 +3260,7 @@ var apple_tv_default = class extends EventEmitter {
|
|
|
2927
3260
|
* @returns True if supported and enabled, false otherwise.
|
|
2928
3261
|
*/
|
|
2929
3262
|
async isCommandSupported(command) {
|
|
2930
|
-
|
|
2931
|
-
if (!client) return false;
|
|
2932
|
-
return client.isCommandSupported(command);
|
|
3263
|
+
return isCommandSupported(this.#airplay.state, command);
|
|
2933
3264
|
}
|
|
2934
3265
|
/** Emits 'connected' when both AirPlay and Companion Link are connected. */
|
|
2935
3266
|
async #onConnected() {
|
|
@@ -3026,6 +3357,26 @@ var homepod_base_default = class extends EventEmitter {
|
|
|
3026
3357
|
get volume() {
|
|
3027
3358
|
return this.#airplay.state.volume ?? 0;
|
|
3028
3359
|
}
|
|
3360
|
+
/** Whether the device is currently muted. */
|
|
3361
|
+
get isMuted() {
|
|
3362
|
+
return this.#airplay.state.volumeMuted;
|
|
3363
|
+
}
|
|
3364
|
+
/** Cluster ID when part of a speaker group, or null. */
|
|
3365
|
+
get clusterID() {
|
|
3366
|
+
return this.#airplay.state.clusterID;
|
|
3367
|
+
}
|
|
3368
|
+
/** Cluster type identifier (0 = none). */
|
|
3369
|
+
get clusterType() {
|
|
3370
|
+
return this.#airplay.state.clusterType;
|
|
3371
|
+
}
|
|
3372
|
+
/** Whether this device supports cluster-aware multi-room. */
|
|
3373
|
+
get isClusterAware() {
|
|
3374
|
+
return this.#airplay.state.isClusterAware;
|
|
3375
|
+
}
|
|
3376
|
+
/** Whether this device is the leader in a speaker cluster. */
|
|
3377
|
+
get isClusterLeader() {
|
|
3378
|
+
return this.#airplay.state.isClusterLeader;
|
|
3379
|
+
}
|
|
3029
3380
|
/** @returns The currently active now-playing client, or null. */
|
|
3030
3381
|
get #nowPlayingClient() {
|
|
3031
3382
|
return this.#airplay.state.nowPlayingClient;
|
|
@@ -3102,15 +3453,23 @@ var homepod_base_default = class extends EventEmitter {
|
|
|
3102
3453
|
await this.#airplay.streamAudio(source);
|
|
3103
3454
|
}
|
|
3104
3455
|
/**
|
|
3456
|
+
* Requests the current playback queue with lyrics from the device.
|
|
3457
|
+
* Lyrics are included by default in the playback queue request.
|
|
3458
|
+
* Real-time lyrics timing events are emitted via the `lyricsEvent` event on state.
|
|
3459
|
+
*
|
|
3460
|
+
* @param length - Maximum number of queue items to retrieve (defaults to 1).
|
|
3461
|
+
*/
|
|
3462
|
+
async requestLyrics(length = 1) {
|
|
3463
|
+
await this.#airplay.requestPlaybackQueue(length);
|
|
3464
|
+
}
|
|
3465
|
+
/**
|
|
3105
3466
|
* Gets the CommandInfo for a specific command from the active player.
|
|
3106
3467
|
*
|
|
3107
3468
|
* @param command - The command to look up.
|
|
3108
3469
|
* @returns The command info, or null if no client is active or command not found.
|
|
3109
3470
|
*/
|
|
3110
3471
|
async getCommandInfo(command) {
|
|
3111
|
-
|
|
3112
|
-
if (!client) return null;
|
|
3113
|
-
return client.supportedCommands.find((c) => c.command === command) ?? null;
|
|
3472
|
+
return getCommandInfo(this.#airplay.state, command);
|
|
3114
3473
|
}
|
|
3115
3474
|
/**
|
|
3116
3475
|
* Checks whether a command is supported and enabled by the active player.
|
|
@@ -3119,9 +3478,7 @@ var homepod_base_default = class extends EventEmitter {
|
|
|
3119
3478
|
* @returns True if supported and enabled, false otherwise.
|
|
3120
3479
|
*/
|
|
3121
3480
|
async isCommandSupported(command) {
|
|
3122
|
-
|
|
3123
|
-
if (!client) return false;
|
|
3124
|
-
return client.isCommandSupported(command);
|
|
3481
|
+
return isCommandSupported(this.#airplay.state, command);
|
|
3125
3482
|
}
|
|
3126
3483
|
/** Emits 'connected' when the AirPlay connection is established. */
|
|
3127
3484
|
async #onConnected() {
|
|
@@ -3158,4 +3515,4 @@ var homepod_default = class extends homepod_base_default {};
|
|
|
3158
3515
|
var homepod_mini_default = class extends homepod_base_default {};
|
|
3159
3516
|
|
|
3160
3517
|
//#endregion
|
|
3161
|
-
export { PROTOCOL as AIRPLAY, Client as AirPlayClient, device_default as AirPlayDevice, Player as AirPlayPlayer, remote_default as AirPlayRemote, state_default as AirPlayState, volume_default as AirPlayVolume, apple_tv_default as AppleTV, PROTOCOL$1 as COMPANION_LINK, device_default$1 as CompanionLinkDevice, homepod_default as HomePod, homepod_mini_default as HomePodMini, SendCommandError };
|
|
3518
|
+
export { PROTOCOL as AIRPLAY, Artwork as AirPlayArtwork, Client as AirPlayClient, device_default as AirPlayDevice, Player as AirPlayPlayer, remote_default as AirPlayRemote, state_default as AirPlayState, volume_default as AirPlayVolume, apple_tv_default as AppleTV, PROTOCOL$1 as COMPANION_LINK, device_default$1 as CompanionLinkDevice, homepod_default as HomePod, homepod_mini_default as HomePodMini, SendCommandError, getCommandInfo, isCommandSupported };
|