@basmilius/apple-airplay 0.9.18 → 0.9.19
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 +85 -12
- package/dist/index.mjs +368 -72
- package/package.json +5 -5
package/dist/index.d.mts
CHANGED
|
@@ -42,6 +42,51 @@ declare class Verify {
|
|
|
42
42
|
start(credentials: AccessoryCredentials): Promise<AccessoryKeys>;
|
|
43
43
|
}
|
|
44
44
|
//#endregion
|
|
45
|
+
//#region src/audioStream.d.ts
|
|
46
|
+
type AudioStreamContext = {
|
|
47
|
+
sampleRate: number;
|
|
48
|
+
channels: number;
|
|
49
|
+
bytesPerChannel: number;
|
|
50
|
+
frameSize: number;
|
|
51
|
+
packetSize: number;
|
|
52
|
+
rtpSeq: number;
|
|
53
|
+
rtpTime: number;
|
|
54
|
+
headTs: number;
|
|
55
|
+
latency: number;
|
|
56
|
+
paddingSent: number;
|
|
57
|
+
totalFrames: number;
|
|
58
|
+
};
|
|
59
|
+
declare class AudioStream {
|
|
60
|
+
#private;
|
|
61
|
+
constructor(protocol: Protocol);
|
|
62
|
+
setup(): Promise<{
|
|
63
|
+
dataPort: number;
|
|
64
|
+
controlPort: number;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Prepare the audio stream for sending. Connects the UDP data socket,
|
|
68
|
+
* initializes stream context, sends FLUSH, and starts RTCP sync.
|
|
69
|
+
*/
|
|
70
|
+
prepare(remoteAddress: string): Promise<AudioStreamContext>;
|
|
71
|
+
/**
|
|
72
|
+
* Send pre-read frame data as an RTP packet. Used by AudioMultiplexer
|
|
73
|
+
* for multi-room streaming where frames are read once and sent to
|
|
74
|
+
* multiple streams.
|
|
75
|
+
*/
|
|
76
|
+
sendFrameData(frames: Buffer, firstPacket: boolean): Promise<number>;
|
|
77
|
+
/**
|
|
78
|
+
* Send padding frames (silence) after audio ends and TEARDOWN.
|
|
79
|
+
*/
|
|
80
|
+
finish(): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Stream audio from a source. Convenience method that uses prepare(),
|
|
83
|
+
* sendFrameData() and finish() internally.
|
|
84
|
+
*/
|
|
85
|
+
stream(source: AudioSource, remoteAddress: string): Promise<void>;
|
|
86
|
+
getPacket(seqno: number): Buffer | undefined;
|
|
87
|
+
close(): void;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
45
90
|
//#region ../../node_modules/.bun/@bufbuild+protobuf@2.11.0/node_modules/@bufbuild/protobuf/dist/esm/json-value.d.ts
|
|
46
91
|
/**
|
|
47
92
|
* Represents any possible JSON value:
|
|
@@ -10341,6 +10386,7 @@ type EventMap$1 = {
|
|
|
10341
10386
|
readonly rawMessage: [ProtocolMessage];
|
|
10342
10387
|
readonly deviceInfo: [DeviceInfoMessage];
|
|
10343
10388
|
readonly deviceInfoUpdate: [DeviceInfoMessage];
|
|
10389
|
+
readonly keyboard: [KeyboardMessage];
|
|
10344
10390
|
readonly originClientProperties: [OriginClientPropertiesMessage];
|
|
10345
10391
|
readonly playerClientProperties: [PlayerClientPropertiesMessage];
|
|
10346
10392
|
readonly removeClient: [RemoveClientMessage];
|
|
@@ -10380,6 +10426,16 @@ declare class EventStream extends BaseStream {
|
|
|
10380
10426
|
}
|
|
10381
10427
|
//#endregion
|
|
10382
10428
|
//#region src/protocol.d.ts
|
|
10429
|
+
type PlaybackInfo = {
|
|
10430
|
+
duration?: number;
|
|
10431
|
+
position?: number;
|
|
10432
|
+
rate?: number;
|
|
10433
|
+
readyToPlay?: boolean;
|
|
10434
|
+
error?: {
|
|
10435
|
+
code: number;
|
|
10436
|
+
domain: string;
|
|
10437
|
+
};
|
|
10438
|
+
};
|
|
10383
10439
|
declare class Protocol {
|
|
10384
10440
|
#private;
|
|
10385
10441
|
get context(): Context;
|
|
@@ -10400,24 +10456,39 @@ declare class Protocol {
|
|
|
10400
10456
|
setupEventStream(sharedSecret: Buffer, pairingId: Buffer): Promise<void>;
|
|
10401
10457
|
setupEventStreamForAudioStreaming(sharedSecret: Buffer, pairingId: Buffer): Promise<void>;
|
|
10402
10458
|
playUrl(url: string, sharedSecret: Buffer, pairingId: Buffer, position?: number): Promise<void>;
|
|
10459
|
+
getPlaybackInfo(): Promise<PlaybackInfo | null>;
|
|
10460
|
+
waitForPlaybackEnd(): Promise<void>;
|
|
10461
|
+
stopPlayUrl(): void;
|
|
10403
10462
|
setupAudioStream(source: AudioSource): Promise<void>;
|
|
10404
10463
|
useTimingServer(timingServer: TimingServer): void;
|
|
10405
10464
|
}
|
|
10406
10465
|
//#endregion
|
|
10407
|
-
//#region src/
|
|
10408
|
-
|
|
10466
|
+
//#region src/audioMultiplexer.d.ts
|
|
10467
|
+
/**
|
|
10468
|
+
* Streams audio from a single source to multiple AirPlay devices
|
|
10469
|
+
* simultaneously. Each device gets its own AudioStream with independent
|
|
10470
|
+
* encryption and RTP state, but they all receive the same audio data
|
|
10471
|
+
* with shared timing.
|
|
10472
|
+
*/
|
|
10473
|
+
declare class AudioMultiplexer {
|
|
10409
10474
|
#private;
|
|
10410
|
-
constructor(
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
10415
|
-
|
|
10416
|
-
|
|
10417
|
-
|
|
10475
|
+
constructor(context: Context);
|
|
10476
|
+
/**
|
|
10477
|
+
* Add a target device to stream to.
|
|
10478
|
+
*/
|
|
10479
|
+
addTarget(protocol: Protocol): void;
|
|
10480
|
+
/**
|
|
10481
|
+
* Remove all targets and close their streams.
|
|
10482
|
+
*/
|
|
10483
|
+
clear(): void;
|
|
10484
|
+
/**
|
|
10485
|
+
* Stream audio from a source to all targets simultaneously.
|
|
10486
|
+
* Sets up, prepares, and streams to all devices, then tears down.
|
|
10487
|
+
*/
|
|
10488
|
+
stream(source: AudioSource): Promise<void>;
|
|
10418
10489
|
}
|
|
10419
10490
|
declare namespace dataStreamMessages_d_exports {
|
|
10420
|
-
export { clientUpdatesConfig, configureConnection, deviceInfo, getExtension, getState, getVolume, getVolumeMuted, modifyOutputContext, notification, playbackQueueRequest, protocol, sendButtonEvent, sendCommand, sendCommandWithPlaybackPosition, sendCommandWithPlaybackRate, sendCommandWithRepeatMode, sendCommandWithShuffleMode, sendCommandWithSkipInterval, sendHIDEvent, sendVirtualTouchEvent, setConnectionState, setReadyState, setVolume, setVolumeMuted, wakeDevice };
|
|
10491
|
+
export { clientUpdatesConfig, configureConnection, deviceInfo, getExtension, getKeyboardSession, getState, getVolume, getVolumeMuted, modifyOutputContext, notification, playbackQueueRequest, protocol, sendButtonEvent, sendCommand, sendCommandWithPlaybackPosition, sendCommandWithPlaybackRate, sendCommandWithRepeatMode, sendCommandWithShuffleMode, sendCommandWithSkipInterval, sendHIDEvent, sendVirtualTouchEvent, setConnectionState, setReadyState, setVolume, setVolumeMuted, textInput, wakeDevice };
|
|
10421
10492
|
}
|
|
10422
10493
|
declare function protocol(type: ProtocolMessage_Type, errorCode?: ErrorCode_Enum): ProtocolMessage;
|
|
10423
10494
|
declare function clientUpdatesConfig(artworkUpdates?: boolean, nowPlayingUpdates?: boolean, volumeUpdates?: boolean, keyboardUpdates?: boolean, outputDeviceUpdates?: boolean, systemEndpointUpdates?: boolean): [ProtocolMessage, DescExtension];
|
|
@@ -10429,6 +10500,8 @@ declare function getVolume(outputDeviceUID: string): [ProtocolMessage, DescExten
|
|
|
10429
10500
|
declare function getVolumeMuted(outputDeviceUID: string): [ProtocolMessage, DescExtension];
|
|
10430
10501
|
declare function notification(notification: string): [ProtocolMessage, DescExtension];
|
|
10431
10502
|
declare function playbackQueueRequest(location: number, length: number, artworkWidth?: number, artworkHeight?: number): [ProtocolMessage, DescExtension];
|
|
10503
|
+
declare function getKeyboardSession(): [ProtocolMessage, DescExtension];
|
|
10504
|
+
declare function textInput(text: string, actionType: ActionType_Enum): [ProtocolMessage, DescExtension];
|
|
10432
10505
|
declare function sendButtonEvent(usagePage: number, usage: number, buttonDown: boolean): [ProtocolMessage, DescExtension];
|
|
10433
10506
|
declare function sendCommandWithSkipInterval(command: Command, skipInterval: number): [ProtocolMessage, DescExtension];
|
|
10434
10507
|
declare function sendCommandWithPlaybackPosition(command: Command, playbackPosition: number): [ProtocolMessage, DescExtension];
|
|
@@ -10445,4 +10518,4 @@ declare function setVolumeMuted(outputDeviceUID: string, isMuted: boolean): [Pro
|
|
|
10445
10518
|
declare function wakeDevice(): [ProtocolMessage, DescExtension];
|
|
10446
10519
|
declare function getExtension<Desc extends DescExtension>(message: Extendee<Desc>, extension: Desc): ExtensionValueShape<Desc>;
|
|
10447
10520
|
//#endregion
|
|
10448
|
-
export { AudioStream, ControlStream, DataStream, dataStreamMessages_d_exports as DataStreamMessage, EventStream, Pairing, index_d_exports as Proto, Protocol, Verify };
|
|
10521
|
+
export { AudioMultiplexer, AudioStream, ControlStream, DataStream, dataStreamMessages_d_exports as DataStreamMessage, EventStream, Pairing, type PlaybackInfo, index_d_exports as Proto, Protocol, Verify };
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { createSocket } from "node:dgram";
|
|
4
|
-
import { AccessoryPair, AccessoryVerify, Context, EncryptionAwareConnection, EncryptionState, HTTP_TIMEOUT, generateActiveRemoteId, generateDacpId, generateSessionId, getMacAddress, randomInt32, randomInt64, uint16ToBE, uuid } from "@basmilius/apple-common";
|
|
4
|
+
import { AccessoryPair, AccessoryVerify, ConnectionClosedError, Context, EncryptionAwareConnection, EncryptionError, EncryptionState, HTTP_TIMEOUT, PairingError, PlaybackError, SetupError, TimeoutError, generateActiveRemoteId, generateDacpId, generateSessionId, getMacAddress, randomInt32, randomInt64, uint16ToBE, uuid, waitFor } from "@basmilius/apple-common";
|
|
5
5
|
import { NTP, Plist } from "@basmilius/apple-encoding";
|
|
6
6
|
import { Chacha20, hkdf } from "@basmilius/apple-encryption";
|
|
7
7
|
import { RtspClient, buildResponse, parseRequest } from "@basmilius/apple-rtsp";
|
|
@@ -14,6 +14,8 @@ const FRAMES_PER_PACKET = 352;
|
|
|
14
14
|
const LATENCY_FRAMES = 11025;
|
|
15
15
|
const PACKET_BACKLOG_SIZE = 1e3;
|
|
16
16
|
const SYNC_INTERVAL = 1e3;
|
|
17
|
+
const MAX_PACKETS_COMPENSATE$1 = 3;
|
|
18
|
+
const SLOW_WARNING_THRESHOLD$1 = 5;
|
|
17
19
|
const ntpFromTs = (timestamp, sampleRate) => {
|
|
18
20
|
const seconds = Math.floor(timestamp / sampleRate);
|
|
19
21
|
const fraction = timestamp % sampleRate * 4294967295 / sampleRate;
|
|
@@ -60,7 +62,7 @@ var AudioStream = class {
|
|
|
60
62
|
latencyMax: 88200,
|
|
61
63
|
latencyMin: 11025,
|
|
62
64
|
shk: shkArrayBuffer,
|
|
63
|
-
spf:
|
|
65
|
+
spf: 352,
|
|
64
66
|
sr: SAMPLE_RATE,
|
|
65
67
|
streamConnectionID,
|
|
66
68
|
supportsDynamicStreamID: false,
|
|
@@ -71,7 +73,7 @@ var AudioStream = class {
|
|
|
71
73
|
const response = await this.#protocol.controlStream.setup(`/${this.#protocol.controlStream.sessionId}`, Buffer.from(setupBody), { "Content-Type": "application/x-apple-binary-plist" });
|
|
72
74
|
if (response.status !== 200) {
|
|
73
75
|
const text = await response.text();
|
|
74
|
-
throw new
|
|
76
|
+
throw new SetupError(`Failed to setup audio stream: ${response.status} - ${text}`);
|
|
75
77
|
}
|
|
76
78
|
const plist = Plist.parse(await response.arrayBuffer());
|
|
77
79
|
this.#context.logger.debug("[audio]", "Setup stream response:", plist);
|
|
@@ -80,7 +82,7 @@ var AudioStream = class {
|
|
|
80
82
|
this.#dataPort = streamInfo.dataPort & 65535;
|
|
81
83
|
this.#remoteControlPort = streamInfo.controlPort & 65535;
|
|
82
84
|
this.#context.logger.info("[audio]", `Audio stream setup: dataPort=${this.#dataPort}, controlPort=${this.#remoteControlPort}`);
|
|
83
|
-
} else throw new
|
|
85
|
+
} else throw new SetupError("No stream info in SETUP response.");
|
|
84
86
|
this.#context.logger.debug("[audio]", "Sending RECORD...");
|
|
85
87
|
await this.#protocol.controlStream.record(`/${this.#protocol.controlStream.sessionId}`);
|
|
86
88
|
this.#context.logger.debug("[audio]", "RECORD complete");
|
|
@@ -89,45 +91,86 @@ var AudioStream = class {
|
|
|
89
91
|
controlPort: this.#remoteControlPort
|
|
90
92
|
};
|
|
91
93
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Prepare the audio stream for sending. Connects the UDP data socket,
|
|
96
|
+
* initializes stream context, sends FLUSH, and starts RTCP sync.
|
|
97
|
+
*/
|
|
98
|
+
async prepare(remoteAddress) {
|
|
99
|
+
if (!this.#controlSocket || !this.#sharedKey || !this.#dataPort) throw new SetupError("Audio stream not setup.");
|
|
94
100
|
this.#dataSocket = createSocket("udp4");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.#dataSocket.
|
|
99
|
-
|
|
100
|
-
resolve();
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
const frameSize = CHANNELS * BYTES_PER_CHANNEL;
|
|
104
|
-
const ctx = {
|
|
105
|
-
sampleRate: SAMPLE_RATE,
|
|
106
|
-
channels: CHANNELS,
|
|
107
|
-
bytesPerChannel: BYTES_PER_CHANNEL,
|
|
108
|
-
frameSize,
|
|
109
|
-
packetSize: FRAMES_PER_PACKET * frameSize,
|
|
110
|
-
rtpSeq: randomInt32() & 65535,
|
|
111
|
-
rtpTime: 0,
|
|
112
|
-
headTs: 0,
|
|
113
|
-
latency: LATENCY_FRAMES,
|
|
114
|
-
paddingSent: 0,
|
|
115
|
-
totalFrames: 0
|
|
116
|
-
};
|
|
117
|
-
this.#streamContext = ctx;
|
|
118
|
-
this.#context.logger.debug("[audio]", "Sending FLUSH...");
|
|
119
|
-
await this.#protocol.controlStream.flush(`/${this.#protocol.controlStream.sessionId}`, {
|
|
120
|
-
"Range": "npt=0-",
|
|
121
|
-
"RTP-Info": `seq=${ctx.rtpSeq};rtptime=${ctx.rtpTime}`
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
this.#dataSocket.once("error", reject);
|
|
103
|
+
this.#dataSocket.connect(this.#dataPort, remoteAddress, () => {
|
|
104
|
+
this.#dataSocket.removeListener("error", reject);
|
|
105
|
+
resolve();
|
|
122
106
|
});
|
|
123
|
-
|
|
107
|
+
});
|
|
108
|
+
const frameSize = CHANNELS * BYTES_PER_CHANNEL;
|
|
109
|
+
const ctx = {
|
|
110
|
+
sampleRate: SAMPLE_RATE,
|
|
111
|
+
channels: CHANNELS,
|
|
112
|
+
bytesPerChannel: BYTES_PER_CHANNEL,
|
|
113
|
+
frameSize,
|
|
114
|
+
packetSize: 352 * frameSize,
|
|
115
|
+
rtpSeq: randomInt32() & 65535,
|
|
116
|
+
rtpTime: 0,
|
|
117
|
+
headTs: 0,
|
|
118
|
+
latency: LATENCY_FRAMES,
|
|
119
|
+
paddingSent: 0,
|
|
120
|
+
totalFrames: 0
|
|
121
|
+
};
|
|
122
|
+
this.#streamContext = ctx;
|
|
123
|
+
this.#context.logger.debug("[audio]", "Sending FLUSH...");
|
|
124
|
+
await this.#protocol.controlStream.flush(`/${this.#protocol.controlStream.sessionId}`, {
|
|
125
|
+
"Range": "npt=0-",
|
|
126
|
+
"RTP-Info": `seq=${ctx.rtpSeq};rtptime=${ctx.rtpTime}`
|
|
127
|
+
});
|
|
128
|
+
this.#context.logger.debug("[audio]", "FLUSH complete");
|
|
129
|
+
this.#context.logger.debug("[audio]", `RTP start: seq=${ctx.rtpSeq}, time=${ctx.rtpTime}, ssrc=${this.#ssrc}`);
|
|
130
|
+
this.#packetBacklog.clear();
|
|
131
|
+
this.#startSync();
|
|
132
|
+
return ctx;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Send pre-read frame data as an RTP packet. Used by AudioMultiplexer
|
|
136
|
+
* for multi-room streaming where frames are read once and sent to
|
|
137
|
+
* multiple streams.
|
|
138
|
+
*/
|
|
139
|
+
async sendFrameData(frames, firstPacket) {
|
|
140
|
+
if (!this.#streamContext) throw new SetupError("Audio stream not prepared.");
|
|
141
|
+
return this.#sendFrameBuffer(frames, firstPacket, this.#streamContext);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Send padding frames (silence) after audio ends and TEARDOWN.
|
|
145
|
+
*/
|
|
146
|
+
async finish() {
|
|
147
|
+
if (!this.#streamContext) return;
|
|
148
|
+
const ctx = this.#streamContext;
|
|
149
|
+
while (ctx.paddingSent < ctx.latency) {
|
|
150
|
+
const silence = Buffer.alloc(ctx.packetSize, 0);
|
|
151
|
+
const sent = await this.#sendFrameBuffer(silence, false, ctx);
|
|
152
|
+
if (sent === 0) break;
|
|
153
|
+
ctx.paddingSent += sent;
|
|
154
|
+
const sleepTime = ctx.totalFrames / SAMPLE_RATE * 1e3 - performance.now();
|
|
155
|
+
if (sleepTime > 0) await this.#sleep(sleepTime);
|
|
156
|
+
}
|
|
157
|
+
this.#stopSync();
|
|
158
|
+
this.#context.logger.debug("[audio]", "Sending TEARDOWN...");
|
|
159
|
+
await this.#protocol.controlStream.teardown(`/${this.#protocol.controlStream.sessionId}`);
|
|
160
|
+
this.#context.logger.debug("[audio]", "TEARDOWN complete");
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Stream audio from a source. Convenience method that uses prepare(),
|
|
164
|
+
* sendFrameData() and finish() internally.
|
|
165
|
+
*/
|
|
166
|
+
async stream(source, remoteAddress) {
|
|
167
|
+
const ctx = await this.prepare(remoteAddress);
|
|
168
|
+
try {
|
|
124
169
|
let firstPacket = true;
|
|
125
170
|
let packetCount = 0;
|
|
171
|
+
let slowCount = 0;
|
|
126
172
|
const startTime = performance.now();
|
|
127
173
|
this.#context.logger.info("[audio]", "Starting audio stream...");
|
|
128
|
-
this.#context.logger.debug("[audio]", `RTP start: seq=${ctx.rtpSeq}, time=${ctx.rtpTime}, ssrc=${this.#ssrc}`);
|
|
129
|
-
this.#packetBacklog.clear();
|
|
130
|
-
this.#startSync();
|
|
131
174
|
while (true) {
|
|
132
175
|
if (await this.#sendPacket(source, firstPacket, ctx) === 0) {
|
|
133
176
|
this.#context.logger.debug("[audio]", `End of audio stream after ${packetCount} packets (padding complete)`);
|
|
@@ -137,7 +180,24 @@ var AudioStream = class {
|
|
|
137
180
|
firstPacket = false;
|
|
138
181
|
if (packetCount % 100 === 0) this.#context.logger.debug("[audio]", `Sent ${packetCount} packets, ${ctx.totalFrames} frames`);
|
|
139
182
|
const sleepTime = ctx.totalFrames / SAMPLE_RATE * 1e3 - (performance.now() - startTime);
|
|
140
|
-
if (sleepTime > 0)
|
|
183
|
+
if (sleepTime > 0) {
|
|
184
|
+
slowCount = 0;
|
|
185
|
+
await this.#sleep(sleepTime);
|
|
186
|
+
} else {
|
|
187
|
+
const framesBehind = Math.floor(-sleepTime / 1e3 * SAMPLE_RATE);
|
|
188
|
+
if (framesBehind >= 352) {
|
|
189
|
+
const extraPackets = Math.min(Math.floor(framesBehind / 352), MAX_PACKETS_COMPENSATE$1);
|
|
190
|
+
for (let i = 0; i < extraPackets; i++) {
|
|
191
|
+
if (await this.#sendPacket(source, false, ctx) === 0) break;
|
|
192
|
+
packetCount++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
slowCount++;
|
|
196
|
+
if (slowCount >= SLOW_WARNING_THRESHOLD$1) {
|
|
197
|
+
this.#context.logger.warn("[audio]", `Stream is behind schedule (${slowCount} consecutive slow packets, ${Math.abs(sleepTime).toFixed(1)}ms behind)`);
|
|
198
|
+
slowCount = 0;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
141
201
|
}
|
|
142
202
|
this.#context.logger.info("[audio]", `Audio stream finished, sent ${packetCount} packets`);
|
|
143
203
|
this.#stopSync();
|
|
@@ -154,7 +214,7 @@ var AudioStream = class {
|
|
|
154
214
|
}
|
|
155
215
|
async #sendPacket(source, firstPacket, ctx) {
|
|
156
216
|
if (ctx.paddingSent >= ctx.latency) return 0;
|
|
157
|
-
let frames = await source.readFrames(
|
|
217
|
+
let frames = await source.readFrames(352);
|
|
158
218
|
if (!frames || frames.length === 0) {
|
|
159
219
|
frames = Buffer.alloc(ctx.packetSize, 0);
|
|
160
220
|
ctx.paddingSent += Math.floor(frames.length / ctx.frameSize);
|
|
@@ -163,6 +223,9 @@ var AudioStream = class {
|
|
|
163
223
|
frames.copy(padded);
|
|
164
224
|
frames = padded;
|
|
165
225
|
}
|
|
226
|
+
return this.#sendFrameBuffer(frames, firstPacket, ctx);
|
|
227
|
+
}
|
|
228
|
+
#sendFrameBuffer(frames, firstPacket, ctx) {
|
|
166
229
|
const rtpHeader = Buffer.allocUnsafe(12);
|
|
167
230
|
rtpHeader.writeUInt8(128, 0);
|
|
168
231
|
rtpHeader.writeUInt8(firstPacket ? 224 : 96, 1);
|
|
@@ -173,12 +236,11 @@ var AudioStream = class {
|
|
|
173
236
|
const payload = this.#encryptAudio(frames, aad, ctx.rtpSeq);
|
|
174
237
|
const packet = Buffer.concat([rtpHeader, payload]);
|
|
175
238
|
this.#storePacket(ctx.rtpSeq, packet);
|
|
176
|
-
await this.#send(packet);
|
|
177
239
|
const framesSent = Math.floor(frames.length / ctx.frameSize);
|
|
178
240
|
ctx.rtpSeq = ctx.rtpSeq + 1 & 65535;
|
|
179
241
|
ctx.headTs = ctx.headTs + framesSent >>> 0;
|
|
180
242
|
ctx.totalFrames += framesSent;
|
|
181
|
-
return framesSent;
|
|
243
|
+
return this.#send(packet).then(() => framesSent);
|
|
182
244
|
}
|
|
183
245
|
#storePacket(seqno, packet) {
|
|
184
246
|
this.#packetBacklog.set(seqno, packet);
|
|
@@ -191,7 +253,7 @@ var AudioStream = class {
|
|
|
191
253
|
return this.#packetBacklog.get(seqno);
|
|
192
254
|
}
|
|
193
255
|
#encryptAudio(data, aad, seqNumber) {
|
|
194
|
-
if (!this.#sharedKey) throw new
|
|
256
|
+
if (!this.#sharedKey) throw new EncryptionError("Encryption not setup.");
|
|
195
257
|
const nonceBytes = Buffer.alloc(12, 0);
|
|
196
258
|
nonceBytes.writeUInt32LE(seqNumber, 4);
|
|
197
259
|
const result = Chacha20.encrypt(this.#sharedKey, nonceBytes, aad, data);
|
|
@@ -227,7 +289,9 @@ var AudioStream = class {
|
|
|
227
289
|
packet.writeUInt32BE(currentFrac, 12);
|
|
228
290
|
packet.writeUInt32BE(ctx.headTs >>> 0, 16);
|
|
229
291
|
firstPacket = false;
|
|
230
|
-
this.#controlSocket.send(packet, this.#remoteControlPort, this.#protocol.discoveryResult.address)
|
|
292
|
+
this.#controlSocket.send(packet, this.#remoteControlPort, this.#protocol.discoveryResult.address, (err) => {
|
|
293
|
+
if (err) this.#protocol.context.logger.warn("[audio]", "Sync packet send failed", err);
|
|
294
|
+
});
|
|
231
295
|
};
|
|
232
296
|
sendSync();
|
|
233
297
|
this.#syncInterval = setInterval(sendSync, SYNC_INTERVAL);
|
|
@@ -261,14 +325,135 @@ var AudioStream = class {
|
|
|
261
325
|
}
|
|
262
326
|
close() {
|
|
263
327
|
this.#stopSync();
|
|
264
|
-
|
|
328
|
+
try {
|
|
329
|
+
this.#controlSocket?.removeAllListeners();
|
|
330
|
+
this.#controlSocket?.close();
|
|
331
|
+
} catch {}
|
|
265
332
|
this.#controlSocket = void 0;
|
|
266
|
-
|
|
333
|
+
try {
|
|
334
|
+
this.#dataSocket?.close();
|
|
335
|
+
} catch {}
|
|
267
336
|
this.#dataSocket = void 0;
|
|
268
337
|
this.#packetBacklog.clear();
|
|
269
338
|
}
|
|
270
339
|
};
|
|
271
340
|
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/audioMultiplexer.ts
|
|
343
|
+
const MAX_PACKETS_COMPENSATE = 3;
|
|
344
|
+
const SLOW_WARNING_THRESHOLD = 5;
|
|
345
|
+
/**
|
|
346
|
+
* Streams audio from a single source to multiple AirPlay devices
|
|
347
|
+
* simultaneously. Each device gets its own AudioStream with independent
|
|
348
|
+
* encryption and RTP state, but they all receive the same audio data
|
|
349
|
+
* with shared timing.
|
|
350
|
+
*/
|
|
351
|
+
var AudioMultiplexer = class {
|
|
352
|
+
#context;
|
|
353
|
+
#targets = [];
|
|
354
|
+
constructor(context) {
|
|
355
|
+
this.#context = context;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Add a target device to stream to.
|
|
359
|
+
*/
|
|
360
|
+
addTarget(protocol) {
|
|
361
|
+
const stream = new AudioStream(protocol);
|
|
362
|
+
this.#targets.push({
|
|
363
|
+
protocol,
|
|
364
|
+
stream
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Remove all targets and close their streams.
|
|
369
|
+
*/
|
|
370
|
+
clear() {
|
|
371
|
+
for (const target of this.#targets) target.stream.close();
|
|
372
|
+
this.#targets.length = 0;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Stream audio from a source to all targets simultaneously.
|
|
376
|
+
* Sets up, prepares, and streams to all devices, then tears down.
|
|
377
|
+
*/
|
|
378
|
+
async stream(source) {
|
|
379
|
+
if (this.#targets.length === 0) return;
|
|
380
|
+
this.#context.logger.info("[multiplexer]", `Streaming to ${this.#targets.length} device(s)...`);
|
|
381
|
+
await Promise.all(this.#targets.map(async (target) => {
|
|
382
|
+
await target.stream.setup();
|
|
383
|
+
}));
|
|
384
|
+
await Promise.all(this.#targets.map(async (target) => {
|
|
385
|
+
await target.stream.prepare(target.protocol.discoveryResult.address);
|
|
386
|
+
}));
|
|
387
|
+
const packetSize = 352 * 4;
|
|
388
|
+
try {
|
|
389
|
+
let firstPacket = true;
|
|
390
|
+
let packetCount = 0;
|
|
391
|
+
let slowCount = 0;
|
|
392
|
+
let totalFrames = 0;
|
|
393
|
+
const startTime = performance.now();
|
|
394
|
+
this.#context.logger.info("[multiplexer]", "Starting multi-room audio stream...");
|
|
395
|
+
while (true) {
|
|
396
|
+
let frames = await source.readFrames(352);
|
|
397
|
+
if (!frames || frames.length === 0) {
|
|
398
|
+
this.#context.logger.debug("[multiplexer]", `End of source after ${packetCount} packets`);
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (frames.length < packetSize) {
|
|
402
|
+
const padded = Buffer.alloc(packetSize, 0);
|
|
403
|
+
frames.copy(padded);
|
|
404
|
+
frames = padded;
|
|
405
|
+
}
|
|
406
|
+
await Promise.all(this.#targets.map(async (target) => {
|
|
407
|
+
await target.stream.sendFrameData(frames, firstPacket);
|
|
408
|
+
}));
|
|
409
|
+
totalFrames += 352;
|
|
410
|
+
packetCount++;
|
|
411
|
+
firstPacket = false;
|
|
412
|
+
if (packetCount % 100 === 0) this.#context.logger.debug("[multiplexer]", `Sent ${packetCount} packets to ${this.#targets.length} device(s)`);
|
|
413
|
+
const sleepTime = totalFrames / SAMPLE_RATE * 1e3 - (performance.now() - startTime);
|
|
414
|
+
if (sleepTime > 0) {
|
|
415
|
+
slowCount = 0;
|
|
416
|
+
await this.#sleep(sleepTime);
|
|
417
|
+
} else {
|
|
418
|
+
const framesBehind = Math.floor(-sleepTime / 1e3 * SAMPLE_RATE);
|
|
419
|
+
if (framesBehind >= 352) {
|
|
420
|
+
const extraPackets = Math.min(Math.floor(framesBehind / 352), MAX_PACKETS_COMPENSATE);
|
|
421
|
+
for (let i = 0; i < extraPackets; i++) {
|
|
422
|
+
let extraFrames = await source.readFrames(352);
|
|
423
|
+
if (!extraFrames || extraFrames.length === 0) break;
|
|
424
|
+
if (extraFrames.length < packetSize) {
|
|
425
|
+
const padded = Buffer.alloc(packetSize, 0);
|
|
426
|
+
extraFrames.copy(padded);
|
|
427
|
+
extraFrames = padded;
|
|
428
|
+
}
|
|
429
|
+
await Promise.all(this.#targets.map(async (target) => {
|
|
430
|
+
await target.stream.sendFrameData(extraFrames, false);
|
|
431
|
+
}));
|
|
432
|
+
totalFrames += 352;
|
|
433
|
+
packetCount++;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
slowCount++;
|
|
437
|
+
if (slowCount >= SLOW_WARNING_THRESHOLD) {
|
|
438
|
+
this.#context.logger.warn("[multiplexer]", `Stream behind schedule (${slowCount} consecutive, ${Math.abs(sleepTime).toFixed(1)}ms behind)`);
|
|
439
|
+
slowCount = 0;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.#context.logger.info("[multiplexer]", `Multi-room stream finished, sent ${packetCount} packets to ${this.#targets.length} device(s)`);
|
|
444
|
+
await Promise.all(this.#targets.map(async (target) => {
|
|
445
|
+
await target.stream.finish();
|
|
446
|
+
}));
|
|
447
|
+
} catch (err) {
|
|
448
|
+
for (const target of this.#targets) target.stream.close();
|
|
449
|
+
throw err;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
#sleep(ms) {
|
|
453
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
272
457
|
//#endregion
|
|
273
458
|
//#region ../../node_modules/.bun/@bufbuild+protobuf@2.11.0/node_modules/@bufbuild/protobuf/dist/esm/is-message.js
|
|
274
459
|
/**
|
|
@@ -10672,7 +10857,7 @@ function readVariant(buf, offset = 0) {
|
|
|
10672
10857
|
let result = 0;
|
|
10673
10858
|
let shift = 0;
|
|
10674
10859
|
let bytesRead = 0;
|
|
10675
|
-
while (
|
|
10860
|
+
while (offset + bytesRead < buf.length) {
|
|
10676
10861
|
const byte = buf[offset + bytesRead++];
|
|
10677
10862
|
result |= (byte & 127) << shift;
|
|
10678
10863
|
if ((byte & 128) === 0) break;
|
|
@@ -10691,6 +10876,7 @@ function chacha20Decrypt(state, data) {
|
|
|
10691
10876
|
if (offset + 2 > data.length) return false;
|
|
10692
10877
|
const frameLength = data.readUInt16LE(offset);
|
|
10693
10878
|
offset += 2;
|
|
10879
|
+
if (frameLength === 0 || frameLength > 65535) return false;
|
|
10694
10880
|
const end = offset + frameLength + 16;
|
|
10695
10881
|
if (end > data.length) return false;
|
|
10696
10882
|
const ciphertext = data.subarray(offset, offset + frameLength);
|
|
@@ -10848,6 +11034,7 @@ var DataStream = class extends BaseStream {
|
|
|
10848
11034
|
this.on("close", this.#onClose.bind(this));
|
|
10849
11035
|
this.on("data", this.#onData.bind(this));
|
|
10850
11036
|
this.on("error", this.#onError.bind(this));
|
|
11037
|
+
this.#handlers[ProtocolMessage_Type.KEYBOARD_MESSAGE] = [keyboardMessage, this.#onKeyboardMessage.bind(this)];
|
|
10851
11038
|
this.#handlers[ProtocolMessage_Type.DEVICE_INFO_MESSAGE] = [deviceInfoMessage, this.#onDeviceInfoMessage.bind(this)];
|
|
10852
11039
|
this.#handlers[ProtocolMessage_Type.DEVICE_INFO_UPDATE_MESSAGE] = [deviceInfoMessage, this.#onDeviceInfoUpdateMessage.bind(this)];
|
|
10853
11040
|
this.#handlers[ProtocolMessage_Type.ORIGIN_CLIENT_PROPERTIES_MESSAGE] = [originClientPropertiesMessage, this.#onOriginClientPropertiesMessage.bind(this)];
|
|
@@ -10879,7 +11066,7 @@ var DataStream = class extends BaseStream {
|
|
|
10879
11066
|
return new Promise((resolve, reject) => {
|
|
10880
11067
|
const timer = setTimeout(() => {
|
|
10881
11068
|
this.#outstanding.delete(identifier);
|
|
10882
|
-
reject(
|
|
11069
|
+
reject(new TimeoutError(`Exchange timed out for ${identifier}`));
|
|
10883
11070
|
}, timeout);
|
|
10884
11071
|
this.#outstanding.set(identifier, {
|
|
10885
11072
|
resolve,
|
|
@@ -10937,7 +11124,7 @@ var DataStream = class extends BaseStream {
|
|
|
10937
11124
|
this.#buffer = Buffer.alloc(0);
|
|
10938
11125
|
for (const [id, req] of this.#outstanding) {
|
|
10939
11126
|
clearTimeout(req.timer);
|
|
10940
|
-
req.reject(
|
|
11127
|
+
req.reject(new ConnectionClosedError());
|
|
10941
11128
|
}
|
|
10942
11129
|
this.#outstanding.clear();
|
|
10943
11130
|
}
|
|
@@ -11010,6 +11197,10 @@ var DataStream = class extends BaseStream {
|
|
|
11010
11197
|
handler(getExtension$1(message, extension));
|
|
11011
11198
|
} else if (message.type !== ProtocolMessage_Type.UNKNOWN_MESSAGE) this.context.logger.warn("[data]", `Unknown message type ${message.type}.`);
|
|
11012
11199
|
}
|
|
11200
|
+
#onKeyboardMessage(message) {
|
|
11201
|
+
this.context.logger.info("[data]", "Keyboard message", message);
|
|
11202
|
+
this.emit("keyboard", message);
|
|
11203
|
+
}
|
|
11013
11204
|
#onDeviceInfoMessage(message) {
|
|
11014
11205
|
this.context.logger.info("[data]", "Connected to device", message.name);
|
|
11015
11206
|
this.emit("deviceInfo", message);
|
|
@@ -11217,7 +11408,7 @@ var Pairing = class {
|
|
|
11217
11408
|
"Content-Type": "application/octet-stream",
|
|
11218
11409
|
"X-Apple-HKP": this.#hkp.toString()
|
|
11219
11410
|
});
|
|
11220
|
-
if (response.status !== 200) throw new
|
|
11411
|
+
if (response.status !== 200) throw new PairingError(`Cannot start pairing session. ${response.status} ${response.statusText} ${await response.text()}`);
|
|
11221
11412
|
}
|
|
11222
11413
|
async #request(_, data) {
|
|
11223
11414
|
const response = await this.#controlStream.post("/pair-setup", data, {
|
|
@@ -11272,6 +11463,10 @@ var Verify = class {
|
|
|
11272
11463
|
|
|
11273
11464
|
//#endregion
|
|
11274
11465
|
//#region src/protocol.ts
|
|
11466
|
+
const FEEDBACK_INTERVAL = 2e3;
|
|
11467
|
+
const PLAY_RETRIES = 3;
|
|
11468
|
+
const PLAYBACK_POLL_INTERVAL = 1e3;
|
|
11469
|
+
const PLAYBACK_IDLE_THRESHOLD = 5;
|
|
11275
11470
|
var Protocol = class {
|
|
11276
11471
|
get context() {
|
|
11277
11472
|
return this.#context;
|
|
@@ -11309,6 +11504,7 @@ var Protocol = class {
|
|
|
11309
11504
|
#audioStream;
|
|
11310
11505
|
#dataStream;
|
|
11311
11506
|
#eventStream;
|
|
11507
|
+
#playUrlFeedbackInterval;
|
|
11312
11508
|
#timingServer;
|
|
11313
11509
|
constructor(discoveryResult) {
|
|
11314
11510
|
this.#context = new Context(discoveryResult.id);
|
|
@@ -11348,6 +11544,7 @@ var Protocol = class {
|
|
|
11348
11544
|
} catch (err) {
|
|
11349
11545
|
this.#context.logger.warn("[protocol]", "Error destroying control stream", err);
|
|
11350
11546
|
}
|
|
11547
|
+
this.#stopPlayUrlFeedback();
|
|
11351
11548
|
this.#audioStream = void 0;
|
|
11352
11549
|
this.#dataStream = void 0;
|
|
11353
11550
|
this.#eventStream = void 0;
|
|
@@ -11369,7 +11566,7 @@ var Protocol = class {
|
|
|
11369
11566
|
}] });
|
|
11370
11567
|
if (response.status !== 200) {
|
|
11371
11568
|
this.context.logger.error("[protocol]", "Failed to setup data stream.", response.status, response.statusText, await response.text());
|
|
11372
|
-
throw new
|
|
11569
|
+
throw new SetupError("Failed to setup data stream.");
|
|
11373
11570
|
}
|
|
11374
11571
|
const dataPort = Plist.parse(await response.arrayBuffer()).streams[0].dataPort & 65535;
|
|
11375
11572
|
this.context.logger.net("[protocol]", `Connecting to data stream on port ${dataPort}...`);
|
|
@@ -11399,10 +11596,11 @@ var Protocol = class {
|
|
|
11399
11596
|
const response = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, body);
|
|
11400
11597
|
if (response.status !== 200) {
|
|
11401
11598
|
this.context.logger.error("[protocol]", "Failed to setup event stream.", response.status, response.statusText, await response.text());
|
|
11402
|
-
throw new
|
|
11599
|
+
throw new SetupError("Failed to setup event stream.");
|
|
11403
11600
|
}
|
|
11404
11601
|
const eventPort = Plist.parse(await response.arrayBuffer()).eventPort & 65535;
|
|
11405
11602
|
this.context.logger.net("[protocol]", `Connecting to event stream on port ${eventPort}...`);
|
|
11603
|
+
this.#eventStream?.destroy();
|
|
11406
11604
|
this.#eventStream = new EventStream(this.#context, this.#controlStream.address, eventPort);
|
|
11407
11605
|
this.#eventStream.setup(sharedSecret);
|
|
11408
11606
|
await this.#eventStream.connect();
|
|
@@ -11437,10 +11635,11 @@ var Protocol = class {
|
|
|
11437
11635
|
const response = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, body);
|
|
11438
11636
|
if (response.status !== 200) {
|
|
11439
11637
|
this.context.logger.error("[protocol]", "Failed to setup event stream.", response.status, response.statusText, await response.text());
|
|
11440
|
-
throw new
|
|
11638
|
+
throw new SetupError("Failed to setup event stream.");
|
|
11441
11639
|
}
|
|
11442
11640
|
const eventPort = Plist.parse(await response.arrayBuffer()).eventPort & 65535;
|
|
11443
11641
|
this.context.logger.net("[protocol]", `Connecting to event stream on port ${eventPort}...`);
|
|
11642
|
+
this.#eventStream?.destroy();
|
|
11444
11643
|
this.#eventStream = new EventStream(this.#context, this.#controlStream.address, eventPort);
|
|
11445
11644
|
this.#eventStream.setup(sharedSecret);
|
|
11446
11645
|
await this.#eventStream.connect();
|
|
@@ -11467,28 +11666,46 @@ var Protocol = class {
|
|
|
11467
11666
|
setupBody.timingProtocol = "NTP";
|
|
11468
11667
|
} else setupBody.timingProtocol = "None";
|
|
11469
11668
|
const setupResponse = await this.#controlStream.setup(`/${this.#controlStream.sessionId}`, setupBody);
|
|
11470
|
-
if (setupResponse.status !== 200) throw new
|
|
11669
|
+
if (setupResponse.status !== 200) throw new SetupError(`Failed to setup for playback: ${setupResponse.status}`);
|
|
11471
11670
|
const eventPort = Plist.parse(await setupResponse.arrayBuffer()).eventPort & 65535;
|
|
11671
|
+
this.#eventStream?.destroy();
|
|
11472
11672
|
this.#eventStream = new EventStream(this.#context, this.#controlStream.address, eventPort);
|
|
11473
11673
|
this.#eventStream.setup(sharedSecret);
|
|
11474
11674
|
await this.#eventStream.connect();
|
|
11675
|
+
this.#startPlayUrlFeedback();
|
|
11475
11676
|
await this.#controlStream.record(`/${this.#controlStream.sessionId}`);
|
|
11476
|
-
|
|
11477
|
-
|
|
11478
|
-
"
|
|
11479
|
-
|
|
11480
|
-
|
|
11481
|
-
|
|
11482
|
-
|
|
11483
|
-
|
|
11484
|
-
|
|
11485
|
-
|
|
11486
|
-
|
|
11487
|
-
|
|
11488
|
-
|
|
11489
|
-
|
|
11490
|
-
|
|
11491
|
-
|
|
11677
|
+
let lastStatus = 0;
|
|
11678
|
+
for (let retry = 0; retry < PLAY_RETRIES; retry++) {
|
|
11679
|
+
lastStatus = (await this.#controlStream.post("/play", {
|
|
11680
|
+
"Content-Location": url,
|
|
11681
|
+
"Start-Position-Seconds": position,
|
|
11682
|
+
uuid: this.#sessionUUID.toUpperCase(),
|
|
11683
|
+
streamType: 1,
|
|
11684
|
+
mediaType: "file",
|
|
11685
|
+
volume: 1,
|
|
11686
|
+
rate: 1,
|
|
11687
|
+
clientBundleID: "com.basmilius.apple-protocols",
|
|
11688
|
+
clientProcName: "apple-protocols",
|
|
11689
|
+
osBuildVersion: "18C66",
|
|
11690
|
+
model: "iPhone16,2",
|
|
11691
|
+
SenderMACAddress: getMacAddress().toUpperCase()
|
|
11692
|
+
})).status;
|
|
11693
|
+
this.context.logger.info("[protocol]", `play_url response: ${lastStatus} (attempt ${retry + 1}/${PLAY_RETRIES})`);
|
|
11694
|
+
if (lastStatus === 200) break;
|
|
11695
|
+
if (lastStatus === 500) {
|
|
11696
|
+
this.context.logger.warn("[protocol]", "play_url returned 500, retrying...");
|
|
11697
|
+
await waitFor(1e3);
|
|
11698
|
+
continue;
|
|
11699
|
+
}
|
|
11700
|
+
if (lastStatus >= 400) {
|
|
11701
|
+
this.#stopPlayUrlFeedback();
|
|
11702
|
+
throw new PlaybackError(`Failed to play URL: ${lastStatus}`);
|
|
11703
|
+
}
|
|
11704
|
+
}
|
|
11705
|
+
if (lastStatus !== 200) {
|
|
11706
|
+
this.#stopPlayUrlFeedback();
|
|
11707
|
+
throw new PlaybackError(`Failed to play URL after ${PLAY_RETRIES} retries: ${lastStatus}`);
|
|
11708
|
+
}
|
|
11492
11709
|
await this.#putProperty("isInterestedInDateRange", { value: true });
|
|
11493
11710
|
await this.#putProperty("actionAtItemEnd", { value: 0 });
|
|
11494
11711
|
await this.#controlStream.post("/rate?value=1.000000");
|
|
@@ -11505,6 +11722,63 @@ var Protocol = class {
|
|
|
11505
11722
|
timescale: 0
|
|
11506
11723
|
} });
|
|
11507
11724
|
}
|
|
11725
|
+
async getPlaybackInfo() {
|
|
11726
|
+
try {
|
|
11727
|
+
const response = await this.#controlStream.get("/playback-info");
|
|
11728
|
+
if (!response.ok) return null;
|
|
11729
|
+
const body = await response.arrayBuffer();
|
|
11730
|
+
if (body.byteLength === 0) return {};
|
|
11731
|
+
return Plist.parse(body);
|
|
11732
|
+
} catch {
|
|
11733
|
+
return null;
|
|
11734
|
+
}
|
|
11735
|
+
}
|
|
11736
|
+
async waitForPlaybackEnd() {
|
|
11737
|
+
let playbackStarted = false;
|
|
11738
|
+
let idleCount = 0;
|
|
11739
|
+
while (true) {
|
|
11740
|
+
const info = await this.getPlaybackInfo();
|
|
11741
|
+
if (!info) {
|
|
11742
|
+
this.context.logger.debug("[protocol]", "Connection lost, assuming playback stopped.");
|
|
11743
|
+
break;
|
|
11744
|
+
}
|
|
11745
|
+
if (info.error) {
|
|
11746
|
+
this.#stopPlayUrlFeedback();
|
|
11747
|
+
throw new PlaybackError(`Playback error: ${info.error.code} (${info.error.domain})`);
|
|
11748
|
+
}
|
|
11749
|
+
if (info.duration !== void 0) {
|
|
11750
|
+
playbackStarted = true;
|
|
11751
|
+
idleCount = 0;
|
|
11752
|
+
} else if (playbackStarted) {
|
|
11753
|
+
idleCount++;
|
|
11754
|
+
if (idleCount >= PLAYBACK_IDLE_THRESHOLD) {
|
|
11755
|
+
this.context.logger.debug("[protocol]", "Playback ended.");
|
|
11756
|
+
break;
|
|
11757
|
+
}
|
|
11758
|
+
}
|
|
11759
|
+
await waitFor(PLAYBACK_POLL_INTERVAL);
|
|
11760
|
+
}
|
|
11761
|
+
this.#stopPlayUrlFeedback();
|
|
11762
|
+
}
|
|
11763
|
+
stopPlayUrl() {
|
|
11764
|
+
this.#stopPlayUrlFeedback();
|
|
11765
|
+
}
|
|
11766
|
+
#startPlayUrlFeedback() {
|
|
11767
|
+
this.#stopPlayUrlFeedback();
|
|
11768
|
+
this.#playUrlFeedbackInterval = setInterval(async () => {
|
|
11769
|
+
try {
|
|
11770
|
+
await this.feedback();
|
|
11771
|
+
} catch (err) {
|
|
11772
|
+
this.#context.logger.warn("[protocol]", "playUrl feedback error", err);
|
|
11773
|
+
}
|
|
11774
|
+
}, FEEDBACK_INTERVAL);
|
|
11775
|
+
}
|
|
11776
|
+
#stopPlayUrlFeedback() {
|
|
11777
|
+
if (this.#playUrlFeedbackInterval) {
|
|
11778
|
+
clearInterval(this.#playUrlFeedbackInterval);
|
|
11779
|
+
this.#playUrlFeedbackInterval = void 0;
|
|
11780
|
+
}
|
|
11781
|
+
}
|
|
11508
11782
|
async #putProperty(property, body) {
|
|
11509
11783
|
await this.#controlStream.put(`/setProperty?${property}`, body);
|
|
11510
11784
|
}
|
|
@@ -11525,6 +11799,7 @@ var dataStreamMessages_exports = /* @__PURE__ */ __exportAll({
|
|
|
11525
11799
|
configureConnection: () => configureConnection,
|
|
11526
11800
|
deviceInfo: () => deviceInfo,
|
|
11527
11801
|
getExtension: () => getExtension,
|
|
11802
|
+
getKeyboardSession: () => getKeyboardSession,
|
|
11528
11803
|
getState: () => getState,
|
|
11529
11804
|
getVolume: () => getVolume,
|
|
11530
11805
|
getVolumeMuted: () => getVolumeMuted,
|
|
@@ -11545,6 +11820,7 @@ var dataStreamMessages_exports = /* @__PURE__ */ __exportAll({
|
|
|
11545
11820
|
setReadyState: () => setReadyState,
|
|
11546
11821
|
setVolume: () => setVolume,
|
|
11547
11822
|
setVolumeMuted: () => setVolumeMuted,
|
|
11823
|
+
textInput: () => textInput,
|
|
11548
11824
|
wakeDevice: () => wakeDevice
|
|
11549
11825
|
});
|
|
11550
11826
|
function protocol(type, errorCode = ErrorCode_Enum.NoError) {
|
|
@@ -11593,7 +11869,9 @@ function deviceInfo(pairingId) {
|
|
|
11593
11869
|
supportsExtendedMotion: true,
|
|
11594
11870
|
sharedQueueVersion: 2,
|
|
11595
11871
|
deviceClass: DeviceClass_Enum.iPhone,
|
|
11596
|
-
logicalDeviceCount: 1
|
|
11872
|
+
logicalDeviceCount: 1,
|
|
11873
|
+
clusterType: 0,
|
|
11874
|
+
isClusterAware: true
|
|
11597
11875
|
});
|
|
11598
11876
|
setExtension(protocolMessage, deviceInfoMessage, message);
|
|
11599
11877
|
return [protocolMessage, deviceInfoMessage];
|
|
@@ -11604,7 +11882,10 @@ function modifyOutputContext(addingDevices = [], removingDevices = [], settingDe
|
|
|
11604
11882
|
type: ModifyOutputContextRequestType_Enum.SharedAudioPresentation,
|
|
11605
11883
|
addingDevices,
|
|
11606
11884
|
removingDevices,
|
|
11607
|
-
settingDevices
|
|
11885
|
+
settingDevices,
|
|
11886
|
+
clusterAwareAddingDevices: addingDevices,
|
|
11887
|
+
clusterAwareRemovingDevices: removingDevices,
|
|
11888
|
+
clusterAwareSettingDevices: settingDevices
|
|
11608
11889
|
});
|
|
11609
11890
|
setExtension(protocolMessage, modifyOutputContextRequestMessage, message);
|
|
11610
11891
|
return [protocolMessage, modifyOutputContextRequestMessage];
|
|
@@ -11654,6 +11935,21 @@ function playbackQueueRequest(location, length, artworkWidth = 600, artworkHeigh
|
|
|
11654
11935
|
setExtension(protocolMessage, playbackQueueRequestMessage, message);
|
|
11655
11936
|
return [protocolMessage, playbackQueueRequestMessage];
|
|
11656
11937
|
}
|
|
11938
|
+
function getKeyboardSession() {
|
|
11939
|
+
const protocolMessage = protocol(ProtocolMessage_Type.GET_KEYBOARD_SESSION_MESSAGE);
|
|
11940
|
+
setExtension(protocolMessage, getKeyboardSessionMessage, "");
|
|
11941
|
+
return [protocolMessage, getKeyboardSessionMessage];
|
|
11942
|
+
}
|
|
11943
|
+
function textInput(text, actionType) {
|
|
11944
|
+
const protocolMessage = protocol(ProtocolMessage_Type.TEXT_INPUT_MESSAGE);
|
|
11945
|
+
const message = create(TextInputMessageSchema, {
|
|
11946
|
+
timestamp: Date.now() / 1e3,
|
|
11947
|
+
text,
|
|
11948
|
+
actionType
|
|
11949
|
+
});
|
|
11950
|
+
setExtension(protocolMessage, textInputMessage, message);
|
|
11951
|
+
return [protocolMessage, textInputMessage];
|
|
11952
|
+
}
|
|
11657
11953
|
function sendButtonEvent(usagePage, usage, buttonDown) {
|
|
11658
11954
|
const protocolMessage = protocol(ProtocolMessage_Type.SEND_BUTTON_EVENT_MESSAGE);
|
|
11659
11955
|
const message = create(SendButtonEventMessageSchema, {
|
|
@@ -11758,4 +12054,4 @@ function getExtension(message, extension) {
|
|
|
11758
12054
|
}
|
|
11759
12055
|
|
|
11760
12056
|
//#endregion
|
|
11761
|
-
export { AudioStream, ControlStream, DataStream, dataStreamMessages_exports as DataStreamMessage, EventStream, Pairing, proto_exports as Proto, Protocol, Verify };
|
|
12057
|
+
export { AudioMultiplexer, AudioStream, ControlStream, DataStream, dataStreamMessages_exports as DataStreamMessage, EventStream, Pairing, proto_exports as Proto, Protocol, Verify };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basmilius/apple-airplay",
|
|
3
3
|
"description": "Implementation of Apple's AirPlay2 in Node.js.",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@basmilius/apple-common": "0.9.
|
|
52
|
-
"@basmilius/apple-encoding": "0.9.
|
|
53
|
-
"@basmilius/apple-encryption": "0.9.
|
|
54
|
-
"@basmilius/apple-rtsp": "0.9.
|
|
51
|
+
"@basmilius/apple-common": "0.9.19",
|
|
52
|
+
"@basmilius/apple-encoding": "0.9.19",
|
|
53
|
+
"@basmilius/apple-encryption": "0.9.19",
|
|
54
|
+
"@basmilius/apple-rtsp": "0.9.19"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@bufbuild/buf": "^1.66.1",
|