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