@rocksky/sdk 0.8.1 → 0.10.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.
@@ -0,0 +1,124 @@
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
+ /** Default remote-control WebSocket endpoint. */
24
+ export declare const DEFAULT_REMOTE_WS = "wss://api.rocksky.app/ws";
25
+ /** Now-playing state you advertise. */
26
+ export interface RemoteNowPlaying {
27
+ title: string;
28
+ artist: string;
29
+ album?: string;
30
+ albumArtist?: string;
31
+ albumArt?: string;
32
+ /** Total track length, ms. */
33
+ durationMs?: number;
34
+ /** Current position, ms. */
35
+ elapsedMs?: number;
36
+ isPlaying?: boolean;
37
+ }
38
+ /** One queue entry. `uploadId` (Rocksky uploads) or `trackId` (Navidrome id). */
39
+ export interface RemoteQueueItem {
40
+ uploadId?: string;
41
+ trackId?: string;
42
+ title: string;
43
+ artist: string;
44
+ album?: string;
45
+ albumArtist?: string;
46
+ albumArt?: string;
47
+ durationMs?: number;
48
+ songUri?: string;
49
+ albumUri?: string;
50
+ trackNumber?: number;
51
+ }
52
+ /** Payload of an `enqueue` command from a controller (an album or a track). */
53
+ export interface EnqueueCommand {
54
+ tracks: RemoteQueueItem[];
55
+ mode: "now" | "next" | "last";
56
+ shuffle: boolean;
57
+ startIndex: number;
58
+ }
59
+ /** Handlers for commands a controller sends to this player. */
60
+ export interface RemotePlayerHandlers {
61
+ play(): void;
62
+ pause(): void;
63
+ next(): void;
64
+ previous(): void;
65
+ seek(positionMs: number): void;
66
+ enqueue(cmd: EnqueueCommand): void;
67
+ queueJump(index: number): void;
68
+ queueRemove(index: number): void;
69
+ }
70
+ type Handler<K extends keyof RemotePlayerHandlers> = RemotePlayerHandlers[K];
71
+ export interface RemotePlayerOptions {
72
+ /** Rocksky access token, or a getter (read fresh on each (re)connect/send). */
73
+ token: string | (() => string | undefined);
74
+ /** Display name shown in the miniplayer device picker. */
75
+ name: string;
76
+ /** WebSocket endpoint. Defaults to {@link DEFAULT_REMOTE_WS}. */
77
+ url?: string;
78
+ /** Heartbeat interval, ms (default 10000). */
79
+ heartbeatMs?: number;
80
+ /** Reconnect delay after a drop, ms (default 3000). */
81
+ reconnectMs?: number;
82
+ /** Optional debug logger. */
83
+ debug?: (...args: unknown[]) => void;
84
+ }
85
+ export declare class RemotePlayer {
86
+ private readonly opts;
87
+ private ws;
88
+ private deviceId;
89
+ private stopped;
90
+ private heartbeat?;
91
+ private reconnectTimer?;
92
+ private readonly url;
93
+ private readonly heartbeatMs;
94
+ private readonly reconnectMs;
95
+ private readonly getToken;
96
+ private readonly debug;
97
+ private handlers;
98
+ private lastTrack;
99
+ private lastStatus;
100
+ private lastQueue;
101
+ constructor(opts: RemotePlayerOptions);
102
+ /** Register a handler for a command. Returns `this` for chaining. */
103
+ on<K extends keyof RemotePlayerHandlers>(event: K, handler: Handler<K>): this;
104
+ /** The server-assigned device id (empty until registered). */
105
+ get id(): string;
106
+ /** Connect, register, and start the heartbeat + auto-reconnect loop. */
107
+ connect(): void;
108
+ /** Tear the connection down and stop reconnecting. */
109
+ disconnect(): void;
110
+ /** Advertise the current track. Call whenever it changes, and periodically
111
+ * (~every 1–4s) with fresh `elapsedMs` so controllers show smooth progress. */
112
+ setNowPlaying(track: RemoteNowPlaying): void;
113
+ /** Advertise transport state. */
114
+ setStatus(status: "playing" | "paused" | "stopped"): void;
115
+ /** Advertise the playback queue + current index. */
116
+ setQueue(items: RemoteQueueItem[], index: number): void;
117
+ private send;
118
+ private open;
119
+ private handle;
120
+ private dispatch;
121
+ private resync;
122
+ }
123
+ export {};
124
+ //# sourceMappingURL=remote-player.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-player.d.ts","sourceRoot":"","sources":["../src/remote-player.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/sdk",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "TypeScript SDK for Rocksky — built on atcute: AppView reads, AT Protocol PDS writes (scrobble, like, follow, shout), a local dedup index, and Jetstream real-time sync.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -21,6 +21,22 @@ export {
21
21
  } from "./agent.js";
22
22
  export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
23
23
  export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
24
+ export {
25
+ RemotePlayer,
26
+ DEFAULT_REMOTE_WS,
27
+ type RemotePlayerOptions,
28
+ type RemotePlayerHandlers,
29
+ type RemoteNowPlaying,
30
+ type RemoteQueueItem,
31
+ type EnqueueCommand,
32
+ } from "./remote-player.js";
33
+ export {
34
+ RemoteController,
35
+ type RemoteControllerOptions,
36
+ type RemoteControllerEvents,
37
+ type RemoteDevice,
38
+ type RemoteStatus,
39
+ } from "./remote-controller.js";
24
40
  export { songHash, albumHash, artistHash } from "./hash.js";
25
41
  export { RockskyError } from "./errors.js";
26
42
  export type * from "./generated/types.js";
@@ -0,0 +1,362 @@
1
+ /**
2
+ * RemoteController — build a Rocksky remote UI in a few lines.
3
+ *
4
+ * The other half of the remote-control WebSocket protocol (see
5
+ * remote-ws/PROTOCOL.md): where {@link RemotePlayer} is a controllable player, a
6
+ * controller lists the user's players, observes what each is playing
7
+ * (now-playing / status / queue), picks the primary device, and sends commands
8
+ * (play/pause/next/previous/seek/queue actions/enqueue). Heartbeat, reconnect,
9
+ * and the register handshake are handled for you.
10
+ *
11
+ * ```ts
12
+ * const controller = new RemoteController({ token, name: "My Controller" });
13
+ * controller.on("devices", (d) => render(d.devices));
14
+ * controller.on("nowPlaying", (e) => showTrack(e.deviceId, e.track));
15
+ * controller.connect();
16
+ * // …then drive a device:
17
+ * controller.setPrimary(deviceId);
18
+ * controller.pause(deviceId);
19
+ * controller.seek(deviceId, 42000);
20
+ * ```
21
+ */
22
+
23
+ import { DEFAULT_REMOTE_WS, type RemoteNowPlaying, type RemoteQueueItem } from "./remote-player.js";
24
+
25
+ export { DEFAULT_REMOTE_WS, type RemoteNowPlaying, type RemoteQueueItem };
26
+
27
+ /** A player device visible to the controller. */
28
+ export interface RemoteDevice {
29
+ deviceId: string;
30
+ name: string;
31
+ nowPlaying?: RemoteNowPlaying;
32
+ queueIndex: number;
33
+ queue: RemoteQueueItem[];
34
+ }
35
+
36
+ /** Transport state advertised by a device. */
37
+ export type RemoteStatus = "playing" | "paused" | "stopped";
38
+
39
+ /** Events the controller emits (from the server). */
40
+ export interface RemoteControllerEvents {
41
+ /** The full device snapshot (on connect + reconnect). */
42
+ devices(payload: { primaryDevice: string | null; devices: RemoteDevice[] }): void;
43
+ deviceRegistered(payload: { deviceId: string; name: string }): void;
44
+ deviceUnregistered(payload: { deviceId: string }): void;
45
+ primaryChanged(payload: { deviceId: string }): void;
46
+ nowPlaying(payload: { deviceId: string; deviceName: string; track: RemoteNowPlaying }): void;
47
+ status(payload: { deviceId: string; deviceName: string; status: RemoteStatus }): void;
48
+ queue(payload: {
49
+ deviceId: string;
50
+ deviceName: string;
51
+ index: number;
52
+ queue: RemoteQueueItem[];
53
+ }): void;
54
+ }
55
+
56
+ type EventName = keyof RemoteControllerEvents;
57
+ type EventHandler<K extends EventName> = RemoteControllerEvents[K];
58
+
59
+ export interface RemoteControllerOptions {
60
+ /** Rocksky access token, or a getter (read fresh on each (re)connect/send). */
61
+ token: string | (() => string | undefined);
62
+ /** Registration label (controllers are hidden from device lists). */
63
+ name: string;
64
+ /** WebSocket endpoint. Defaults to {@link DEFAULT_REMOTE_WS}. */
65
+ url?: string;
66
+ /** Heartbeat interval, ms (default 10000). */
67
+ heartbeatMs?: number;
68
+ /** Reconnect delay after a drop, ms (default 3000). */
69
+ reconnectMs?: number;
70
+ /** Optional debug logger. */
71
+ debug?: (...args: unknown[]) => void;
72
+ }
73
+
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ type Json = any;
76
+
77
+ export class RemoteController {
78
+ private ws: WebSocket | null = null;
79
+ private stopped = false;
80
+ private heartbeat?: ReturnType<typeof setInterval>;
81
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
82
+
83
+ private readonly url: string;
84
+ private readonly heartbeatMs: number;
85
+ private readonly reconnectMs: number;
86
+ private readonly getToken: () => string | undefined;
87
+ private readonly debug: (...args: unknown[]) => void;
88
+
89
+ private handlers: { [K in EventName]?: EventHandler<K> } = {};
90
+
91
+ constructor(private readonly opts: RemoteControllerOptions) {
92
+ this.url = opts.url ?? DEFAULT_REMOTE_WS;
93
+ this.heartbeatMs = opts.heartbeatMs ?? 10_000;
94
+ this.reconnectMs = opts.reconnectMs ?? 3_000;
95
+ this.getToken =
96
+ typeof opts.token === "function" ? opts.token : () => opts.token as string;
97
+ this.debug = opts.debug ?? (() => {});
98
+ }
99
+
100
+ /** Subscribe to a server event. Returns `this` for chaining. */
101
+ on<K extends EventName>(event: K, handler: EventHandler<K>): this {
102
+ this.handlers[event] = handler;
103
+ return this;
104
+ }
105
+
106
+ /** Connect, register, and start the heartbeat + auto-reconnect loop. */
107
+ connect(): void {
108
+ this.stopped = false;
109
+ this.open();
110
+ }
111
+
112
+ /** Tear the connection down and stop reconnecting. */
113
+ disconnect(): void {
114
+ this.stopped = true;
115
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
116
+ if (this.heartbeat) clearInterval(this.heartbeat);
117
+ try {
118
+ this.ws?.close();
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ this.ws = null;
123
+ }
124
+
125
+ // ── Controls ──────────────────────────────────────────────────────────────
126
+
127
+ /** Choose the primary (scrobble/profile) device. */
128
+ setPrimary(deviceId: string): void {
129
+ this.send({ type: "set_primary", device_id: deviceId, token: this.getToken() });
130
+ }
131
+
132
+ /** `play`. `target` is a device id, or omit to broadcast to all devices. */
133
+ play(target?: string): void {
134
+ this.command("play", target);
135
+ }
136
+
137
+ pause(target?: string): void {
138
+ this.command("pause", target);
139
+ }
140
+
141
+ next(target?: string): void {
142
+ this.command("next", target);
143
+ }
144
+
145
+ previous(target?: string): void {
146
+ this.command("previous", target);
147
+ }
148
+
149
+ seek(target: string | undefined, positionMs: number): void {
150
+ this.command("seek", target, { position: positionMs });
151
+ }
152
+
153
+ queueJump(target: string | undefined, index: number): void {
154
+ this.command("queue_jump", target, { index });
155
+ }
156
+
157
+ queueRemove(target: string | undefined, index: number): void {
158
+ this.command("queue_remove", target, { index });
159
+ }
160
+
161
+ /** Enqueue tracks on the target device. */
162
+ enqueue(
163
+ target: string | undefined,
164
+ tracks: RemoteQueueItem[],
165
+ mode: "now" | "next" | "last" = "now",
166
+ shuffle = false,
167
+ startIndex = 0,
168
+ ): void {
169
+ this.command("enqueue", target, {
170
+ tracks: tracks.map((t) => ({
171
+ uploadId: t.uploadId,
172
+ trackId: t.trackId,
173
+ title: t.title,
174
+ artist: t.artist,
175
+ album: t.album,
176
+ album_artist: t.albumArtist,
177
+ album_art: t.albumArt,
178
+ duration: t.durationMs,
179
+ song_uri: t.songUri,
180
+ album_uri: t.albumUri,
181
+ track_number: t.trackNumber,
182
+ })),
183
+ mode,
184
+ shuffle,
185
+ startIndex,
186
+ });
187
+ }
188
+
189
+ /** Send an arbitrary command (escape hatch). */
190
+ command(action: string, target?: string, args?: Json): void {
191
+ const payload: Json = { type: "command", action, token: this.getToken() };
192
+ if (target) payload.target = target;
193
+ if (args !== undefined) payload.args = args;
194
+ this.send(payload);
195
+ }
196
+
197
+ // ── Internals ───────────────────────────────────────────────────────────────
198
+
199
+ private send(payload: Json): void {
200
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
201
+ try {
202
+ this.ws.send(JSON.stringify(payload));
203
+ } catch (e) {
204
+ this.debug("send error", e);
205
+ }
206
+ }
207
+ }
208
+
209
+ private open(): void {
210
+ if (this.stopped) return;
211
+ const token = this.getToken();
212
+ if (!token) {
213
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
214
+ return;
215
+ }
216
+
217
+ let ws: WebSocket;
218
+ try {
219
+ ws = new WebSocket(this.url);
220
+ } catch (e) {
221
+ this.debug("connect failed", e);
222
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
223
+ return;
224
+ }
225
+ this.ws = ws;
226
+
227
+ ws.onopen = () => {
228
+ this.debug("connected");
229
+ this.send({ type: "register", clientName: this.opts.name, token: this.getToken() });
230
+ if (this.heartbeat) clearInterval(this.heartbeat);
231
+ this.heartbeat = setInterval(() => {
232
+ if (ws.readyState === WebSocket.OPEN) ws.send("ping");
233
+ }, this.heartbeatMs);
234
+ };
235
+
236
+ ws.onmessage = (ev: MessageEvent) => {
237
+ if (ev.data === "pong") return;
238
+ let msg: Json;
239
+ try {
240
+ msg = JSON.parse(ev.data as string);
241
+ } catch {
242
+ return;
243
+ }
244
+ this.handle(msg);
245
+ };
246
+
247
+ ws.onerror = () => {
248
+ try {
249
+ ws.close();
250
+ } catch {
251
+ /* ignore */
252
+ }
253
+ };
254
+
255
+ ws.onclose = () => {
256
+ this.debug("disconnected");
257
+ if (this.heartbeat) clearInterval(this.heartbeat);
258
+ if (this.ws === ws) this.ws = null;
259
+ if (!this.stopped) {
260
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
261
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
262
+ }
263
+ };
264
+ }
265
+
266
+ private handle(msg: Json): void {
267
+ // The registration reply carries no event (a controller doesn't need its own
268
+ // id to control other devices).
269
+ if (msg.status === "registered") return;
270
+ switch (msg.type) {
271
+ case "devices":
272
+ this.handlers.devices?.({
273
+ primaryDevice: msg.primary_device ?? null,
274
+ devices: (msg.devices ?? []).map(deviceFromJson),
275
+ });
276
+ break;
277
+ case "device_registered":
278
+ this.handlers.deviceRegistered?.({
279
+ deviceId: msg.deviceId ?? "",
280
+ name: msg.clientName ?? "",
281
+ });
282
+ break;
283
+ case "device_unregistered":
284
+ this.handlers.deviceUnregistered?.({ deviceId: msg.device_id ?? "" });
285
+ break;
286
+ case "primary_changed":
287
+ this.handlers.primaryChanged?.({ deviceId: msg.device_id ?? "" });
288
+ break;
289
+ case "message":
290
+ this.handleMessage(msg);
291
+ break;
292
+ default:
293
+ this.debug("unknown frame", msg.type);
294
+ }
295
+ }
296
+
297
+ private handleMessage(msg: Json): void {
298
+ const deviceId = msg.device_id ?? "";
299
+ const deviceName = msg.device_name ?? "";
300
+ const data = msg.data;
301
+ if (!data) return;
302
+ switch (data.type) {
303
+ case "track":
304
+ this.handlers.nowPlaying?.({ deviceId, deviceName, track: trackFromJson(data) });
305
+ break;
306
+ case "status":
307
+ this.handlers.status?.({ deviceId, deviceName, status: statusFromCode(data.status) });
308
+ break;
309
+ case "queue":
310
+ this.handlers.queue?.({
311
+ deviceId,
312
+ deviceName,
313
+ index: data.index ?? 0,
314
+ queue: (data.queue ?? []).map(queueItemFromJson),
315
+ });
316
+ break;
317
+ }
318
+ }
319
+ }
320
+
321
+ function statusFromCode(code: number): RemoteStatus {
322
+ return code === 1 ? "playing" : code === 0 ? "stopped" : "paused";
323
+ }
324
+
325
+ function trackFromJson(d: Json): RemoteNowPlaying {
326
+ return {
327
+ title: d.title ?? "",
328
+ artist: d.artist ?? "",
329
+ album: d.album,
330
+ albumArtist: d.album_artist,
331
+ albumArt: d.album_art,
332
+ durationMs: d.duration_ms ?? d.length,
333
+ elapsedMs: d.elapsed,
334
+ isPlaying: d.is_playing,
335
+ };
336
+ }
337
+
338
+ function queueItemFromJson(d: Json): RemoteQueueItem {
339
+ return {
340
+ uploadId: d.uploadId,
341
+ trackId: d.trackId,
342
+ title: d.title ?? "",
343
+ artist: d.artist ?? "",
344
+ album: d.album,
345
+ albumArtist: d.album_artist,
346
+ albumArt: d.album_art,
347
+ durationMs: d.duration,
348
+ songUri: d.song_uri,
349
+ albumUri: d.album_uri,
350
+ trackNumber: d.track_number,
351
+ };
352
+ }
353
+
354
+ function deviceFromJson(d: Json): RemoteDevice {
355
+ return {
356
+ deviceId: d.device_id ?? "",
357
+ name: d.name ?? "",
358
+ nowPlaying: d.now_playing ? trackFromJson(d.now_playing) : undefined,
359
+ queueIndex: d.queue?.index ?? 0,
360
+ queue: (d.queue?.queue ?? []).map(queueItemFromJson),
361
+ };
362
+ }