@lox-audioserver/node-slimproto 0.1.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/.devcontainer/devcontainer.json +22 -0
- package/.github/workflows/publish.yml +33 -0
- package/README.md +51 -0
- package/dist/client.d.ts +78 -0
- package/dist/client.js +652 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/discovery.d.ts +10 -0
- package/dist/discovery.js +104 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/models.d.ts +119 -0
- package/dist/models.js +168 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +109 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +98 -0
- package/dist/volume.d.ts +15 -0
- package/dist/volume.js +59 -0
- package/package.json +28 -0
- package/src/client.ts +780 -0
- package/src/constants.ts +7 -0
- package/src/discovery.ts +121 -0
- package/src/errors.ts +6 -0
- package/src/index.ts +8 -0
- package/src/models.ts +202 -0
- package/src/server.ts +142 -0
- package/src/util.ts +104 -0
- package/src/volume.ts +64 -0
- package/tsconfig.json +15 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { ButtonCode, CODEC_MAPPING, DEVICE_TYPE, EventType, FORMAT_BYTE, PCM_SAMPLE_RATE, PCM_SAMPLE_SIZE, PlayerState, RemoteCode, TransitionType, } from './models.js';
|
|
2
|
+
import { FALLBACK_CODECS, FALLBACK_MODEL, FALLBACK_SAMPLE_RATE, FALLLBACK_FIRMWARE, HEARTBEAT_INTERVAL, } from './constants.js';
|
|
3
|
+
import { UnsupportedContentType } from './errors.js';
|
|
4
|
+
import { SlimProtoVolume } from './volume.js';
|
|
5
|
+
import { ipToInt, lookupHost, parseCapabilities, parseHeaders, parseStatus } from './util.js';
|
|
6
|
+
export class SlimClient {
|
|
7
|
+
socket;
|
|
8
|
+
callback;
|
|
9
|
+
remoteAddress;
|
|
10
|
+
remotePort;
|
|
11
|
+
volumeControl = new SlimProtoVolume();
|
|
12
|
+
buffer = Buffer.alloc(0);
|
|
13
|
+
_connected = false;
|
|
14
|
+
_playerId = '';
|
|
15
|
+
_deviceType = '';
|
|
16
|
+
capabilities = {};
|
|
17
|
+
_deviceName = '';
|
|
18
|
+
_powered = false;
|
|
19
|
+
_muted = false;
|
|
20
|
+
_state = PlayerState.STOPPED;
|
|
21
|
+
_jiffies = 0;
|
|
22
|
+
_lastTimestamp = 0;
|
|
23
|
+
_elapsedMs = 0;
|
|
24
|
+
_currentMedia = null;
|
|
25
|
+
_bufferingMedia = null;
|
|
26
|
+
_nextMedia = null;
|
|
27
|
+
_autoPlay = false;
|
|
28
|
+
_heartbeatTimer = null;
|
|
29
|
+
_heartbeatId = 0;
|
|
30
|
+
constructor(socket, callback) {
|
|
31
|
+
this.socket = socket;
|
|
32
|
+
this.callback = callback;
|
|
33
|
+
this.remoteAddress = socket.remoteAddress ?? undefined;
|
|
34
|
+
this.remotePort = socket.remotePort ?? undefined;
|
|
35
|
+
this.socket.on('data', (data) => this.onData(data));
|
|
36
|
+
this.socket.on('close', () => this.handleDisconnect());
|
|
37
|
+
this.socket.on('error', () => this.handleDisconnect());
|
|
38
|
+
}
|
|
39
|
+
disconnect() {
|
|
40
|
+
if (this.socket.destroyed) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.socket.destroy();
|
|
44
|
+
this.handleDisconnect();
|
|
45
|
+
}
|
|
46
|
+
get connected() {
|
|
47
|
+
return this._connected;
|
|
48
|
+
}
|
|
49
|
+
get playerId() {
|
|
50
|
+
return this._playerId;
|
|
51
|
+
}
|
|
52
|
+
get deviceType() {
|
|
53
|
+
return this._deviceType;
|
|
54
|
+
}
|
|
55
|
+
get name() {
|
|
56
|
+
if (this._deviceName)
|
|
57
|
+
return this._deviceName;
|
|
58
|
+
return `${this._deviceType || FALLBACK_MODEL}: ${this._playerId}`;
|
|
59
|
+
}
|
|
60
|
+
get deviceAddress() {
|
|
61
|
+
return this.remoteAddress;
|
|
62
|
+
}
|
|
63
|
+
get devicePort() {
|
|
64
|
+
return this.remotePort;
|
|
65
|
+
}
|
|
66
|
+
get state() {
|
|
67
|
+
return this._state;
|
|
68
|
+
}
|
|
69
|
+
get currentMedia() {
|
|
70
|
+
return this._currentMedia;
|
|
71
|
+
}
|
|
72
|
+
get supportedCodecs() {
|
|
73
|
+
const codecs = this.capabilities.SupportedCodecs;
|
|
74
|
+
return Array.isArray(codecs) ? codecs : FALLBACK_CODECS;
|
|
75
|
+
}
|
|
76
|
+
get maxSampleRate() {
|
|
77
|
+
const rate = this.capabilities.MaxSampleRate;
|
|
78
|
+
return typeof rate === 'number' ? rate : FALLBACK_SAMPLE_RATE;
|
|
79
|
+
}
|
|
80
|
+
get firmware() {
|
|
81
|
+
const firmware = this.capabilities.Firmware;
|
|
82
|
+
return typeof firmware === 'string' ? firmware : FALLLBACK_FIRMWARE;
|
|
83
|
+
}
|
|
84
|
+
get volumeLevel() {
|
|
85
|
+
return this.volumeControl.volume;
|
|
86
|
+
}
|
|
87
|
+
get elapsedMilliseconds() {
|
|
88
|
+
if (this._state !== PlayerState.PLAYING) {
|
|
89
|
+
return this._elapsedMs;
|
|
90
|
+
}
|
|
91
|
+
return this._elapsedMs + Math.round(Date.now() - this._lastTimestamp);
|
|
92
|
+
}
|
|
93
|
+
get jiffies() {
|
|
94
|
+
return this._jiffies;
|
|
95
|
+
}
|
|
96
|
+
get lastHeartbeatAt() {
|
|
97
|
+
return this._lastTimestamp || null;
|
|
98
|
+
}
|
|
99
|
+
async stop() {
|
|
100
|
+
if (this._state === PlayerState.STOPPED)
|
|
101
|
+
return;
|
|
102
|
+
await this.sendStrm({ command: 'q', flags: 0 });
|
|
103
|
+
this._state = PlayerState.STOPPED;
|
|
104
|
+
this.signalUpdate();
|
|
105
|
+
}
|
|
106
|
+
async play() {
|
|
107
|
+
if (this._state !== PlayerState.PAUSED)
|
|
108
|
+
return;
|
|
109
|
+
await this.sendStrm({ command: 'u', flags: 0 });
|
|
110
|
+
this._state = PlayerState.PLAYING;
|
|
111
|
+
this.signalUpdate();
|
|
112
|
+
}
|
|
113
|
+
async pause() {
|
|
114
|
+
if (this._state !== PlayerState.PLAYING && this._state !== PlayerState.BUFFERING)
|
|
115
|
+
return;
|
|
116
|
+
await this.sendStrm({ command: 'p' });
|
|
117
|
+
this._state = PlayerState.PAUSED;
|
|
118
|
+
this.signalUpdate();
|
|
119
|
+
}
|
|
120
|
+
async pauseFor(millis) {
|
|
121
|
+
const duration = Math.max(0, Math.round(millis));
|
|
122
|
+
if (!duration)
|
|
123
|
+
return;
|
|
124
|
+
await this.sendStrm({ command: 'p', replayGain: duration });
|
|
125
|
+
}
|
|
126
|
+
async skipOver(millis) {
|
|
127
|
+
const duration = Math.max(0, Math.round(millis));
|
|
128
|
+
if (!duration)
|
|
129
|
+
return;
|
|
130
|
+
await this.sendStrm({ command: 'a', replayGain: duration });
|
|
131
|
+
}
|
|
132
|
+
async unpauseAt(timestamp) {
|
|
133
|
+
const ts = Math.max(0, Math.round(timestamp));
|
|
134
|
+
await this.sendStrm({ command: 'u', replayGain: ts });
|
|
135
|
+
}
|
|
136
|
+
async power(powered = true) {
|
|
137
|
+
if (this._powered === powered)
|
|
138
|
+
return;
|
|
139
|
+
if (!powered) {
|
|
140
|
+
await this.stop();
|
|
141
|
+
}
|
|
142
|
+
const powerInt = powered ? 1 : 0;
|
|
143
|
+
await this.sendFrame('aude', Buffer.from([powerInt, 1]));
|
|
144
|
+
this._powered = powered;
|
|
145
|
+
this.signalUpdate();
|
|
146
|
+
}
|
|
147
|
+
async volumeSet(volumeLevel) {
|
|
148
|
+
if (volumeLevel === this.volumeControl.volume)
|
|
149
|
+
return;
|
|
150
|
+
this.volumeControl.volume = volumeLevel;
|
|
151
|
+
await this.sendAudg();
|
|
152
|
+
this.signalUpdate();
|
|
153
|
+
}
|
|
154
|
+
async volumeUp() {
|
|
155
|
+
this.volumeControl.increment();
|
|
156
|
+
await this.sendAudg();
|
|
157
|
+
this.signalUpdate();
|
|
158
|
+
}
|
|
159
|
+
async volumeDown() {
|
|
160
|
+
this.volumeControl.decrement();
|
|
161
|
+
await this.sendAudg();
|
|
162
|
+
this.signalUpdate();
|
|
163
|
+
}
|
|
164
|
+
async mute(muted = false) {
|
|
165
|
+
if (this._muted === muted)
|
|
166
|
+
return;
|
|
167
|
+
const mutedInt = muted ? 0 : 1;
|
|
168
|
+
await this.sendFrame('aude', Buffer.from([mutedInt, 0]));
|
|
169
|
+
this._muted = muted;
|
|
170
|
+
this.signalUpdate();
|
|
171
|
+
}
|
|
172
|
+
async playUrl(url, mimeType, metadata, transition = TransitionType.NONE, transitionDuration = 0, enqueue = false, autostart = true, sendFlush = true) {
|
|
173
|
+
if (!url.startsWith('http')) {
|
|
174
|
+
throw new UnsupportedContentType(`Invalid URL: ${url}`);
|
|
175
|
+
}
|
|
176
|
+
if (sendFlush) {
|
|
177
|
+
await this.sendStrm({ command: 'f', autostart: '0' });
|
|
178
|
+
await this.sendStrm({ command: 'q', flags: 0 });
|
|
179
|
+
}
|
|
180
|
+
const mediaDetails = {
|
|
181
|
+
url,
|
|
182
|
+
mimeType: mimeType ?? undefined,
|
|
183
|
+
metadata: metadata ?? {},
|
|
184
|
+
transition,
|
|
185
|
+
transitionDuration,
|
|
186
|
+
};
|
|
187
|
+
if (enqueue) {
|
|
188
|
+
this._nextMedia = mediaDetails;
|
|
189
|
+
this.signalUpdate();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this._bufferingMedia = mediaDetails;
|
|
193
|
+
this.signalUpdate();
|
|
194
|
+
if (!this._powered) {
|
|
195
|
+
await this.power(true);
|
|
196
|
+
}
|
|
197
|
+
this._state = PlayerState.BUFFERING;
|
|
198
|
+
const parsed = new URL(url);
|
|
199
|
+
let scheme = parsed.protocol.replace(':', '');
|
|
200
|
+
let host = parsed.hostname;
|
|
201
|
+
let port = parsed.port ? Number(parsed.port) : scheme === 'https' ? 443 : 80;
|
|
202
|
+
let path = parsed.pathname;
|
|
203
|
+
if (parsed.search) {
|
|
204
|
+
path += parsed.search;
|
|
205
|
+
}
|
|
206
|
+
const canHttpsRaw = String(this.capabilities.CanHTTPS ?? '').toLowerCase();
|
|
207
|
+
const canHttps = canHttpsRaw === '1' || canHttpsRaw === 'true' || canHttpsRaw === 'yes';
|
|
208
|
+
if (scheme === 'https' && !canHttps) {
|
|
209
|
+
url = url.replace(/^https:/i, 'http:');
|
|
210
|
+
scheme = 'http';
|
|
211
|
+
port = 80;
|
|
212
|
+
}
|
|
213
|
+
if (!mimeType) {
|
|
214
|
+
for (const ext of [url.slice(-3), url.split('.').pop() ?? '']) {
|
|
215
|
+
const candidate = `audio/${ext}`;
|
|
216
|
+
if (CODEC_MAPPING[candidate]) {
|
|
217
|
+
mimeType = candidate;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const codecDetails = mimeType ? this.parseCodc(mimeType) : Buffer.from('?????');
|
|
223
|
+
const ipAddress = await lookupHost(host);
|
|
224
|
+
const hostHeader = port === 80 || port === 443 ? host : `${host}:${port}`;
|
|
225
|
+
const httpreq = Buffer.from(`GET ${path} HTTP/1.0\r\n` +
|
|
226
|
+
`Host: ${hostHeader}\r\n` +
|
|
227
|
+
'Connection: close\r\n' +
|
|
228
|
+
'Accept: */*\r\n' +
|
|
229
|
+
'Cache-Control: no-cache\r\n' +
|
|
230
|
+
'User-Agent: VLC/3.0.9 LibVLC/3.0.9\r\n' +
|
|
231
|
+
'Range: bytes=0-\r\n' +
|
|
232
|
+
'\r\n', 'ascii');
|
|
233
|
+
this._autoPlay = autostart;
|
|
234
|
+
await this.sendStrm({
|
|
235
|
+
command: 's',
|
|
236
|
+
codecDetails,
|
|
237
|
+
autostart: autostart ? '3' : '0',
|
|
238
|
+
serverPort: port,
|
|
239
|
+
serverIp: ipToInt(ipAddress),
|
|
240
|
+
threshold: 20,
|
|
241
|
+
outputThreshold: 5,
|
|
242
|
+
transDuration: transitionDuration,
|
|
243
|
+
transType: transition,
|
|
244
|
+
flags: scheme === 'https' ? 0x20 : 0x00,
|
|
245
|
+
httpreq,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async sendAudg() {
|
|
249
|
+
const oldGain = this.volumeControl.oldGain();
|
|
250
|
+
const newGain = this.volumeControl.newGain();
|
|
251
|
+
const payload = Buffer.alloc(18);
|
|
252
|
+
payload.writeUInt32BE(oldGain, 0);
|
|
253
|
+
payload.writeUInt32BE(oldGain, 4);
|
|
254
|
+
payload.writeUInt8(1, 8);
|
|
255
|
+
payload.writeUInt8(255, 9);
|
|
256
|
+
payload.writeUInt32BE(newGain, 10);
|
|
257
|
+
payload.writeUInt32BE(newGain, 14);
|
|
258
|
+
await this.sendFrame('audg', payload);
|
|
259
|
+
}
|
|
260
|
+
async sendFrame(command, data) {
|
|
261
|
+
if (this.socket.destroyed) {
|
|
262
|
+
this.handleDisconnect();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const cmd = Buffer.from(command, 'ascii');
|
|
266
|
+
const length = data.length + 4;
|
|
267
|
+
const header = Buffer.alloc(2);
|
|
268
|
+
header.writeUInt16BE(length, 0);
|
|
269
|
+
const packet = Buffer.concat([header, cmd, data]);
|
|
270
|
+
await new Promise((resolve) => {
|
|
271
|
+
this.socket.write(packet, () => resolve());
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async sendStrm(options) {
|
|
275
|
+
const data = Buffer.alloc(24);
|
|
276
|
+
let offset = 0;
|
|
277
|
+
const command = options.command ?? 'q';
|
|
278
|
+
const autostart = options.autostart ?? '0';
|
|
279
|
+
const codecDetails = options.codecDetails ?? Buffer.from('p1321', 'ascii');
|
|
280
|
+
const threshold = options.threshold ?? 0;
|
|
281
|
+
const spdif = options.spdif ?? '0';
|
|
282
|
+
const transDuration = options.transDuration ?? 0;
|
|
283
|
+
const transType = options.transType ?? '0';
|
|
284
|
+
const flags = options.flags ?? 0x20;
|
|
285
|
+
const outputThreshold = options.outputThreshold ?? 0;
|
|
286
|
+
const replayGain = options.replayGain ?? 0;
|
|
287
|
+
const serverPort = options.serverPort ?? 0;
|
|
288
|
+
const serverIp = options.serverIp ?? 0;
|
|
289
|
+
data.write(command, offset, 'ascii');
|
|
290
|
+
offset += 1;
|
|
291
|
+
data.write(autostart, offset, 'ascii');
|
|
292
|
+
offset += 1;
|
|
293
|
+
codecDetails.copy(data, offset, 0, 5);
|
|
294
|
+
offset += 5;
|
|
295
|
+
data.writeUInt8(threshold, offset);
|
|
296
|
+
offset += 1;
|
|
297
|
+
data.write(spdif, offset, 'ascii');
|
|
298
|
+
offset += 1;
|
|
299
|
+
data.writeUInt8(transDuration, offset);
|
|
300
|
+
offset += 1;
|
|
301
|
+
data.write(transType, offset, 'ascii');
|
|
302
|
+
offset += 1;
|
|
303
|
+
data.writeUInt8(flags, offset);
|
|
304
|
+
offset += 1;
|
|
305
|
+
data.writeUInt8(outputThreshold, offset);
|
|
306
|
+
offset += 1;
|
|
307
|
+
data.writeUInt8(0, offset);
|
|
308
|
+
offset += 1;
|
|
309
|
+
data.writeUInt32BE(replayGain, offset);
|
|
310
|
+
offset += 4;
|
|
311
|
+
data.writeUInt16BE(serverPort, offset);
|
|
312
|
+
offset += 2;
|
|
313
|
+
data.writeUInt32BE(serverIp, offset);
|
|
314
|
+
const payload = options.httpreq ? Buffer.concat([data, options.httpreq]) : data;
|
|
315
|
+
await this.sendFrame('strm', payload);
|
|
316
|
+
}
|
|
317
|
+
handleDisconnect() {
|
|
318
|
+
if (!this._connected)
|
|
319
|
+
return;
|
|
320
|
+
this._connected = false;
|
|
321
|
+
if (this._heartbeatTimer) {
|
|
322
|
+
clearInterval(this._heartbeatTimer);
|
|
323
|
+
this._heartbeatTimer = null;
|
|
324
|
+
}
|
|
325
|
+
this.signalEvent(EventType.PLAYER_DISCONNECTED);
|
|
326
|
+
}
|
|
327
|
+
onData(data) {
|
|
328
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
329
|
+
while (this.buffer.length >= 8) {
|
|
330
|
+
const operation = this.buffer.subarray(0, 4).toString('ascii');
|
|
331
|
+
const length = this.buffer.readUInt32BE(4);
|
|
332
|
+
const packetLength = 8 + length;
|
|
333
|
+
if (this.buffer.length < packetLength) {
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
const payload = this.buffer.subarray(8, packetLength);
|
|
337
|
+
this.buffer = this.buffer.subarray(packetLength);
|
|
338
|
+
const op = operation.replace(/!/g, '').trim().toLowerCase();
|
|
339
|
+
if (!op) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (op === 'bye') {
|
|
343
|
+
this.handleDisconnect();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
switch (op) {
|
|
347
|
+
case 'helo':
|
|
348
|
+
void this.processHelo(payload);
|
|
349
|
+
break;
|
|
350
|
+
case 'stat':
|
|
351
|
+
void this.processStat(payload);
|
|
352
|
+
break;
|
|
353
|
+
case 'resp':
|
|
354
|
+
void this.processResp(payload);
|
|
355
|
+
break;
|
|
356
|
+
case 'setd':
|
|
357
|
+
this.processSetd(payload);
|
|
358
|
+
break;
|
|
359
|
+
case 'butn':
|
|
360
|
+
this.processButn(payload);
|
|
361
|
+
break;
|
|
362
|
+
case 'ir':
|
|
363
|
+
this.processIr(payload);
|
|
364
|
+
break;
|
|
365
|
+
case 'knob':
|
|
366
|
+
this.processKnob(payload);
|
|
367
|
+
break;
|
|
368
|
+
case 'dsco':
|
|
369
|
+
this.processDsco(payload);
|
|
370
|
+
break;
|
|
371
|
+
default:
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async processHelo(data) {
|
|
377
|
+
const devId = data.readUInt8(0);
|
|
378
|
+
const mac = data.subarray(2, 8);
|
|
379
|
+
const deviceMac = Array.from(mac)
|
|
380
|
+
.map((value) => value.toString(16).padStart(2, '0'))
|
|
381
|
+
.join(':');
|
|
382
|
+
this._playerId = deviceMac.toLowerCase();
|
|
383
|
+
this._deviceType = DEVICE_TYPE[devId] ?? 'unknown device';
|
|
384
|
+
this.capabilities = parseCapabilities(data);
|
|
385
|
+
await this.sendFrame('vers', Buffer.from('7.9', 'ascii'));
|
|
386
|
+
await this.sendFrame('setd', Buffer.from([0xfe]));
|
|
387
|
+
await this.sendFrame('setd', Buffer.from([0]));
|
|
388
|
+
await this.power(this._powered);
|
|
389
|
+
await this.volumeSet(this.volumeControl.volume);
|
|
390
|
+
this._connected = true;
|
|
391
|
+
this.startHeartbeat();
|
|
392
|
+
this.signalEvent(EventType.PLAYER_CONNECTED);
|
|
393
|
+
}
|
|
394
|
+
processButn(data) {
|
|
395
|
+
if (data.length < 8)
|
|
396
|
+
return;
|
|
397
|
+
const button = data.readUInt32BE(4);
|
|
398
|
+
if (button === ButtonCode.POWER) {
|
|
399
|
+
void this.togglePower();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (button === ButtonCode.PAUSE) {
|
|
403
|
+
void this.togglePause();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (button === ButtonCode.PLAY) {
|
|
407
|
+
void this.play();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (button === ButtonCode.VOLUME_DOWN) {
|
|
411
|
+
void this.volumeDown();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.signalEvent(EventType.PLAYER_BTN_EVENT, { type: 'butn', button });
|
|
415
|
+
}
|
|
416
|
+
processKnob(data) {
|
|
417
|
+
if (data.length < 9)
|
|
418
|
+
return;
|
|
419
|
+
const position = data.readUInt32BE(4);
|
|
420
|
+
const sync = data.readUInt8(8);
|
|
421
|
+
this.signalEvent(EventType.PLAYER_BTN_EVENT, { type: 'knob', position, sync });
|
|
422
|
+
}
|
|
423
|
+
processIr(data) {
|
|
424
|
+
if (data.length < 8)
|
|
425
|
+
return;
|
|
426
|
+
const code = data.readUInt32BE(4);
|
|
427
|
+
if (code === RemoteCode.POWER) {
|
|
428
|
+
void this.togglePower();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (code === RemoteCode.PAUSE) {
|
|
432
|
+
void this.togglePause();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (code === RemoteCode.PLAY) {
|
|
436
|
+
void this.play();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (code === RemoteCode.VOLUME_DOWN) {
|
|
440
|
+
void this.volumeDown();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (code === RemoteCode.VOLUME_UP) {
|
|
444
|
+
void this.volumeUp();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
this.signalEvent(EventType.PLAYER_BTN_EVENT, { type: 'ir', code });
|
|
448
|
+
}
|
|
449
|
+
processDsco(_data) {
|
|
450
|
+
// stream disconnected; ignore for now
|
|
451
|
+
}
|
|
452
|
+
async processStat(data) {
|
|
453
|
+
if (data.length < 4)
|
|
454
|
+
return;
|
|
455
|
+
const eventBytes = data.subarray(0, 4);
|
|
456
|
+
if (eventBytes.every((value) => value === 0)) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const event = eventBytes.toString('ascii');
|
|
460
|
+
const payload = data.subarray(4);
|
|
461
|
+
switch (event) {
|
|
462
|
+
case 'STMc':
|
|
463
|
+
this._state = PlayerState.BUFFERING;
|
|
464
|
+
this.signalUpdate();
|
|
465
|
+
break;
|
|
466
|
+
case 'STMd':
|
|
467
|
+
if (this._nextMedia) {
|
|
468
|
+
const next = this._nextMedia;
|
|
469
|
+
this._nextMedia = null;
|
|
470
|
+
await this.playUrl(next.url, next.mimeType, next.metadata, next.transition ?? TransitionType.NONE, next.transitionDuration ?? 0, false, true, false);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
this.signalEvent(EventType.PLAYER_DECODER_READY);
|
|
474
|
+
break;
|
|
475
|
+
case 'STMf':
|
|
476
|
+
break;
|
|
477
|
+
case 'STMo':
|
|
478
|
+
if (this._state !== PlayerState.BUFFERING) {
|
|
479
|
+
this._state = PlayerState.BUFFERING;
|
|
480
|
+
if (this._autoPlay) {
|
|
481
|
+
void this.play();
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
this.signalEvent(EventType.PLAYER_OUTPUT_UNDERRUN);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
case 'STMp':
|
|
489
|
+
this._state = PlayerState.PAUSED;
|
|
490
|
+
this.signalUpdate();
|
|
491
|
+
break;
|
|
492
|
+
case 'STMr':
|
|
493
|
+
this._state = PlayerState.PLAYING;
|
|
494
|
+
this.signalUpdate();
|
|
495
|
+
break;
|
|
496
|
+
case 'STMs':
|
|
497
|
+
this._state = PlayerState.PLAYING;
|
|
498
|
+
if (this._bufferingMedia) {
|
|
499
|
+
this._currentMedia = this._bufferingMedia;
|
|
500
|
+
this._bufferingMedia = null;
|
|
501
|
+
}
|
|
502
|
+
this.signalUpdate();
|
|
503
|
+
break;
|
|
504
|
+
case 'STMt':
|
|
505
|
+
this.processStatHeartbeat(payload);
|
|
506
|
+
break;
|
|
507
|
+
case 'STMu':
|
|
508
|
+
this._state = PlayerState.STOPPED;
|
|
509
|
+
this._currentMedia = null;
|
|
510
|
+
this._bufferingMedia = null;
|
|
511
|
+
this._nextMedia = null;
|
|
512
|
+
this.signalUpdate();
|
|
513
|
+
break;
|
|
514
|
+
case 'STMl':
|
|
515
|
+
this._state = PlayerState.BUFFER_READY;
|
|
516
|
+
this.signalEvent(EventType.PLAYER_BUFFER_READY);
|
|
517
|
+
break;
|
|
518
|
+
case 'STMn':
|
|
519
|
+
this.signalEvent(EventType.PLAYER_DECODER_ERROR);
|
|
520
|
+
break;
|
|
521
|
+
case 'AUDe':
|
|
522
|
+
break;
|
|
523
|
+
case 'AUDg':
|
|
524
|
+
break;
|
|
525
|
+
default:
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
processStatHeartbeat(data) {
|
|
530
|
+
if (data.length < 47)
|
|
531
|
+
return;
|
|
532
|
+
const jiffies = data.readUInt32BE(21);
|
|
533
|
+
const elapsedMs = data.readUInt32BE(39);
|
|
534
|
+
this._jiffies = jiffies;
|
|
535
|
+
this._elapsedMs = elapsedMs;
|
|
536
|
+
this._lastTimestamp = Date.now();
|
|
537
|
+
this.signalEvent(EventType.PLAYER_HEARTBEAT);
|
|
538
|
+
}
|
|
539
|
+
async processResp(data) {
|
|
540
|
+
const { statusCode } = parseStatus(data);
|
|
541
|
+
const headers = parseHeaders(data);
|
|
542
|
+
if (headers.location) {
|
|
543
|
+
await this.playUrl(headers.location, this._nextMedia?.mimeType, this._nextMedia?.metadata, this._nextMedia?.transition ?? TransitionType.NONE, this._nextMedia?.transitionDuration ?? 0);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (statusCode > 300) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (headers['content-type']) {
|
|
550
|
+
const codc = this.parseCodc(headers['content-type']);
|
|
551
|
+
await this.sendFrame('codc', codc);
|
|
552
|
+
}
|
|
553
|
+
if (headers['icy-name'] && this._bufferingMedia && !this._bufferingMedia.metadata?.title) {
|
|
554
|
+
if (!this._bufferingMedia.metadata) {
|
|
555
|
+
this._bufferingMedia.metadata = {};
|
|
556
|
+
}
|
|
557
|
+
this._bufferingMedia.metadata.title = headers['icy-name'];
|
|
558
|
+
}
|
|
559
|
+
if (this._autoPlay) {
|
|
560
|
+
await this.sendFrame('cont', Buffer.from('1'));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
processSetd(data) {
|
|
564
|
+
if (data.length < 2)
|
|
565
|
+
return;
|
|
566
|
+
const dataId = data.readUInt8(0);
|
|
567
|
+
if (dataId === 0) {
|
|
568
|
+
this._deviceName = data.subarray(1).toString('utf8').replace(/\0+$/, '');
|
|
569
|
+
this.signalEvent(EventType.PLAYER_NAME_RECEIVED, this._deviceName);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (dataId === 0xfe) {
|
|
573
|
+
const width = data.length >= 5 ? data.readUInt16BE(1) : data.readUInt16BE(1);
|
|
574
|
+
const height = data.length >= 7 ? data.readUInt16BE(3) : 0;
|
|
575
|
+
this.signalEvent(EventType.PLAYER_DISPLAY_RESOLUTION, `${width} x ${height}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
parseCodc(contentType) {
|
|
579
|
+
if (contentType.includes('wav') || contentType.includes('pcm')) {
|
|
580
|
+
const params = contentType.includes(';')
|
|
581
|
+
? Object.fromEntries(contentType
|
|
582
|
+
.replace(/;/g, '&')
|
|
583
|
+
.split('&')
|
|
584
|
+
.map((segment) => segment.trim())
|
|
585
|
+
.filter(Boolean)
|
|
586
|
+
.map((segment) => segment.split('=')))
|
|
587
|
+
: {};
|
|
588
|
+
const sampleRate = Number(params.rate ?? 44100);
|
|
589
|
+
const sampleSize = Number(params.bitrate ?? 16);
|
|
590
|
+
const channels = Number(params.channels ?? 2);
|
|
591
|
+
return Buffer.from([
|
|
592
|
+
'p'.charCodeAt(0),
|
|
593
|
+
PCM_SAMPLE_SIZE[sampleSize]?.[0] ?? '?'.charCodeAt(0),
|
|
594
|
+
PCM_SAMPLE_RATE[sampleRate]?.[0] ?? '?'.charCodeAt(0),
|
|
595
|
+
String(channels).charCodeAt(0),
|
|
596
|
+
'1'.charCodeAt(0),
|
|
597
|
+
]);
|
|
598
|
+
}
|
|
599
|
+
if (!CODEC_MAPPING[contentType]) {
|
|
600
|
+
return Buffer.from('m????');
|
|
601
|
+
}
|
|
602
|
+
const codec = CODEC_MAPPING[contentType];
|
|
603
|
+
if (!this.supportedCodecs.includes(codec)) {
|
|
604
|
+
// best-effort; still try to play
|
|
605
|
+
}
|
|
606
|
+
if (contentType === 'audio/aac' || contentType === 'audio/aacp') {
|
|
607
|
+
return Buffer.from('a2???');
|
|
608
|
+
}
|
|
609
|
+
return Buffer.concat([FORMAT_BYTE[codec] ?? Buffer.from('m'), Buffer.from('????')]);
|
|
610
|
+
}
|
|
611
|
+
signalUpdate() {
|
|
612
|
+
this.signalEvent(EventType.PLAYER_UPDATED);
|
|
613
|
+
}
|
|
614
|
+
signalEvent(eventType, data) {
|
|
615
|
+
try {
|
|
616
|
+
const result = this.callback(this, eventType, data);
|
|
617
|
+
if (result && typeof result.catch === 'function') {
|
|
618
|
+
result.catch(() => undefined);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// ignore
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
startHeartbeat() {
|
|
626
|
+
if (this._heartbeatTimer) {
|
|
627
|
+
clearInterval(this._heartbeatTimer);
|
|
628
|
+
}
|
|
629
|
+
this._heartbeatTimer = setInterval(() => {
|
|
630
|
+
if (!this._connected)
|
|
631
|
+
return;
|
|
632
|
+
this._heartbeatId += 1;
|
|
633
|
+
void this.sendStrm({
|
|
634
|
+
command: 't',
|
|
635
|
+
autostart: '0',
|
|
636
|
+
flags: 0,
|
|
637
|
+
replayGain: this._heartbeatId,
|
|
638
|
+
});
|
|
639
|
+
}, HEARTBEAT_INTERVAL * 1000);
|
|
640
|
+
}
|
|
641
|
+
async togglePower() {
|
|
642
|
+
await this.power(!this._powered);
|
|
643
|
+
}
|
|
644
|
+
async togglePause() {
|
|
645
|
+
if (this._state === PlayerState.PLAYING) {
|
|
646
|
+
await this.pause();
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
await this.play();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const SLIMPROTO_PORT = 3483;
|
|
2
|
+
export declare const FALLBACK_CODECS: string[];
|
|
3
|
+
export declare const FALLBACK_MODEL = "Squeezebox";
|
|
4
|
+
export declare const FALLLBACK_FIRMWARE = "Unknown";
|
|
5
|
+
export declare const FALLBACK_SAMPLE_RATE = 96000;
|
|
6
|
+
export declare const HEARTBEAT_INTERVAL = 5;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import dgram from 'dgram';
|
|
2
|
+
export interface DiscoveryOptions {
|
|
3
|
+
ipAddress: string;
|
|
4
|
+
controlPort: number;
|
|
5
|
+
cliPort?: number | null;
|
|
6
|
+
cliPortJson?: number | null;
|
|
7
|
+
name?: string;
|
|
8
|
+
uuid?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function startDiscovery(opts: DiscoveryOptions): dgram.Socket;
|