@rocksky/sdk 0.8.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * RemotePlayer — build a Rocksky-controllable player in a few lines.
3
+ *
4
+ * It speaks the remote-control WebSocket protocol (see remote-ws/PROTOCOL.md):
5
+ * it registers as a device, advertises what you're playing (now-playing, status,
6
+ * queue), and invokes your handlers when a miniplayer sends a command
7
+ * (play/pause/next/previous/seek/enqueue/queue actions). Heartbeat, reconnect,
8
+ * and the device-id handshake are handled for you.
9
+ *
10
+ * ```ts
11
+ * const player = new RemotePlayer({ token, name: "My Player" });
12
+ * player.on("play", () => engine.play());
13
+ * player.on("pause", () => engine.pause());
14
+ * player.on("next", () => engine.next());
15
+ * player.on("seek", (ms) => engine.seek(ms));
16
+ * player.connect();
17
+ * // …then, as your engine plays:
18
+ * player.setNowPlaying({ title, artist, album, albumArt, durationMs, elapsedMs, isPlaying: true });
19
+ * player.setStatus("playing");
20
+ * player.setQueue(items, index);
21
+ * ```
22
+ */
23
+
24
+ /** Default remote-control WebSocket endpoint. */
25
+ export const DEFAULT_REMOTE_WS = "wss://api.rocksky.app/ws";
26
+
27
+ /** Now-playing state you advertise. */
28
+ export interface RemoteNowPlaying {
29
+ title: string;
30
+ artist: string;
31
+ album?: string;
32
+ albumArtist?: string;
33
+ albumArt?: string;
34
+ /** Total track length, ms. */
35
+ durationMs?: number;
36
+ /** Current position, ms. */
37
+ elapsedMs?: number;
38
+ isPlaying?: boolean;
39
+ // The following are filled in by the server on the broadcast a controller
40
+ // receives (a player leaves them unset — the server resolves them from the
41
+ // library). They let a controller UI deep-link and show like state.
42
+ /** The song's `at://` URI. */
43
+ songUri?: string;
44
+ /** The album's `at://` URI. */
45
+ albumUri?: string;
46
+ /** The artist's `at://` URI. */
47
+ artistUri?: string;
48
+ /** Content hash (matches the server's `sha256`). */
49
+ sha256?: string;
50
+ /** Whether the current user has liked this track. */
51
+ liked?: boolean;
52
+ }
53
+
54
+ /** One queue entry. `uploadId` (Rocksky uploads) or `trackId` (Navidrome id). */
55
+ export interface RemoteQueueItem {
56
+ uploadId?: string;
57
+ trackId?: string;
58
+ title: string;
59
+ artist: string;
60
+ album?: string;
61
+ albumArtist?: string;
62
+ albumArt?: string;
63
+ durationMs?: number;
64
+ songUri?: string;
65
+ albumUri?: string;
66
+ trackNumber?: number;
67
+ }
68
+
69
+ /** Payload of an `enqueue` command from a controller (an album or a track). */
70
+ export interface EnqueueCommand {
71
+ tracks: RemoteQueueItem[];
72
+ mode: "now" | "next" | "last";
73
+ shuffle: boolean;
74
+ startIndex: number;
75
+ }
76
+
77
+ /** Handlers for commands a controller sends to this player. */
78
+ export interface RemotePlayerHandlers {
79
+ play(): void;
80
+ pause(): void;
81
+ next(): void;
82
+ previous(): void;
83
+ seek(positionMs: number): void;
84
+ enqueue(cmd: EnqueueCommand): void;
85
+ queueJump(index: number): void;
86
+ queueRemove(index: number): void;
87
+ }
88
+
89
+ type Handler<K extends keyof RemotePlayerHandlers> = RemotePlayerHandlers[K];
90
+
91
+ export interface RemotePlayerOptions {
92
+ /** Rocksky access token, or a getter (read fresh on each (re)connect/send). */
93
+ token: string | (() => string | undefined);
94
+ /** Display name shown in the miniplayer device picker. */
95
+ name: string;
96
+ /** WebSocket endpoint. Defaults to {@link DEFAULT_REMOTE_WS}. */
97
+ url?: string;
98
+ /** Heartbeat interval, ms (default 10000). */
99
+ heartbeatMs?: number;
100
+ /** Reconnect delay after a drop, ms (default 3000). */
101
+ reconnectMs?: number;
102
+ /** Optional debug logger. */
103
+ debug?: (...args: unknown[]) => void;
104
+ }
105
+
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ type Json = any;
108
+
109
+ export class RemotePlayer {
110
+ private ws: WebSocket | null = null;
111
+ private deviceId = "";
112
+ private stopped = false;
113
+ private heartbeat?: ReturnType<typeof setInterval>;
114
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
115
+
116
+ private readonly url: string;
117
+ private readonly heartbeatMs: number;
118
+ private readonly reconnectMs: number;
119
+ private readonly getToken: () => string | undefined;
120
+ private readonly debug: (...args: unknown[]) => void;
121
+
122
+ private handlers: Partial<RemotePlayerHandlers> = {};
123
+
124
+ // Last state, re-sent after a reconnect so controllers resync instantly.
125
+ private lastTrack: RemoteNowPlaying | null = null;
126
+ private lastStatus: number | null = null;
127
+ private lastQueue: { items: RemoteQueueItem[]; index: number } | null = null;
128
+
129
+ constructor(private readonly opts: RemotePlayerOptions) {
130
+ this.url = opts.url ?? DEFAULT_REMOTE_WS;
131
+ this.heartbeatMs = opts.heartbeatMs ?? 10_000;
132
+ this.reconnectMs = opts.reconnectMs ?? 3_000;
133
+ this.getToken =
134
+ typeof opts.token === "function" ? opts.token : () => opts.token as string;
135
+ this.debug = opts.debug ?? (() => {});
136
+ }
137
+
138
+ /** Register a handler for a command. Returns `this` for chaining. */
139
+ on<K extends keyof RemotePlayerHandlers>(event: K, handler: Handler<K>): this {
140
+ this.handlers[event] = handler;
141
+ return this;
142
+ }
143
+
144
+ /** The server-assigned device id (empty until registered). */
145
+ get id(): string {
146
+ return this.deviceId;
147
+ }
148
+
149
+ /** Connect, register, and start the heartbeat + auto-reconnect loop. */
150
+ connect(): void {
151
+ this.stopped = false;
152
+ this.open();
153
+ }
154
+
155
+ /** Tear the connection down and stop reconnecting. */
156
+ disconnect(): void {
157
+ this.stopped = true;
158
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
159
+ if (this.heartbeat) clearInterval(this.heartbeat);
160
+ try {
161
+ this.ws?.close();
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ this.ws = null;
166
+ }
167
+
168
+ // ── State push ────────────────────────────────────────────────────────────
169
+
170
+ /** Advertise the current track. Call whenever it changes, and periodically
171
+ * (~every 1–4s) with fresh `elapsedMs` so controllers show smooth progress. */
172
+ setNowPlaying(track: RemoteNowPlaying): void {
173
+ this.lastTrack = track;
174
+ this.send({
175
+ type: "message",
176
+ device_id: this.deviceId,
177
+ token: this.getToken(),
178
+ data: {
179
+ type: "track",
180
+ title: track.title,
181
+ artist: track.artist,
182
+ album: track.album,
183
+ album_artist: track.albumArtist ?? track.artist,
184
+ length: track.durationMs ?? 0,
185
+ elapsed: track.elapsedMs ?? 0,
186
+ duration_ms: track.durationMs ?? 0,
187
+ album_art: track.albumArt,
188
+ is_playing: track.isPlaying ?? true,
189
+ device_name: this.opts.name,
190
+ },
191
+ });
192
+ }
193
+
194
+ /** Advertise transport state. */
195
+ setStatus(status: "playing" | "paused" | "stopped"): void {
196
+ const code = status === "playing" ? 1 : status === "paused" ? 2 : 0;
197
+ this.lastStatus = code;
198
+ this.send({
199
+ type: "message",
200
+ device_id: this.deviceId,
201
+ token: this.getToken(),
202
+ data: { type: "status", status: code },
203
+ });
204
+ }
205
+
206
+ /** Advertise the playback queue + current index. */
207
+ setQueue(items: RemoteQueueItem[], index: number): void {
208
+ this.lastQueue = { items, index };
209
+ this.send({
210
+ type: "message",
211
+ device_id: this.deviceId,
212
+ token: this.getToken(),
213
+ data: {
214
+ type: "queue",
215
+ index,
216
+ queue: items.map((t) => ({
217
+ uploadId: t.uploadId,
218
+ trackId: t.trackId,
219
+ title: t.title,
220
+ artist: t.artist,
221
+ album: t.album,
222
+ album_artist: t.albumArtist,
223
+ album_art: t.albumArt,
224
+ duration: t.durationMs,
225
+ song_uri: t.songUri,
226
+ album_uri: t.albumUri,
227
+ track_number: t.trackNumber,
228
+ })),
229
+ },
230
+ });
231
+ }
232
+
233
+ // ── Internals ───────────────────────────────────────────────────────────────
234
+
235
+ private send(payload: Json): void {
236
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
237
+ try {
238
+ this.ws.send(JSON.stringify(payload));
239
+ } catch (e) {
240
+ this.debug("send error", e);
241
+ }
242
+ }
243
+ }
244
+
245
+ private open(): void {
246
+ if (this.stopped) return;
247
+ const token = this.getToken();
248
+ if (!token) {
249
+ // Not authenticated yet — retry so a later login connects.
250
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
251
+ return;
252
+ }
253
+
254
+ let ws: WebSocket;
255
+ try {
256
+ ws = new WebSocket(this.url);
257
+ } catch (e) {
258
+ this.debug("connect failed", e);
259
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
260
+ return;
261
+ }
262
+ this.ws = ws;
263
+
264
+ ws.onopen = () => {
265
+ this.debug("connected");
266
+ this.send({ type: "register", clientName: this.opts.name, token: this.getToken() });
267
+ if (this.heartbeat) clearInterval(this.heartbeat);
268
+ this.heartbeat = setInterval(() => {
269
+ if (ws.readyState === WebSocket.OPEN) ws.send("ping");
270
+ }, this.heartbeatMs);
271
+ };
272
+
273
+ ws.onmessage = (ev: MessageEvent) => {
274
+ if (ev.data === "pong") return;
275
+ let msg: Json;
276
+ try {
277
+ msg = JSON.parse(ev.data as string);
278
+ } catch {
279
+ return;
280
+ }
281
+ this.handle(msg);
282
+ };
283
+
284
+ ws.onerror = () => {
285
+ try {
286
+ ws.close();
287
+ } catch {
288
+ /* ignore */
289
+ }
290
+ };
291
+
292
+ ws.onclose = () => {
293
+ this.debug("disconnected");
294
+ if (this.heartbeat) clearInterval(this.heartbeat);
295
+ if (this.ws === ws) this.ws = null;
296
+ this.deviceId = "";
297
+ if (!this.stopped) {
298
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
299
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
300
+ }
301
+ };
302
+ }
303
+
304
+ private handle(msg: Json): void {
305
+ // Our device id comes ONLY from the registration reply. Do NOT read it from
306
+ // `device_registered` (which carries another device's id) — see PROTOCOL.md.
307
+ if (msg.status === "registered" && typeof msg.deviceId === "string") {
308
+ this.deviceId = msg.deviceId;
309
+ this.debug("registered", this.deviceId);
310
+ this.resync();
311
+ return;
312
+ }
313
+ if (msg.type === "command") {
314
+ this.dispatch(msg);
315
+ return;
316
+ }
317
+ // devices / device_registered / device_unregistered / primary_changed and
318
+ // `message` echoes are irrelevant to a pure player — ignore them.
319
+ }
320
+
321
+ private dispatch(msg: Json): void {
322
+ const h = this.handlers;
323
+ switch (msg.action) {
324
+ case "play":
325
+ h.play?.();
326
+ break;
327
+ case "pause":
328
+ h.pause?.();
329
+ break;
330
+ case "next":
331
+ h.next?.();
332
+ break;
333
+ case "previous":
334
+ h.previous?.();
335
+ break;
336
+ case "seek": {
337
+ const a = msg.args as { position?: number } | number | undefined;
338
+ const pos = typeof a === "number" ? a : (a?.position ?? 0);
339
+ h.seek?.(pos);
340
+ break;
341
+ }
342
+ case "queue_jump":
343
+ h.queueJump?.((msg.args as { index?: number })?.index ?? 0);
344
+ break;
345
+ case "queue_remove":
346
+ h.queueRemove?.((msg.args as { index?: number })?.index ?? 0);
347
+ break;
348
+ case "enqueue": {
349
+ const a = (msg.args ?? {}) as Json;
350
+ h.enqueue?.({
351
+ tracks: (a.tracks ?? []).map(descriptorToItem),
352
+ mode: a.mode ?? "now",
353
+ shuffle: !!a.shuffle,
354
+ startIndex: a.startIndex ?? 0,
355
+ });
356
+ break;
357
+ }
358
+ default:
359
+ this.debug("unknown command", msg.action);
360
+ }
361
+ }
362
+
363
+ // On (re)connect, re-advertise the last known state so controllers resync.
364
+ private resync(): void {
365
+ if (this.lastTrack) this.setNowPlaying(this.lastTrack);
366
+ if (this.lastStatus !== null) {
367
+ this.send({
368
+ type: "message",
369
+ device_id: this.deviceId,
370
+ token: this.getToken(),
371
+ data: { type: "status", status: this.lastStatus },
372
+ });
373
+ }
374
+ if (this.lastQueue) this.setQueue(this.lastQueue.items, this.lastQueue.index);
375
+ }
376
+ }
377
+
378
+ function descriptorToItem(d: Json): RemoteQueueItem {
379
+ return {
380
+ uploadId: d.uploadId,
381
+ trackId: d.trackId,
382
+ title: d.title ?? "",
383
+ artist: d.artist ?? "",
384
+ album: d.album,
385
+ albumArtist: d.album_artist,
386
+ albumArt: d.album_art,
387
+ durationMs: d.duration,
388
+ songUri: d.song_uri,
389
+ albumUri: d.album_uri,
390
+ trackNumber: d.track_number,
391
+ };
392
+ }