@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.
- package/README.md +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +511 -0
- package/dist/remote-controller.d.ts +123 -0
- package/dist/remote-controller.d.ts.map +1 -0
- package/dist/remote-player.d.ts +134 -0
- package/dist/remote-player.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +16 -0
- package/src/remote-controller.ts +368 -0
- package/src/remote-player.ts +392 -0
package/README.md
CHANGED
|
@@ -104,6 +104,33 @@ the existing URI) and a same-second scrobble of the same track isn't duplicated.
|
|
|
104
104
|
|
|
105
105
|
Node ≥ 22 (global `WebSocket`/`fetch`) or Bun.
|
|
106
106
|
|
|
107
|
+
## Remote control
|
|
108
|
+
|
|
109
|
+
Build a controllable player or a remote UI over the remote-control WebSocket (see
|
|
110
|
+
[`remote-ws/PROTOCOL.md`](../../remote-ws/PROTOCOL.md)). Heartbeat, reconnect, and
|
|
111
|
+
the register handshake are handled for you.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { RemotePlayer, RemoteController } from "@rocksky/sdk";
|
|
115
|
+
|
|
116
|
+
// A controllable player (advertises now-playing + obeys commands):
|
|
117
|
+
const player = new RemotePlayer({ token, name: "My Player" });
|
|
118
|
+
player.on("play", () => engine.play()).on("pause", () => engine.pause());
|
|
119
|
+
player.connect();
|
|
120
|
+
player.setNowPlaying({ title, artist, albumArt, durationMs, elapsedMs, isPlaying: true });
|
|
121
|
+
player.setStatus("playing");
|
|
122
|
+
|
|
123
|
+
// A controller (lists devices, picks the primary, sends commands):
|
|
124
|
+
const controller = new RemoteController({ token, name: "My Controller" });
|
|
125
|
+
controller.on("devices", ({ devices }) => render(devices));
|
|
126
|
+
controller.on("nowPlaying", ({ deviceId, track }) => show(deviceId, track));
|
|
127
|
+
controller.connect();
|
|
128
|
+
controller.setPrimary(deviceId);
|
|
129
|
+
controller.pause(deviceId);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
See `examples/remote.ts`.
|
|
133
|
+
|
|
107
134
|
## Example
|
|
108
135
|
|
|
109
136
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { RockskyLibrary } from "./library.js";
|
|
|
14
14
|
export { Agent, type ScrobbleInput, type ScrobbleMatchInput, type SongInput, type AlbumInput, type ArtistInput, } from "./agent.js";
|
|
15
15
|
export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
|
|
16
16
|
export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
|
|
17
|
+
export { RemotePlayer, DEFAULT_REMOTE_WS, type RemotePlayerOptions, type RemotePlayerHandlers, type RemoteNowPlaying, type RemoteQueueItem, type EnqueueCommand, } from "./remote-player.js";
|
|
18
|
+
export { RemoteController, type RemoteControllerOptions, type RemoteControllerEvents, type RemoteDevice, type RemoteStatus, } from "./remote-controller.js";
|
|
17
19
|
export { songHash, albumHash, artistHash } from "./hash.js";
|
|
18
20
|
export { RockskyError } from "./errors.js";
|
|
19
21
|
export type * from "./generated/types.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,KAAK,EACL,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,mBAAmB,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,KAAK,EACL,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,mBAAmB,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1018,6 +1018,514 @@ function* walkMst(blocks, cid) {
|
|
|
1018
1018
|
yield* walkMst(blocks, e.t.$link);
|
|
1019
1019
|
}
|
|
1020
1020
|
}
|
|
1021
|
+
// src/remote-player.ts
|
|
1022
|
+
var DEFAULT_REMOTE_WS = "wss://api.rocksky.app/ws";
|
|
1023
|
+
|
|
1024
|
+
class RemotePlayer {
|
|
1025
|
+
opts;
|
|
1026
|
+
ws = null;
|
|
1027
|
+
deviceId = "";
|
|
1028
|
+
stopped = false;
|
|
1029
|
+
heartbeat;
|
|
1030
|
+
reconnectTimer;
|
|
1031
|
+
url;
|
|
1032
|
+
heartbeatMs;
|
|
1033
|
+
reconnectMs;
|
|
1034
|
+
getToken;
|
|
1035
|
+
debug;
|
|
1036
|
+
handlers = {};
|
|
1037
|
+
lastTrack = null;
|
|
1038
|
+
lastStatus = null;
|
|
1039
|
+
lastQueue = null;
|
|
1040
|
+
constructor(opts) {
|
|
1041
|
+
this.opts = opts;
|
|
1042
|
+
this.url = opts.url ?? DEFAULT_REMOTE_WS;
|
|
1043
|
+
this.heartbeatMs = opts.heartbeatMs ?? 1e4;
|
|
1044
|
+
this.reconnectMs = opts.reconnectMs ?? 3000;
|
|
1045
|
+
this.getToken = typeof opts.token === "function" ? opts.token : () => opts.token;
|
|
1046
|
+
this.debug = opts.debug ?? (() => {});
|
|
1047
|
+
}
|
|
1048
|
+
on(event, handler) {
|
|
1049
|
+
this.handlers[event] = handler;
|
|
1050
|
+
return this;
|
|
1051
|
+
}
|
|
1052
|
+
get id() {
|
|
1053
|
+
return this.deviceId;
|
|
1054
|
+
}
|
|
1055
|
+
connect() {
|
|
1056
|
+
this.stopped = false;
|
|
1057
|
+
this.open();
|
|
1058
|
+
}
|
|
1059
|
+
disconnect() {
|
|
1060
|
+
this.stopped = true;
|
|
1061
|
+
if (this.reconnectTimer)
|
|
1062
|
+
clearTimeout(this.reconnectTimer);
|
|
1063
|
+
if (this.heartbeat)
|
|
1064
|
+
clearInterval(this.heartbeat);
|
|
1065
|
+
try {
|
|
1066
|
+
this.ws?.close();
|
|
1067
|
+
} catch {}
|
|
1068
|
+
this.ws = null;
|
|
1069
|
+
}
|
|
1070
|
+
setNowPlaying(track) {
|
|
1071
|
+
this.lastTrack = track;
|
|
1072
|
+
this.send({
|
|
1073
|
+
type: "message",
|
|
1074
|
+
device_id: this.deviceId,
|
|
1075
|
+
token: this.getToken(),
|
|
1076
|
+
data: {
|
|
1077
|
+
type: "track",
|
|
1078
|
+
title: track.title,
|
|
1079
|
+
artist: track.artist,
|
|
1080
|
+
album: track.album,
|
|
1081
|
+
album_artist: track.albumArtist ?? track.artist,
|
|
1082
|
+
length: track.durationMs ?? 0,
|
|
1083
|
+
elapsed: track.elapsedMs ?? 0,
|
|
1084
|
+
duration_ms: track.durationMs ?? 0,
|
|
1085
|
+
album_art: track.albumArt,
|
|
1086
|
+
is_playing: track.isPlaying ?? true,
|
|
1087
|
+
device_name: this.opts.name
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
setStatus(status) {
|
|
1092
|
+
const code = status === "playing" ? 1 : status === "paused" ? 2 : 0;
|
|
1093
|
+
this.lastStatus = code;
|
|
1094
|
+
this.send({
|
|
1095
|
+
type: "message",
|
|
1096
|
+
device_id: this.deviceId,
|
|
1097
|
+
token: this.getToken(),
|
|
1098
|
+
data: { type: "status", status: code }
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
setQueue(items, index) {
|
|
1102
|
+
this.lastQueue = { items, index };
|
|
1103
|
+
this.send({
|
|
1104
|
+
type: "message",
|
|
1105
|
+
device_id: this.deviceId,
|
|
1106
|
+
token: this.getToken(),
|
|
1107
|
+
data: {
|
|
1108
|
+
type: "queue",
|
|
1109
|
+
index,
|
|
1110
|
+
queue: items.map((t) => ({
|
|
1111
|
+
uploadId: t.uploadId,
|
|
1112
|
+
trackId: t.trackId,
|
|
1113
|
+
title: t.title,
|
|
1114
|
+
artist: t.artist,
|
|
1115
|
+
album: t.album,
|
|
1116
|
+
album_artist: t.albumArtist,
|
|
1117
|
+
album_art: t.albumArt,
|
|
1118
|
+
duration: t.durationMs,
|
|
1119
|
+
song_uri: t.songUri,
|
|
1120
|
+
album_uri: t.albumUri,
|
|
1121
|
+
track_number: t.trackNumber
|
|
1122
|
+
}))
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
send(payload) {
|
|
1127
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1128
|
+
try {
|
|
1129
|
+
this.ws.send(JSON.stringify(payload));
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
this.debug("send error", e);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
open() {
|
|
1136
|
+
if (this.stopped)
|
|
1137
|
+
return;
|
|
1138
|
+
const token = this.getToken();
|
|
1139
|
+
if (!token) {
|
|
1140
|
+
this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
let ws;
|
|
1144
|
+
try {
|
|
1145
|
+
ws = new WebSocket(this.url);
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
this.debug("connect failed", e);
|
|
1148
|
+
this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
this.ws = ws;
|
|
1152
|
+
ws.onopen = () => {
|
|
1153
|
+
this.debug("connected");
|
|
1154
|
+
this.send({ type: "register", clientName: this.opts.name, token: this.getToken() });
|
|
1155
|
+
if (this.heartbeat)
|
|
1156
|
+
clearInterval(this.heartbeat);
|
|
1157
|
+
this.heartbeat = setInterval(() => {
|
|
1158
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
1159
|
+
ws.send("ping");
|
|
1160
|
+
}, this.heartbeatMs);
|
|
1161
|
+
};
|
|
1162
|
+
ws.onmessage = (ev) => {
|
|
1163
|
+
if (ev.data === "pong")
|
|
1164
|
+
return;
|
|
1165
|
+
let msg;
|
|
1166
|
+
try {
|
|
1167
|
+
msg = JSON.parse(ev.data);
|
|
1168
|
+
} catch {
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
this.handle(msg);
|
|
1172
|
+
};
|
|
1173
|
+
ws.onerror = () => {
|
|
1174
|
+
try {
|
|
1175
|
+
ws.close();
|
|
1176
|
+
} catch {}
|
|
1177
|
+
};
|
|
1178
|
+
ws.onclose = () => {
|
|
1179
|
+
this.debug("disconnected");
|
|
1180
|
+
if (this.heartbeat)
|
|
1181
|
+
clearInterval(this.heartbeat);
|
|
1182
|
+
if (this.ws === ws)
|
|
1183
|
+
this.ws = null;
|
|
1184
|
+
this.deviceId = "";
|
|
1185
|
+
if (!this.stopped) {
|
|
1186
|
+
if (this.reconnectTimer)
|
|
1187
|
+
clearTimeout(this.reconnectTimer);
|
|
1188
|
+
this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
handle(msg) {
|
|
1193
|
+
if (msg.status === "registered" && typeof msg.deviceId === "string") {
|
|
1194
|
+
this.deviceId = msg.deviceId;
|
|
1195
|
+
this.debug("registered", this.deviceId);
|
|
1196
|
+
this.resync();
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (msg.type === "command") {
|
|
1200
|
+
this.dispatch(msg);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
dispatch(msg) {
|
|
1205
|
+
const h = this.handlers;
|
|
1206
|
+
switch (msg.action) {
|
|
1207
|
+
case "play":
|
|
1208
|
+
h.play?.();
|
|
1209
|
+
break;
|
|
1210
|
+
case "pause":
|
|
1211
|
+
h.pause?.();
|
|
1212
|
+
break;
|
|
1213
|
+
case "next":
|
|
1214
|
+
h.next?.();
|
|
1215
|
+
break;
|
|
1216
|
+
case "previous":
|
|
1217
|
+
h.previous?.();
|
|
1218
|
+
break;
|
|
1219
|
+
case "seek": {
|
|
1220
|
+
const a = msg.args;
|
|
1221
|
+
const pos = typeof a === "number" ? a : a?.position ?? 0;
|
|
1222
|
+
h.seek?.(pos);
|
|
1223
|
+
break;
|
|
1224
|
+
}
|
|
1225
|
+
case "queue_jump":
|
|
1226
|
+
h.queueJump?.(msg.args?.index ?? 0);
|
|
1227
|
+
break;
|
|
1228
|
+
case "queue_remove":
|
|
1229
|
+
h.queueRemove?.(msg.args?.index ?? 0);
|
|
1230
|
+
break;
|
|
1231
|
+
case "enqueue": {
|
|
1232
|
+
const a = msg.args ?? {};
|
|
1233
|
+
h.enqueue?.({
|
|
1234
|
+
tracks: (a.tracks ?? []).map(descriptorToItem),
|
|
1235
|
+
mode: a.mode ?? "now",
|
|
1236
|
+
shuffle: !!a.shuffle,
|
|
1237
|
+
startIndex: a.startIndex ?? 0
|
|
1238
|
+
});
|
|
1239
|
+
break;
|
|
1240
|
+
}
|
|
1241
|
+
default:
|
|
1242
|
+
this.debug("unknown command", msg.action);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
resync() {
|
|
1246
|
+
if (this.lastTrack)
|
|
1247
|
+
this.setNowPlaying(this.lastTrack);
|
|
1248
|
+
if (this.lastStatus !== null) {
|
|
1249
|
+
this.send({
|
|
1250
|
+
type: "message",
|
|
1251
|
+
device_id: this.deviceId,
|
|
1252
|
+
token: this.getToken(),
|
|
1253
|
+
data: { type: "status", status: this.lastStatus }
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
if (this.lastQueue)
|
|
1257
|
+
this.setQueue(this.lastQueue.items, this.lastQueue.index);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
function descriptorToItem(d) {
|
|
1261
|
+
return {
|
|
1262
|
+
uploadId: d.uploadId,
|
|
1263
|
+
trackId: d.trackId,
|
|
1264
|
+
title: d.title ?? "",
|
|
1265
|
+
artist: d.artist ?? "",
|
|
1266
|
+
album: d.album,
|
|
1267
|
+
albumArtist: d.album_artist,
|
|
1268
|
+
albumArt: d.album_art,
|
|
1269
|
+
durationMs: d.duration,
|
|
1270
|
+
songUri: d.song_uri,
|
|
1271
|
+
albumUri: d.album_uri,
|
|
1272
|
+
trackNumber: d.track_number
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
// src/remote-controller.ts
|
|
1276
|
+
class RemoteController {
|
|
1277
|
+
opts;
|
|
1278
|
+
ws = null;
|
|
1279
|
+
stopped = false;
|
|
1280
|
+
heartbeat;
|
|
1281
|
+
reconnectTimer;
|
|
1282
|
+
url;
|
|
1283
|
+
heartbeatMs;
|
|
1284
|
+
reconnectMs;
|
|
1285
|
+
getToken;
|
|
1286
|
+
debug;
|
|
1287
|
+
handlers = {};
|
|
1288
|
+
constructor(opts) {
|
|
1289
|
+
this.opts = opts;
|
|
1290
|
+
this.url = opts.url ?? DEFAULT_REMOTE_WS;
|
|
1291
|
+
this.heartbeatMs = opts.heartbeatMs ?? 1e4;
|
|
1292
|
+
this.reconnectMs = opts.reconnectMs ?? 3000;
|
|
1293
|
+
this.getToken = typeof opts.token === "function" ? opts.token : () => opts.token;
|
|
1294
|
+
this.debug = opts.debug ?? (() => {});
|
|
1295
|
+
}
|
|
1296
|
+
on(event, handler) {
|
|
1297
|
+
this.handlers[event] = handler;
|
|
1298
|
+
return this;
|
|
1299
|
+
}
|
|
1300
|
+
connect() {
|
|
1301
|
+
this.stopped = false;
|
|
1302
|
+
this.open();
|
|
1303
|
+
}
|
|
1304
|
+
disconnect() {
|
|
1305
|
+
this.stopped = true;
|
|
1306
|
+
if (this.reconnectTimer)
|
|
1307
|
+
clearTimeout(this.reconnectTimer);
|
|
1308
|
+
if (this.heartbeat)
|
|
1309
|
+
clearInterval(this.heartbeat);
|
|
1310
|
+
try {
|
|
1311
|
+
this.ws?.close();
|
|
1312
|
+
} catch {}
|
|
1313
|
+
this.ws = null;
|
|
1314
|
+
}
|
|
1315
|
+
setPrimary(deviceId) {
|
|
1316
|
+
this.send({ type: "set_primary", device_id: deviceId, token: this.getToken() });
|
|
1317
|
+
}
|
|
1318
|
+
play(target) {
|
|
1319
|
+
this.command("play", target);
|
|
1320
|
+
}
|
|
1321
|
+
pause(target) {
|
|
1322
|
+
this.command("pause", target);
|
|
1323
|
+
}
|
|
1324
|
+
next(target) {
|
|
1325
|
+
this.command("next", target);
|
|
1326
|
+
}
|
|
1327
|
+
previous(target) {
|
|
1328
|
+
this.command("previous", target);
|
|
1329
|
+
}
|
|
1330
|
+
seek(target, positionMs) {
|
|
1331
|
+
this.command("seek", target, { position: positionMs });
|
|
1332
|
+
}
|
|
1333
|
+
queueJump(target, index) {
|
|
1334
|
+
this.command("queue_jump", target, { index });
|
|
1335
|
+
}
|
|
1336
|
+
queueRemove(target, index) {
|
|
1337
|
+
this.command("queue_remove", target, { index });
|
|
1338
|
+
}
|
|
1339
|
+
enqueue(target, tracks, mode = "now", shuffle = false, startIndex = 0) {
|
|
1340
|
+
this.command("enqueue", target, {
|
|
1341
|
+
tracks: tracks.map((t) => ({
|
|
1342
|
+
uploadId: t.uploadId,
|
|
1343
|
+
trackId: t.trackId,
|
|
1344
|
+
title: t.title,
|
|
1345
|
+
artist: t.artist,
|
|
1346
|
+
album: t.album,
|
|
1347
|
+
album_artist: t.albumArtist,
|
|
1348
|
+
album_art: t.albumArt,
|
|
1349
|
+
duration: t.durationMs,
|
|
1350
|
+
song_uri: t.songUri,
|
|
1351
|
+
album_uri: t.albumUri,
|
|
1352
|
+
track_number: t.trackNumber
|
|
1353
|
+
})),
|
|
1354
|
+
mode,
|
|
1355
|
+
shuffle,
|
|
1356
|
+
startIndex
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
command(action, target, args) {
|
|
1360
|
+
const payload = { type: "command", action, token: this.getToken() };
|
|
1361
|
+
if (target)
|
|
1362
|
+
payload.target = target;
|
|
1363
|
+
if (args !== undefined)
|
|
1364
|
+
payload.args = args;
|
|
1365
|
+
this.send(payload);
|
|
1366
|
+
}
|
|
1367
|
+
send(payload) {
|
|
1368
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1369
|
+
try {
|
|
1370
|
+
this.ws.send(JSON.stringify(payload));
|
|
1371
|
+
} catch (e) {
|
|
1372
|
+
this.debug("send error", e);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
open() {
|
|
1377
|
+
if (this.stopped)
|
|
1378
|
+
return;
|
|
1379
|
+
const token = this.getToken();
|
|
1380
|
+
if (!token) {
|
|
1381
|
+
this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
let ws;
|
|
1385
|
+
try {
|
|
1386
|
+
ws = new WebSocket(this.url);
|
|
1387
|
+
} catch (e) {
|
|
1388
|
+
this.debug("connect failed", e);
|
|
1389
|
+
this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
this.ws = ws;
|
|
1393
|
+
ws.onopen = () => {
|
|
1394
|
+
this.debug("connected");
|
|
1395
|
+
this.send({ type: "register", clientName: this.opts.name, token: this.getToken() });
|
|
1396
|
+
if (this.heartbeat)
|
|
1397
|
+
clearInterval(this.heartbeat);
|
|
1398
|
+
this.heartbeat = setInterval(() => {
|
|
1399
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
1400
|
+
ws.send("ping");
|
|
1401
|
+
}, this.heartbeatMs);
|
|
1402
|
+
};
|
|
1403
|
+
ws.onmessage = (ev) => {
|
|
1404
|
+
if (ev.data === "pong")
|
|
1405
|
+
return;
|
|
1406
|
+
let msg;
|
|
1407
|
+
try {
|
|
1408
|
+
msg = JSON.parse(ev.data);
|
|
1409
|
+
} catch {
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
this.handle(msg);
|
|
1413
|
+
};
|
|
1414
|
+
ws.onerror = () => {
|
|
1415
|
+
try {
|
|
1416
|
+
ws.close();
|
|
1417
|
+
} catch {}
|
|
1418
|
+
};
|
|
1419
|
+
ws.onclose = () => {
|
|
1420
|
+
this.debug("disconnected");
|
|
1421
|
+
if (this.heartbeat)
|
|
1422
|
+
clearInterval(this.heartbeat);
|
|
1423
|
+
if (this.ws === ws)
|
|
1424
|
+
this.ws = null;
|
|
1425
|
+
if (!this.stopped) {
|
|
1426
|
+
if (this.reconnectTimer)
|
|
1427
|
+
clearTimeout(this.reconnectTimer);
|
|
1428
|
+
this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
handle(msg) {
|
|
1433
|
+
if (msg.status === "registered")
|
|
1434
|
+
return;
|
|
1435
|
+
switch (msg.type) {
|
|
1436
|
+
case "devices":
|
|
1437
|
+
this.handlers.devices?.({
|
|
1438
|
+
primaryDevice: msg.primary_device ?? null,
|
|
1439
|
+
devices: (msg.devices ?? []).map(deviceFromJson)
|
|
1440
|
+
});
|
|
1441
|
+
break;
|
|
1442
|
+
case "device_registered":
|
|
1443
|
+
this.handlers.deviceRegistered?.({
|
|
1444
|
+
deviceId: msg.deviceId ?? "",
|
|
1445
|
+
name: msg.clientName ?? ""
|
|
1446
|
+
});
|
|
1447
|
+
break;
|
|
1448
|
+
case "device_unregistered":
|
|
1449
|
+
this.handlers.deviceUnregistered?.({ deviceId: msg.device_id ?? "" });
|
|
1450
|
+
break;
|
|
1451
|
+
case "primary_changed":
|
|
1452
|
+
this.handlers.primaryChanged?.({ deviceId: msg.device_id ?? "" });
|
|
1453
|
+
break;
|
|
1454
|
+
case "message":
|
|
1455
|
+
this.handleMessage(msg);
|
|
1456
|
+
break;
|
|
1457
|
+
default:
|
|
1458
|
+
this.debug("unknown frame", msg.type);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
handleMessage(msg) {
|
|
1462
|
+
const deviceId = msg.device_id ?? "";
|
|
1463
|
+
const deviceName = msg.device_name ?? "";
|
|
1464
|
+
const data = msg.data;
|
|
1465
|
+
if (!data)
|
|
1466
|
+
return;
|
|
1467
|
+
switch (data.type) {
|
|
1468
|
+
case "track":
|
|
1469
|
+
this.handlers.nowPlaying?.({ deviceId, deviceName, track: trackFromJson(data) });
|
|
1470
|
+
break;
|
|
1471
|
+
case "status":
|
|
1472
|
+
this.handlers.status?.({ deviceId, deviceName, status: statusFromCode(data.status) });
|
|
1473
|
+
break;
|
|
1474
|
+
case "queue":
|
|
1475
|
+
this.handlers.queue?.({
|
|
1476
|
+
deviceId,
|
|
1477
|
+
deviceName,
|
|
1478
|
+
index: data.index ?? 0,
|
|
1479
|
+
queue: (data.queue ?? []).map(queueItemFromJson)
|
|
1480
|
+
});
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
function statusFromCode(code) {
|
|
1486
|
+
return code === 1 ? "playing" : code === 0 ? "stopped" : "paused";
|
|
1487
|
+
}
|
|
1488
|
+
function trackFromJson(d) {
|
|
1489
|
+
return {
|
|
1490
|
+
title: d.title ?? "",
|
|
1491
|
+
artist: d.artist ?? "",
|
|
1492
|
+
album: d.album,
|
|
1493
|
+
albumArtist: d.album_artist,
|
|
1494
|
+
albumArt: d.album_art,
|
|
1495
|
+
durationMs: d.duration_ms ?? d.length,
|
|
1496
|
+
elapsedMs: d.elapsed,
|
|
1497
|
+
isPlaying: d.is_playing,
|
|
1498
|
+
songUri: d.song_uri,
|
|
1499
|
+
albumUri: d.album_uri,
|
|
1500
|
+
artistUri: d.artist_uri,
|
|
1501
|
+
sha256: d.sha256,
|
|
1502
|
+
liked: d.liked
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function queueItemFromJson(d) {
|
|
1506
|
+
return {
|
|
1507
|
+
uploadId: d.uploadId,
|
|
1508
|
+
trackId: d.trackId,
|
|
1509
|
+
title: d.title ?? "",
|
|
1510
|
+
artist: d.artist ?? "",
|
|
1511
|
+
album: d.album,
|
|
1512
|
+
albumArtist: d.album_artist,
|
|
1513
|
+
albumArt: d.album_art,
|
|
1514
|
+
durationMs: d.duration,
|
|
1515
|
+
songUri: d.song_uri,
|
|
1516
|
+
albumUri: d.album_uri,
|
|
1517
|
+
trackNumber: d.track_number
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
function deviceFromJson(d) {
|
|
1521
|
+
return {
|
|
1522
|
+
deviceId: d.device_id ?? "",
|
|
1523
|
+
name: d.name ?? "",
|
|
1524
|
+
nowPlaying: d.now_playing ? trackFromJson(d.now_playing) : undefined,
|
|
1525
|
+
queueIndex: d.queue?.index ?? 0,
|
|
1526
|
+
queue: (d.queue?.queue ?? []).map(queueItemFromJson)
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1021
1529
|
|
|
1022
1530
|
// src/index.ts
|
|
1023
1531
|
init_errors();
|
|
@@ -1031,7 +1539,10 @@ export {
|
|
|
1031
1539
|
RockskyIndex,
|
|
1032
1540
|
RockskyError,
|
|
1033
1541
|
RockskyClient,
|
|
1542
|
+
RemotePlayer,
|
|
1543
|
+
RemoteController,
|
|
1034
1544
|
Interval,
|
|
1545
|
+
DEFAULT_REMOTE_WS,
|
|
1035
1546
|
DEFAULT_JETSTREAM_SERVERS,
|
|
1036
1547
|
DEFAULT_APPVIEW,
|
|
1037
1548
|
Agent
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
import { DEFAULT_REMOTE_WS, type RemoteNowPlaying, type RemoteQueueItem } from "./remote-player.js";
|
|
23
|
+
export { DEFAULT_REMOTE_WS, type RemoteNowPlaying, type RemoteQueueItem };
|
|
24
|
+
/** A player device visible to the controller. */
|
|
25
|
+
export interface RemoteDevice {
|
|
26
|
+
deviceId: string;
|
|
27
|
+
name: string;
|
|
28
|
+
nowPlaying?: RemoteNowPlaying;
|
|
29
|
+
queueIndex: number;
|
|
30
|
+
queue: RemoteQueueItem[];
|
|
31
|
+
}
|
|
32
|
+
/** Transport state advertised by a device. */
|
|
33
|
+
export type RemoteStatus = "playing" | "paused" | "stopped";
|
|
34
|
+
/** Events the controller emits (from the server). */
|
|
35
|
+
export interface RemoteControllerEvents {
|
|
36
|
+
/** The full device snapshot (on connect + reconnect). */
|
|
37
|
+
devices(payload: {
|
|
38
|
+
primaryDevice: string | null;
|
|
39
|
+
devices: RemoteDevice[];
|
|
40
|
+
}): void;
|
|
41
|
+
deviceRegistered(payload: {
|
|
42
|
+
deviceId: string;
|
|
43
|
+
name: string;
|
|
44
|
+
}): void;
|
|
45
|
+
deviceUnregistered(payload: {
|
|
46
|
+
deviceId: string;
|
|
47
|
+
}): void;
|
|
48
|
+
primaryChanged(payload: {
|
|
49
|
+
deviceId: string;
|
|
50
|
+
}): void;
|
|
51
|
+
nowPlaying(payload: {
|
|
52
|
+
deviceId: string;
|
|
53
|
+
deviceName: string;
|
|
54
|
+
track: RemoteNowPlaying;
|
|
55
|
+
}): void;
|
|
56
|
+
status(payload: {
|
|
57
|
+
deviceId: string;
|
|
58
|
+
deviceName: string;
|
|
59
|
+
status: RemoteStatus;
|
|
60
|
+
}): void;
|
|
61
|
+
queue(payload: {
|
|
62
|
+
deviceId: string;
|
|
63
|
+
deviceName: string;
|
|
64
|
+
index: number;
|
|
65
|
+
queue: RemoteQueueItem[];
|
|
66
|
+
}): void;
|
|
67
|
+
}
|
|
68
|
+
type EventName = keyof RemoteControllerEvents;
|
|
69
|
+
type EventHandler<K extends EventName> = RemoteControllerEvents[K];
|
|
70
|
+
export interface RemoteControllerOptions {
|
|
71
|
+
/** Rocksky access token, or a getter (read fresh on each (re)connect/send). */
|
|
72
|
+
token: string | (() => string | undefined);
|
|
73
|
+
/** Registration label (controllers are hidden from device lists). */
|
|
74
|
+
name: string;
|
|
75
|
+
/** WebSocket endpoint. Defaults to {@link DEFAULT_REMOTE_WS}. */
|
|
76
|
+
url?: string;
|
|
77
|
+
/** Heartbeat interval, ms (default 10000). */
|
|
78
|
+
heartbeatMs?: number;
|
|
79
|
+
/** Reconnect delay after a drop, ms (default 3000). */
|
|
80
|
+
reconnectMs?: number;
|
|
81
|
+
/** Optional debug logger. */
|
|
82
|
+
debug?: (...args: unknown[]) => void;
|
|
83
|
+
}
|
|
84
|
+
type Json = any;
|
|
85
|
+
export declare class RemoteController {
|
|
86
|
+
private readonly opts;
|
|
87
|
+
private ws;
|
|
88
|
+
private stopped;
|
|
89
|
+
private heartbeat?;
|
|
90
|
+
private reconnectTimer?;
|
|
91
|
+
private readonly url;
|
|
92
|
+
private readonly heartbeatMs;
|
|
93
|
+
private readonly reconnectMs;
|
|
94
|
+
private readonly getToken;
|
|
95
|
+
private readonly debug;
|
|
96
|
+
private handlers;
|
|
97
|
+
constructor(opts: RemoteControllerOptions);
|
|
98
|
+
/** Subscribe to a server event. Returns `this` for chaining. */
|
|
99
|
+
on<K extends EventName>(event: K, handler: EventHandler<K>): this;
|
|
100
|
+
/** Connect, register, and start the heartbeat + auto-reconnect loop. */
|
|
101
|
+
connect(): void;
|
|
102
|
+
/** Tear the connection down and stop reconnecting. */
|
|
103
|
+
disconnect(): void;
|
|
104
|
+
/** Choose the primary (scrobble/profile) device. */
|
|
105
|
+
setPrimary(deviceId: string): void;
|
|
106
|
+
/** `play`. `target` is a device id, or omit to broadcast to all devices. */
|
|
107
|
+
play(target?: string): void;
|
|
108
|
+
pause(target?: string): void;
|
|
109
|
+
next(target?: string): void;
|
|
110
|
+
previous(target?: string): void;
|
|
111
|
+
seek(target: string | undefined, positionMs: number): void;
|
|
112
|
+
queueJump(target: string | undefined, index: number): void;
|
|
113
|
+
queueRemove(target: string | undefined, index: number): void;
|
|
114
|
+
/** Enqueue tracks on the target device. */
|
|
115
|
+
enqueue(target: string | undefined, tracks: RemoteQueueItem[], mode?: "now" | "next" | "last", shuffle?: boolean, startIndex?: number): void;
|
|
116
|
+
/** Send an arbitrary command (escape hatch). */
|
|
117
|
+
command(action: string, target?: string, args?: Json): void;
|
|
118
|
+
private send;
|
|
119
|
+
private open;
|
|
120
|
+
private handle;
|
|
121
|
+
private handleMessage;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=remote-controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-controller.d.ts","sourceRoot":"","sources":["../src/remote-controller.ts"],"names":[],"mappings":"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"}
|