A social internet radio platform built on AT Protocol. atradio.fm
atproto radio
7

Configure Feed

Select the types of activity you want to include in your feed.

feat: atradio Connect — cross-device remote control

Spotify-Connect-style remote control across web + CLI. Every logged-in
client opens a WebSocket to a new hub in apps/api, is grouped by account
DID (verified via atproto service-auth JWT), and can list and control the
other devices on the account.

- api: WebSocket Connect hub (/connect) with per-DID device registry,
command routing, presence tracking, Redis cross-instance relay, and a
did:web service document. Service-auth JWTs verified with
@atproto/xrpc-server + @atproto/identity.
- web: device picker (navbar) + bottom "Playing on …" banner, transport
routing to the selected remote, transfer-here/there, and WS-driven
deletion of the actor.status record when no player is online.
- cli: remote module (tokio-tungstenite) making the TUI both controllable
and a controller, a device-picker overlay + text indicator, a headless
`--no-tui` daemon mode, and a settings.toml-configurable device name.
- tests: hub integration tests (ws) + web routing/transfer + device
helper tests; new GitHub Actions workflow runs web + api suites.
- i18n: new `connect` namespace for en/fr/pt.

Tsiry Sandratraina (Jul 17, 2026, 11:37 AM +0300) fd8a1ed3 0f40001e

+3157 -14
+46
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + paths: 7 + - "apps/**" 8 + - "packages/**" 9 + - "package.json" 10 + - "bun.lock" 11 + - "turbo.json" 12 + - ".github/workflows/test.yml" 13 + pull_request: 14 + paths: 15 + - "apps/**" 16 + - "packages/**" 17 + - "package.json" 18 + - "bun.lock" 19 + - "turbo.json" 20 + - ".github/workflows/test.yml" 21 + workflow_dispatch: 22 + 23 + jobs: 24 + test: 25 + name: typecheck + test (web + api) 26 + runs-on: ubuntu-latest 27 + steps: 28 + - uses: actions/checkout@v4 29 + 30 + - uses: actions/setup-node@v4 31 + with: 32 + node-version: "24" 33 + 34 + - uses: oven-sh/setup-bun@v2 35 + with: 36 + bun-version: "1.3.14" 37 + 38 + - name: Install dependencies 39 + run: bun install --frozen-lockfile 40 + 41 + # Typecheck + run the vitest suites for the web app and the API. 42 + - name: Typecheck 43 + run: bunx turbo run typecheck --filter='./apps/*' 44 + 45 + - name: Test 46 + run: bunx turbo run test --filter='./apps/*'
+2
apps/api/package.json
··· 23 23 "@atcute/client": "^4.2.1", 24 24 "@atcute/identity-resolver": "^1.2.2", 25 25 "@atcute/lexicons": "^1.3.1", 26 + "@atproto/identity": "^0.5.6", 27 + "@atproto/xrpc-server": "^0.11.10", 26 28 "@atradio/lexicons": "workspace:*", 27 29 "consola": "^3.3.3", 28 30 "cors": "^2.8.5",
+22 -1
apps/api/src/app.ts
··· 1 1 import express, { type Express } from "express"; 2 2 import cors from "cors"; 3 + import { env } from "./env"; 3 4 import { xrpcRouter } from "./xrpc"; 4 5 import { proxyRouter } from "./proxy"; 5 6 import { liveRouter } from "./live"; ··· 12 13 app.use(express.json()); 13 14 14 15 app.get("/health", (_req, res) => { 15 - res.json({ ok: true, service: "atradio-api" }); 16 + res.json({ 17 + ok: true, 18 + service: "atradio-api", 19 + connectDid: env.CONNECT_SERVICE_DID, 20 + }); 21 + }); 22 + 23 + // did:web document for the Connect service DID, so the `aud` clients bind 24 + // their service-auth tokens to is a resolvable identifier. 25 + app.get("/.well-known/did.json", (_req, res) => { 26 + res.json({ 27 + "@context": ["https://www.w3.org/ns/did/v1"], 28 + id: env.CONNECT_SERVICE_DID, 29 + service: [ 30 + { 31 + id: "#atradio_appview", 32 + type: "AtradioAppView", 33 + serviceEndpoint: "https://api.atradio.fm", 34 + }, 35 + ], 36 + }); 16 37 }); 17 38 18 39 // XRPC query endpoints: /xrpc/fm.atradio.*
+5 -1
apps/api/src/bin/server.ts
··· 1 + import { createServer } from "node:http"; 1 2 import { consola } from "consola"; 2 3 import { env } from "../env"; 3 4 import { createApp } from "../app"; 5 + import { attachConnectHub } from "../connect/hub"; 4 6 5 7 const app = createApp(); 6 - app.listen(env.PORT, () => { 8 + const server = createServer(app); 9 + attachConnectHub(server); 10 + server.listen(env.PORT, () => { 7 11 consola.success(`[api] listening on :${env.PORT}`); 8 12 });
+35
apps/api/src/connect/auth.ts
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import { verifyJwt } from "@atproto/xrpc-server"; 3 + import { env } from "../env"; 4 + import { CONNECT_LXM } from "./protocol"; 5 + 6 + /** 7 + * Verifies an atproto **service-auth JWT** and returns the issuer DID. 8 + * 9 + * The token is minted by the client's PDS (`com.atproto.server.getServiceAuth`) 10 + * and signed with the account's signing key. We resolve that key from the 11 + * issuer's DID document and check the signature, the audience (must be this 12 + * AppView's DID), the expiry, and the method binding (`lxm`). This is the only 13 + * proof a WebSocket connection genuinely belongs to the DID it claims — without 14 + * it, anyone could watch or hijack another user's playback. 15 + */ 16 + const idResolver = new IdResolver(); 17 + 18 + async function getSigningKey( 19 + did: string, 20 + forceRefresh: boolean, 21 + ): Promise<string> { 22 + const data = await idResolver.did.resolveAtprotoData(did, forceRefresh); 23 + return data.signingKey; 24 + } 25 + 26 + /** Verify a service-auth JWT; returns the issuer DID or throws. */ 27 + export async function verifyConnectToken(token: string): Promise<string> { 28 + const payload = await verifyJwt( 29 + token, 30 + env.CONNECT_SERVICE_DID, 31 + CONNECT_LXM, 32 + getSigningKey, 33 + ); 34 + return payload.iss; 35 + }
+192
apps/api/src/connect/hub.test.ts
··· 1 + import { createServer, type Server } from "node:http"; 2 + import type { AddressInfo } from "node:net"; 3 + import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; 4 + import { WebSocket } from "ws"; 5 + import { attachConnectHub, _resetConnectState } from "./hub"; 6 + import type { PlaybackState, ServerMsg } from "./protocol"; 7 + 8 + /** 9 + * End-to-end tests for the Connect hub over a real WebSocket server. The 10 + * service-auth verifier is stubbed to treat the token as the account DID, so 11 + * we exercise routing/roster/presence without real atproto crypto. 12 + */ 13 + 14 + let server: Server; 15 + let url: string; 16 + 17 + beforeAll(async () => { 18 + server = createServer(); 19 + // Token === DID: lets tests group devices by choosing the token they send. 20 + // An empty token "fails verification" so we can test rejection. 21 + attachConnectHub(server, { 22 + verifyToken: async (token) => { 23 + if (!token) throw new Error("invalid token"); 24 + return token; 25 + }, 26 + }); 27 + await new Promise<void>((resolve) => server.listen(0, resolve)); 28 + const port = (server.address() as AddressInfo).port; 29 + url = `ws://127.0.0.1:${port}/connect`; 30 + }); 31 + 32 + afterAll(() => { 33 + server.close(); 34 + }); 35 + 36 + const open: TestClient[] = []; 37 + 38 + afterEach(() => { 39 + for (const c of open.splice(0)) c.close(); 40 + _resetConnectState(); 41 + }); 42 + 43 + const state = (over: Partial<PlaybackState> = {}): PlaybackState => ({ 44 + playing: false, 45 + station: null, 46 + volume: 0.5, 47 + muted: false, 48 + ...over, 49 + }); 50 + 51 + interface TestClient { 52 + send: (o: unknown) => void; 53 + next: () => Promise<ServerMsg>; 54 + until: (pred: (m: ServerMsg) => boolean) => Promise<ServerMsg>; 55 + close: () => void; 56 + } 57 + 58 + function makeClient(): Promise<TestClient> { 59 + const ws = new WebSocket(url); 60 + const inbox: ServerMsg[] = []; 61 + const waiters: ((m: ServerMsg) => void)[] = []; 62 + ws.on("message", (data) => { 63 + const msg = JSON.parse(data.toString()) as ServerMsg; 64 + const w = waiters.shift(); 65 + if (w) w(msg); 66 + else inbox.push(msg); 67 + }); 68 + const next = () => 69 + new Promise<ServerMsg>((resolve) => { 70 + const m = inbox.shift(); 71 + if (m) resolve(m); 72 + else waiters.push(resolve); 73 + }); 74 + const client: TestClient = { 75 + send: (o) => ws.send(JSON.stringify(o)), 76 + next, 77 + until: async (pred) => { 78 + for (;;) { 79 + const m = await withTimeout(next()); 80 + if (pred(m)) return m; 81 + } 82 + }, 83 + close: () => ws.close(), 84 + }; 85 + open.push(client); 86 + return new Promise((resolve) => ws.on("open", () => resolve(client))); 87 + } 88 + 89 + function withTimeout<T>(p: Promise<T>, ms = 2000): Promise<T> { 90 + return Promise.race([ 91 + p, 92 + new Promise<T>((_, reject) => 93 + setTimeout(() => reject(new Error("timeout waiting for message")), ms), 94 + ), 95 + ]); 96 + } 97 + 98 + async function hello( 99 + did: string, 100 + id: string, 101 + st: PlaybackState = state(), 102 + ): Promise<TestClient> { 103 + const c = await makeClient(); 104 + c.send({ 105 + t: "hello", 106 + token: did, 107 + device: { id, name: id, platform: "web", state: st }, 108 + }); 109 + await c.until((m) => m.t === "welcome"); 110 + return c; 111 + } 112 + 113 + describe("connect hub", () => { 114 + it("acknowledges a device and reports it in the roster", async () => { 115 + const a = await hello("did:test:alice", "A"); 116 + const devices = await a.until((m) => m.t === "devices"); 117 + if (devices.t !== "devices") throw new Error("unreachable"); 118 + expect(devices.devices.map((d) => d.id)).toContain("A"); 119 + expect(devices.devices.find((d) => d.id === "A")?.self).toBe(true); 120 + }); 121 + 122 + it("shows both devices of the same account to each other", async () => { 123 + const a = await hello("did:test:alice", "A"); 124 + await hello("did:test:alice", "B"); 125 + const roster = await a.until( 126 + (m) => m.t === "devices" && m.devices.length === 2, 127 + ); 128 + if (roster.t !== "devices") throw new Error("unreachable"); 129 + expect(roster.devices.map((d) => d.id).sort()).toEqual(["A", "B"]); 130 + }); 131 + 132 + it("routes a command from one device to the targeted peer", async () => { 133 + const a = await hello("did:test:alice", "A"); 134 + const b = await hello("did:test:alice", "B"); 135 + a.send({ t: "command", target: "B", cmd: { action: "play" } }); 136 + const cmd = await b.until((m) => m.t === "command"); 137 + if (cmd.t !== "command") throw new Error("unreachable"); 138 + expect(cmd.cmd).toEqual({ action: "play" }); 139 + expect(cmd.from).toBe("A"); 140 + }); 141 + 142 + it("broadcasts a peer's playback state to the roster", async () => { 143 + const a = await hello("did:test:alice", "A"); 144 + const b = await hello("did:test:alice", "B"); 145 + b.send({ 146 + t: "state", 147 + state: state({ 148 + playing: true, 149 + station: { id: "rb:1", name: "Synthwave FM", url: "http://x/s" }, 150 + }), 151 + }); 152 + const roster = await a.until( 153 + (m) => 154 + m.t === "devices" && 155 + !!m.devices.find((d) => d.id === "B")?.state.playing, 156 + ); 157 + if (roster.t !== "devices") throw new Error("unreachable"); 158 + expect(roster.devices.find((d) => d.id === "B")?.state.station?.name).toBe( 159 + "Synthwave FM", 160 + ); 161 + }); 162 + 163 + it("asks a device to clean up status when playback stops everywhere", async () => { 164 + const a = await hello("did:test:alice", "A", state({ playing: true })); 165 + // Stop playing → presence transitions true→false → cleanup request. 166 + a.send({ t: "state", state: state({ playing: false }) }); 167 + const presence = await a.until((m) => m.t === "presence" && !!m.cleanup); 168 + expect(presence.t).toBe("presence"); 169 + }); 170 + 171 + it("isolates devices across different accounts", async () => { 172 + await hello("did:test:alice", "A"); 173 + const bob = await hello("did:test:bob", "Z"); 174 + const roster = await bob.until((m) => m.t === "devices"); 175 + if (roster.t !== "devices") throw new Error("unreachable"); 176 + expect(roster.devices.map((d) => d.id)).toEqual(["Z"]); 177 + expect(roster.devices.map((d) => d.id)).not.toContain("A"); 178 + }); 179 + 180 + it("rejects a device whose token fails verification", async () => { 181 + // The stub verifier throws on an empty token. 182 + const c = await makeClient(); 183 + c.send({ 184 + t: "hello", 185 + token: "", 186 + device: { id: "bad", name: "bad", platform: "web", state: state() }, 187 + }); 188 + const err = await c.until((m) => m.t === "error"); 189 + if (err.t !== "error") throw new Error("unreachable"); 190 + expect(err.code).toBe("AuthFailed"); 191 + }); 192 + });
+489
apps/api/src/connect/hub.ts
··· 1 + import type { Server } from "node:http"; 2 + import type { Duplex } from "node:stream"; 3 + import { WebSocketServer, WebSocket } from "ws"; 4 + import { Redis } from "ioredis"; 5 + import { consola } from "consola"; 6 + import { env } from "../env"; 7 + import { verifyConnectToken } from "./auth"; 8 + import type { 9 + ClientMsg, 10 + Command, 11 + DeviceInfo, 12 + PlaybackState, 13 + Platform, 14 + ServerMsg, 15 + } from "./protocol"; 16 + 17 + /** 18 + * atradio Connect hub — a WebSocket server that groups every logged-in client 19 + * by its account DID and routes remote-control commands between the devices on 20 + * that account (Spotify-Connect style). 21 + * 22 + * The in-process registry is authoritative for connections on this instance. 23 + * When Redis is configured the hub also replicates presence and relays commands 24 + * across instances, so a horizontally-scaled deployment still lets devices on 25 + * different processes see and control each other. With no Redis (the default 26 + * single-process deployment) everything runs locally. 27 + */ 28 + 29 + const REDIS_CHANNEL = "atradio:connect"; 30 + /** Kill a connection that never sends a valid `hello`. */ 31 + const HELLO_TIMEOUT_MS = 10_000; 32 + /** WS ping + remote-presence heartbeat cadence. */ 33 + const HEARTBEAT_MS = 30_000; 34 + /** Remote devices unheard-of for this long are dropped. */ 35 + const REMOTE_TTL_MS = HEARTBEAT_MS * 3; 36 + 37 + /** A device connected to *this* instance. */ 38 + interface LocalConn { 39 + ws: WebSocket; 40 + did: string; 41 + deviceId: string; 42 + name: string; 43 + platform: Platform; 44 + state: PlaybackState; 45 + connectedAt: number; 46 + alive: boolean; 47 + } 48 + 49 + /** A device connected to another instance, learned via Redis. */ 50 + interface RemoteDevice { 51 + did: string; 52 + deviceId: string; 53 + name: string; 54 + platform: Platform; 55 + state: PlaybackState; 56 + connectedAt: number; 57 + origin: string; 58 + lastSeen: number; 59 + } 60 + 61 + type RelayMsg = 62 + | { type: "join" | "update"; instance: string; device: WireDevice } 63 + | { type: "leave"; instance: string; did: string; deviceId: string } 64 + | { 65 + type: "command"; 66 + instance: string; 67 + did: string; 68 + target: string; 69 + from: string; 70 + cmd: Command; 71 + }; 72 + 73 + interface WireDevice { 74 + did: string; 75 + deviceId: string; 76 + name: string; 77 + platform: Platform; 78 + state: PlaybackState; 79 + connectedAt: number; 80 + } 81 + 82 + /** did → deviceId → conn */ 83 + const local = new Map<string, Map<string, LocalConn>>(); 84 + /** did → deviceId → remote device */ 85 + const remote = new Map<string, Map<string, RemoteDevice>>(); 86 + /** did → last computed anyPlaying, to detect the true→false transition. */ 87 + const lastAnyPlaying = new Map<string, boolean>(); 88 + 89 + /** A per-process id, so we ignore the echoes of our own Redis publishes. */ 90 + const INSTANCE_ID = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`; 91 + 92 + /** Token verifier — swappable in tests via `attachConnectHub`'s options. */ 93 + let verifyToken: (token: string) => Promise<string> = verifyConnectToken; 94 + 95 + let pub: Redis | null = null; 96 + 97 + function send(ws: WebSocket, msg: ServerMsg): void { 98 + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); 99 + } 100 + 101 + function localMap(did: string): Map<string, LocalConn> { 102 + let m = local.get(did); 103 + if (!m) { 104 + m = new Map(); 105 + local.set(did, m); 106 + } 107 + return m; 108 + } 109 + 110 + function remoteMap(did: string): Map<string, RemoteDevice> { 111 + let m = remote.get(did); 112 + if (!m) { 113 + m = new Map(); 114 + remote.set(did, m); 115 + } 116 + return m; 117 + } 118 + 119 + function toDeviceInfo( 120 + d: LocalConn | RemoteDevice, 121 + selfId: string, 122 + ): DeviceInfo { 123 + return { 124 + id: d.deviceId, 125 + name: d.name, 126 + platform: d.platform, 127 + self: d.deviceId === selfId, 128 + state: d.state, 129 + }; 130 + } 131 + 132 + /** Merged roster for a DID (local wins over a stale remote copy). */ 133 + function roster(did: string): (LocalConn | RemoteDevice)[] { 134 + const byId = new Map<string, LocalConn | RemoteDevice>(); 135 + for (const d of remoteMap(did).values()) byId.set(d.deviceId, d); 136 + for (const d of localMap(did).values()) byId.set(d.deviceId, d); 137 + return [...byId.values()].sort((a, b) => a.connectedAt - b.connectedAt); 138 + } 139 + 140 + function anyPlaying(did: string): boolean { 141 + return roster(did).some((d) => d.state.playing); 142 + } 143 + 144 + /** Push a fresh roster to every locally-connected device of this DID. */ 145 + function broadcastRoster(did: string): void { 146 + const all = roster(did); 147 + for (const conn of localMap(did).values()) { 148 + send(conn.ws, { 149 + t: "devices", 150 + devices: all.map((d) => toDeviceInfo(d, conn.deviceId)), 151 + }); 152 + } 153 + } 154 + 155 + /** 156 + * The device responsible for cleaning up the durable `actor.status` record when 157 + * nobody is playing: deterministically the lowest deviceId across the account, 158 + * so exactly one instance acts. Returns its local conn, or null if that device 159 + * lives on another instance. 160 + */ 161 + function cleanupOwner(did: string): LocalConn | null { 162 + const all = roster(did); 163 + if (all.length === 0) return null; 164 + const owner = all.reduce((a, b) => (a.deviceId < b.deviceId ? a : b)); 165 + return localMap(did).get(owner.deviceId) ?? null; 166 + } 167 + 168 + /** 169 + * Recompute presence for a DID and, on the true→false transition, ask the 170 + * cleanup owner to delete the account's `actor.status` record (only a client 171 + * can write to its own PDS). 172 + */ 173 + function reconcilePresence(did: string): void { 174 + const now = anyPlaying(did); 175 + const before = lastAnyPlaying.get(did) ?? false; 176 + lastAnyPlaying.set(did, now); 177 + if (before && !now) { 178 + const owner = cleanupOwner(did); 179 + if (owner) send(owner.ws, { t: "presence", anyPlaying: false, cleanup: true }); 180 + } 181 + // Broadcast the summary (without cleanup) to everyone else so UIs update. 182 + for (const conn of localMap(did).values()) { 183 + send(conn.ws, { t: "presence", anyPlaying: now }); 184 + } 185 + } 186 + 187 + function publish(msg: RelayMsg): void { 188 + if (pub) void pub.publish(REDIS_CHANNEL, JSON.stringify(msg)).catch(() => {}); 189 + } 190 + 191 + function wire(conn: LocalConn): WireDevice { 192 + return { 193 + did: conn.did, 194 + deviceId: conn.deviceId, 195 + name: conn.name, 196 + platform: conn.platform, 197 + state: conn.state, 198 + connectedAt: conn.connectedAt, 199 + }; 200 + } 201 + 202 + /** Deliver a command to a locally-connected target; returns whether it landed. */ 203 + function deliverCommand( 204 + did: string, 205 + target: string, 206 + from: string, 207 + cmd: Command, 208 + ): boolean { 209 + const conn = localMap(did).get(target); 210 + if (!conn) return false; 211 + send(conn.ws, { t: "command", from, cmd }); 212 + return true; 213 + } 214 + 215 + // ---- inbound client message handling -------------------------------------- 216 + 217 + function handleState(conn: LocalConn, state: PlaybackState): void { 218 + conn.state = state; 219 + publish({ type: "update", instance: INSTANCE_ID, device: wire(conn) }); 220 + broadcastRoster(conn.did); 221 + reconcilePresence(conn.did); 222 + } 223 + 224 + function handleCommand( 225 + conn: LocalConn, 226 + target: string, 227 + cmd: Command, 228 + ): void { 229 + if (target === conn.deviceId) return; // no self-commands 230 + if (deliverCommand(conn.did, target, conn.deviceId, cmd)) return; 231 + // Target lives elsewhere (or nowhere): relay across instances. 232 + publish({ 233 + type: "command", 234 + instance: INSTANCE_ID, 235 + did: conn.did, 236 + target, 237 + from: conn.deviceId, 238 + cmd, 239 + }); 240 + } 241 + 242 + function removeConn(conn: LocalConn): void { 243 + const m = local.get(conn.did); 244 + if (!m || m.get(conn.deviceId) !== conn) return; 245 + m.delete(conn.deviceId); 246 + if (m.size === 0) local.delete(conn.did); 247 + publish({ 248 + type: "leave", 249 + instance: INSTANCE_ID, 250 + did: conn.did, 251 + deviceId: conn.deviceId, 252 + }); 253 + broadcastRoster(conn.did); 254 + reconcilePresence(conn.did); 255 + } 256 + 257 + async function onHello( 258 + ws: WebSocket, 259 + raw: Extract<ClientMsg, { t: "hello" }>, 260 + ): Promise<LocalConn | null> { 261 + let did: string; 262 + try { 263 + did = await verifyToken(raw.token); 264 + } catch (err) { 265 + send(ws, { 266 + t: "error", 267 + code: "AuthFailed", 268 + message: err instanceof Error ? err.message : "invalid token", 269 + }); 270 + ws.close(4001, "auth failed"); 271 + return null; 272 + } 273 + 274 + const conn: LocalConn = { 275 + ws, 276 + did, 277 + deviceId: raw.device.id, 278 + name: raw.device.name || "Unknown device", 279 + platform: raw.device.platform, 280 + state: raw.device.state, 281 + connectedAt: Date.now(), 282 + alive: true, 283 + }; 284 + 285 + // Replace any prior connection with the same deviceId (reconnect / dup tab). 286 + const m = localMap(did); 287 + const prior = m.get(conn.deviceId); 288 + if (prior && prior.ws !== ws) prior.ws.close(4002, "replaced"); 289 + m.set(conn.deviceId, conn); 290 + 291 + const wasEmpty = roster(did).length === 1; // just us 292 + send(ws, { t: "welcome", did, deviceId: conn.deviceId }); 293 + publish({ type: "join", instance: INSTANCE_ID, device: wire(conn) }); 294 + broadcastRoster(did); 295 + 296 + const playing = anyPlaying(did); 297 + lastAnyPlaying.set(did, playing); 298 + send(ws, { t: "presence", anyPlaying: playing }); 299 + // Fresh, lone session that isn't playing: clear a status record potentially 300 + // left dangling by a crashed previous session. 301 + if (wasEmpty && !conn.state.playing && !playing) { 302 + send(ws, { t: "presence", anyPlaying: false, cleanup: true }); 303 + } 304 + return conn; 305 + } 306 + 307 + // ---- Redis relay (cross-instance) ----------------------------------------- 308 + 309 + function pruneRemote(did: string): void { 310 + const m = remote.get(did); 311 + if (!m) return; 312 + const cutoff = Date.now() - REMOTE_TTL_MS; 313 + let changed = false; 314 + for (const [id, d] of m) { 315 + if (d.lastSeen < cutoff) { 316 + m.delete(id); 317 + changed = true; 318 + } 319 + } 320 + if (m.size === 0) remote.delete(did); 321 + if (changed) { 322 + broadcastRoster(did); 323 + reconcilePresence(did); 324 + } 325 + } 326 + 327 + function onRelay(msg: RelayMsg): void { 328 + if (msg.instance === INSTANCE_ID) return; // our own echo 329 + if (msg.type === "command") { 330 + deliverCommand(msg.did, msg.target, msg.from, msg.cmd); 331 + return; 332 + } 333 + if (msg.type === "leave") { 334 + const m = remote.get(msg.did); 335 + if (m?.delete(msg.deviceId)) { 336 + broadcastRoster(msg.did); 337 + reconcilePresence(msg.did); 338 + } 339 + return; 340 + } 341 + // join | update 342 + const d = msg.device; 343 + remoteMap(d.did).set(d.deviceId, { 344 + ...d, 345 + origin: msg.instance, 346 + lastSeen: Date.now(), 347 + }); 348 + broadcastRoster(d.did); 349 + reconcilePresence(d.did); 350 + } 351 + 352 + /** Re-announce our local devices so peers refresh their remote view + TTL. */ 353 + function heartbeatRemotePresence(): void { 354 + for (const m of local.values()) { 355 + for (const conn of m.values()) { 356 + publish({ type: "update", instance: INSTANCE_ID, device: wire(conn) }); 357 + } 358 + } 359 + for (const did of remote.keys()) pruneRemote(did); 360 + } 361 + 362 + function setupRedis(): void { 363 + if (!env.REDIS_URL) return; 364 + pub = new Redis(env.REDIS_URL, { 365 + maxRetriesPerRequest: null, 366 + retryStrategy: (n) => Math.min(n * 500, 5000), 367 + }); 368 + pub.on("error", (e) => consola.warn("[connect pub]", e.message)); 369 + const sub = new Redis(env.REDIS_URL, { 370 + maxRetriesPerRequest: null, 371 + retryStrategy: (n) => Math.min(n * 500, 5000), 372 + }); 373 + sub.on("error", (e) => consola.warn("[connect sub]", e.message)); 374 + sub.on("ready", () => consola.success("[connect] redis relay ready")); 375 + void sub.subscribe(REDIS_CHANNEL).catch(() => {}); 376 + sub.on("message", (_ch, payload) => { 377 + try { 378 + onRelay(JSON.parse(payload) as RelayMsg); 379 + } catch { 380 + /* ignore malformed relay */ 381 + } 382 + }); 383 + } 384 + 385 + // ---- server attach --------------------------------------------------------- 386 + 387 + /** Options for {@link attachConnectHub}. */ 388 + export interface ConnectHubOptions { 389 + /** Override the service-auth token verifier (used by tests). */ 390 + verifyToken?: (token: string) => Promise<string>; 391 + } 392 + 393 + /** 394 + * Attach the Connect hub to an HTTP server, handling `upgrade` requests for the 395 + * `/connect` path. Call once at boot with the same server Express listens on. 396 + */ 397 + export function attachConnectHub( 398 + server: Server, 399 + opts: ConnectHubOptions = {}, 400 + ): void { 401 + if (opts.verifyToken) verifyToken = opts.verifyToken; 402 + setupRedis(); 403 + const wss = new WebSocketServer({ noServer: true }); 404 + 405 + server.on("upgrade", (req, socket: Duplex, head) => { 406 + let pathname = ""; 407 + try { 408 + pathname = new URL(req.url ?? "", "http://localhost").pathname; 409 + } catch { 410 + /* ignore */ 411 + } 412 + if (pathname !== "/connect") return; // not ours; leave for others 413 + wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws)); 414 + }); 415 + 416 + wss.on("connection", (ws: WebSocket) => { 417 + let conn: LocalConn | null = null; 418 + 419 + const helloTimer = setTimeout(() => { 420 + if (!conn) ws.close(4008, "hello timeout"); 421 + }, HELLO_TIMEOUT_MS); 422 + 423 + ws.on("message", (data) => { 424 + let msg: ClientMsg; 425 + try { 426 + msg = JSON.parse(data.toString()) as ClientMsg; 427 + } catch { 428 + return; 429 + } 430 + if (!conn) { 431 + if (msg.t !== "hello") return; 432 + void onHello(ws, msg).then((c) => { 433 + clearTimeout(helloTimer); 434 + conn = c; 435 + }); 436 + return; 437 + } 438 + switch (msg.t) { 439 + case "state": 440 + handleState(conn, msg.state); 441 + break; 442 + case "command": 443 + handleCommand(conn, msg.target, msg.cmd); 444 + break; 445 + case "bye": 446 + ws.close(1000, "bye"); 447 + break; 448 + } 449 + }); 450 + 451 + ws.on("pong", () => { 452 + if (conn) conn.alive = true; 453 + }); 454 + 455 + ws.on("close", () => { 456 + clearTimeout(helloTimer); 457 + if (conn) removeConn(conn); 458 + }); 459 + 460 + ws.on("error", () => { 461 + /* close handler does the cleanup */ 462 + }); 463 + }); 464 + 465 + // Liveness ping + remote-presence heartbeat. 466 + const timer = setInterval(() => { 467 + for (const m of local.values()) { 468 + for (const conn of m.values()) { 469 + if (!conn.alive) { 470 + conn.ws.terminate(); 471 + continue; 472 + } 473 + conn.alive = false; 474 + conn.ws.ping(); 475 + } 476 + } 477 + heartbeatRemotePresence(); 478 + }, HEARTBEAT_MS); 479 + timer.unref?.(); 480 + 481 + consola.success("[connect] hub attached at /connect"); 482 + } 483 + 484 + /** Clear all in-memory registries. Test-only. */ 485 + export function _resetConnectState(): void { 486 + local.clear(); 487 + remote.clear(); 488 + lastAnyPlaying.clear(); 489 + }
+129
apps/api/src/connect/protocol.ts
··· 1 + /** 2 + * atradio Connect — the wire protocol shared by every client (web + CLI) and 3 + * the hub. Spotify-Connect-style remote control: each logged-in client opens a 4 + * WebSocket to `/connect`, is grouped by its account DID, and can list and 5 + * control the other devices on the same account. 6 + * 7 + * Frames are JSON text messages. The web and CLI keep their own copies of these 8 + * shapes (TypeScript / Rust); this file is the source of truth — keep them in 9 + * sync. 10 + * 11 + * The `lxm` (lexicon method) that clients bind their service-auth JWT to. 12 + */ 13 + export const CONNECT_LXM = "fm.atradio.connect"; 14 + 15 + /** A minimal station description — everything needed to start playback. */ 16 + export interface StationLite { 17 + id: string; 18 + name: string; 19 + /** The stream URL to play. */ 20 + url: string; 21 + /** Station logo / favicon, if any. */ 22 + favicon?: string; 23 + } 24 + 25 + /** A device's current playback snapshot, broadcast to its peers. */ 26 + export interface PlaybackState { 27 + playing: boolean; 28 + station: StationLite | null; 29 + /** ICY "now playing" title, when known. */ 30 + title?: string; 31 + /** 0..1 */ 32 + volume: number; 33 + muted: boolean; 34 + } 35 + 36 + export type Platform = "web" | "cli" | "other"; 37 + 38 + /** A device as seen in the roster. `self` is stamped per-recipient. */ 39 + export interface DeviceInfo { 40 + id: string; 41 + name: string; 42 + platform: Platform; 43 + /** True in the roster copy sent to this same device. */ 44 + self?: boolean; 45 + state: PlaybackState; 46 + } 47 + 48 + /** A transport/control command targeted at a device. */ 49 + export type Command = 50 + | { action: "playPause" } 51 + | { action: "play" } 52 + | { action: "pause" } 53 + | { action: "stop" } 54 + | { action: "setVolume"; value: number } 55 + | { action: "toggleMute" } 56 + | { action: "playStation"; station: StationLite }; 57 + 58 + // ---- client → server ------------------------------------------------------- 59 + 60 + export interface HelloMsg { 61 + t: "hello"; 62 + /** atproto service-auth JWT (aud = this AppView's DID, lxm = CONNECT_LXM). */ 63 + token: string; 64 + device: { 65 + id: string; 66 + name: string; 67 + platform: Platform; 68 + state: PlaybackState; 69 + }; 70 + } 71 + 72 + export interface StateMsg { 73 + t: "state"; 74 + state: PlaybackState; 75 + } 76 + 77 + export interface CommandMsg { 78 + t: "command"; 79 + /** deviceId of the target device on the same account. */ 80 + target: string; 81 + cmd: Command; 82 + } 83 + 84 + export type ClientMsg = HelloMsg | StateMsg | CommandMsg | { t: "bye" }; 85 + 86 + // ---- server → client ------------------------------------------------------- 87 + 88 + export interface WelcomeMsg { 89 + t: "welcome"; 90 + did: string; 91 + deviceId: string; 92 + } 93 + 94 + export interface DevicesMsg { 95 + t: "devices"; 96 + devices: DeviceInfo[]; 97 + } 98 + 99 + /** A command routed from a peer that this device should apply locally. */ 100 + export interface IncomingCommandMsg { 101 + t: "command"; 102 + from: string; 103 + cmd: Command; 104 + } 105 + 106 + /** 107 + * Presence summary for the account. `cleanup` asks this device to delete the 108 + * durable `fm.atradio.actor.status` record because no player is online/playing 109 + * anymore (the hub picks one online device to do it, since only a client can 110 + * write to its own PDS). 111 + */ 112 + export interface PresenceMsg { 113 + t: "presence"; 114 + anyPlaying: boolean; 115 + cleanup?: boolean; 116 + } 117 + 118 + export interface ErrorMsg { 119 + t: "error"; 120 + code: string; 121 + message: string; 122 + } 123 + 124 + export type ServerMsg = 125 + | WelcomeMsg 126 + | DevicesMsg 127 + | IncomingCommandMsg 128 + | PresenceMsg 129 + | ErrorMsg;
+7
apps/api/src/env.ts
··· 13 13 /** Optional Redis cache; caching is disabled when unset. */ 14 14 REDIS_URL: process.env.REDIS_URL ?? "", 15 15 PORT: Number(process.env.PORT ?? 8080), 16 + /** 17 + * This AppView's own DID — the `aud` (audience) that clients bind their 18 + * atproto service-auth JWTs to when connecting to the Connect hub. Clients 19 + * must request a token for exactly this DID. Served (as did:web) at 20 + * `/.well-known/did.json` so the identifier is resolvable. 21 + */ 22 + CONNECT_SERVICE_DID: process.env.CONNECT_SERVICE_DID ?? "did:web:api.atradio.fm", 16 23 /** All Jetstream hosts we connect to simultaneously for redundancy. */ 17 24 JETSTREAM_HOSTS: (process.env.JETSTREAM_HOSTS 18 25 ? process.env.JETSTREAM_HOSTS.split(",")
+5 -1
apps/api/src/index.ts
··· 1 + import { createServer } from "node:http"; 1 2 import { consola } from "consola"; 2 3 import { env } from "./env"; 3 4 import { createApp } from "./app"; 5 + import { attachConnectHub } from "./connect/hub"; 4 6 import { startJetstream } from "./jetstream/consumer"; 5 7 6 8 /** Combined entrypoint: Express server + Jetstream consumer in one process. */ 7 9 const app = createApp(); 8 - app.listen(env.PORT, () => { 10 + const server = createServer(app); 11 + attachConnectHub(server); 12 + server.listen(env.PORT, () => { 9 13 consola.success(`[api] listening on :${env.PORT}`); 10 14 }); 11 15
+114
apps/web/src/atoms/connect.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it } from "vitest"; 2 + import { createStore } from "jotai"; 3 + import type { Command } from "@/lib/connect/protocol"; 4 + import { setConnectClient } from "@/lib/connect/client"; 5 + import type { Station } from "@/lib/types"; 6 + import { 7 + currentStationAtom, 8 + isPlayingAtom, 9 + playStationAtom, 10 + togglePlayAtom, 11 + } from "./player"; 12 + import { devicesAtom, remoteTargetIdAtom, selectDeviceAtom } from "./connect"; 13 + 14 + const STATION: Station = { 15 + id: "rb:1", 16 + name: "Local FM", 17 + streamUrl: "http://x/local", 18 + source: "radio-browser", 19 + }; 20 + 21 + /** A stub Connect client that records the commands sent to it. */ 22 + function stubClient() { 23 + const sent: { target: string; cmd: Command }[] = []; 24 + setConnectClient({ 25 + command: (target: string, cmd: Command) => sent.push({ target, cmd }), 26 + } as never); 27 + return sent; 28 + } 29 + 30 + let sent: { target: string; cmd: Command }[]; 31 + let store: ReturnType<typeof createStore>; 32 + 33 + beforeEach(() => { 34 + sent = stubClient(); 35 + store = createStore(); 36 + }); 37 + 38 + afterEach(() => { 39 + setConnectClient(null); 40 + }); 41 + 42 + describe("playStationAtom routing", () => { 43 + it("plays locally when no remote is selected", () => { 44 + store.set(playStationAtom, STATION); 45 + expect(store.get(currentStationAtom)?.id).toBe("rb:1"); 46 + expect(store.get(isPlayingAtom)).toBe(true); 47 + expect(sent).toHaveLength(0); 48 + }); 49 + 50 + it("sends the station to the remote when one is selected", () => { 51 + store.set(remoteTargetIdAtom, "B"); 52 + store.set(playStationAtom, STATION); 53 + // No local playback… 54 + expect(store.get(currentStationAtom)).toBeNull(); 55 + // …a command went to the remote instead. 56 + expect(sent).toEqual([ 57 + { 58 + target: "B", 59 + cmd: { 60 + action: "playStation", 61 + station: { id: "rb:1", name: "Local FM", url: "http://x/local" }, 62 + }, 63 + }, 64 + ]); 65 + }); 66 + 67 + it("routes play/pause to the remote", () => { 68 + store.set(remoteTargetIdAtom, "B"); 69 + store.set(togglePlayAtom); 70 + expect(sent).toEqual([{ target: "B", cmd: { action: "playPause" } }]); 71 + }); 72 + }); 73 + 74 + describe("selectDeviceAtom transfer", () => { 75 + it("hands local playback to the chosen device", () => { 76 + store.set(currentStationAtom, STATION); 77 + store.set(isPlayingAtom, true); 78 + store.set(selectDeviceAtom, "B"); 79 + 80 + expect(store.get(remoteTargetIdAtom)).toBe("B"); 81 + expect(store.get(isPlayingAtom)).toBe(false); // local silenced 82 + expect(sent[0]).toEqual({ 83 + target: "B", 84 + cmd: { 85 + action: "playStation", 86 + station: { id: "rb:1", name: "Local FM", url: "http://x/local" }, 87 + }, 88 + }); 89 + }); 90 + 91 + it("pulls playback back to this device and stops the remote", () => { 92 + store.set(devicesAtom, [ 93 + { 94 + id: "B", 95 + name: "Kitchen", 96 + platform: "cli", 97 + state: { 98 + playing: true, 99 + station: { id: "rb:2", name: "Remote FM", url: "http://x/remote" }, 100 + volume: 0.6, 101 + muted: false, 102 + }, 103 + }, 104 + ]); 105 + store.set(remoteTargetIdAtom, "B"); 106 + 107 + store.set(selectDeviceAtom, null); 108 + 109 + expect(store.get(remoteTargetIdAtom)).toBeNull(); 110 + expect(store.get(currentStationAtom)?.id).toBe("rb:2"); 111 + expect(store.get(isPlayingAtom)).toBe(true); 112 + expect(sent).toContainEqual({ target: "B", cmd: { action: "stop" } }); 113 + }); 114 + });
+79
apps/web/src/atoms/connect.ts
··· 1 + import { atom } from "jotai"; 2 + import type { DeviceInfo } from "@/lib/connect/protocol"; 3 + import { getConnectClient } from "@/lib/connect/client"; 4 + import { liteToStation, stationToLite } from "@/lib/connect/device"; 5 + import { 6 + currentStationAtom, 7 + isPlayingAtom, 8 + playbackStatusAtom, 9 + } from "./player"; 10 + 11 + export type ConnectStatus = "connecting" | "online" | "offline"; 12 + 13 + /** Live roster of this account's connected devices (self flagged). */ 14 + export const devicesAtom = atom<DeviceInfo[]>([]); 15 + 16 + /** WebSocket status to the Connect hub. */ 17 + export const connectStatusAtom = atom<ConnectStatus>("offline"); 18 + 19 + /** This browser's device id, once the hub has acknowledged it. */ 20 + export const selfDeviceIdAtom = atom<string | null>(null); 21 + 22 + /** 23 + * The remote device this client is currently controlling, or null when this 24 + * device is the active player. Not persisted — resets each session. 25 + */ 26 + export const remoteTargetIdAtom = atom<string | null>(null); 27 + 28 + /** Devices other than this one — what the picker lists as remote targets. */ 29 + export const otherDevicesAtom = atom((get) => 30 + get(devicesAtom).filter((d) => !d.self), 31 + ); 32 + 33 + /** The DeviceInfo we're controlling, or null. Falls back to null if it dropped. */ 34 + export const remoteTargetAtom = atom<DeviceInfo | null>((get) => { 35 + const id = get(remoteTargetIdAtom); 36 + if (!id) return null; 37 + return get(devicesAtom).find((d) => d.id === id && !d.self) ?? null; 38 + }); 39 + 40 + /** True when transport/station actions should be routed to a remote device. */ 41 + export const isRemoteActiveAtom = atom((get) => get(remoteTargetAtom) !== null); 42 + 43 + /** 44 + * Choose the active playback device (Spotify-style transfer). 45 + * - `deviceId` set → control that remote; hand it whatever we're playing. 46 + * - `null` → become the active device; pull the remote's station back here. 47 + */ 48 + export const selectDeviceAtom = atom( 49 + null, 50 + (get, set, deviceId: string | null) => { 51 + const prev = get(remoteTargetIdAtom); 52 + const devices = get(devicesAtom); 53 + const client = getConnectClient(); 54 + 55 + if (deviceId) { 56 + const station = get(currentStationAtom); 57 + const playing = get(isPlayingAtom); 58 + set(remoteTargetIdAtom, deviceId); 59 + set(isPlayingAtom, false); // silence local audio; the remote takes over 60 + if (station && playing && client) { 61 + client.command(deviceId, { 62 + action: "playStation", 63 + station: stationToLite(station), 64 + }); 65 + } 66 + return; 67 + } 68 + 69 + // Back to "this device": pull playback from the remote we were controlling. 70 + const remoteDev = devices.find((d) => d.id === prev); 71 + set(remoteTargetIdAtom, null); 72 + if (remoteDev?.state.station) { 73 + set(currentStationAtom, liteToStation(remoteDev.state.station)); 74 + set(isPlayingAtom, true); 75 + set(playbackStatusAtom, "loading"); 76 + if (client && prev) client.command(prev, { action: "stop" }); 77 + } 78 + }, 79 + );
+21 -2
apps/web/src/atoms/player.ts
··· 1 1 import { atom } from "jotai"; 2 2 import { atomWithStorage } from "jotai/utils"; 3 3 import type { Station } from "@/lib/types"; 4 + import { getConnectClient } from "@/lib/connect/client"; 5 + import { stationToLite } from "@/lib/connect/device"; 6 + import { remoteTargetIdAtom } from "./connect"; 4 7 5 8 export type PlaybackStatus = "idle" | "loading" | "playing" | "error"; 6 9 ··· 29 32 export const volumeAtom = atomWithStorage<number>("atradio:volume", 0.8); 30 33 export const mutedAtom = atomWithStorage<boolean>("atradio:muted", false); 31 34 32 - /** Select a station and start playing it. */ 33 - export const playStationAtom = atom(null, (_get, set, station: Station) => { 35 + /** 36 + * Select a station and start playing it. When this client is controlling a 37 + * remote device, the station is sent there instead of played locally. 38 + */ 39 + export const playStationAtom = atom(null, (get, set, station: Station) => { 40 + const targetId = get(remoteTargetIdAtom); 41 + if (targetId) { 42 + getConnectClient()?.command(targetId, { 43 + action: "playStation", 44 + station: stationToLite(station), 45 + }); 46 + return; 47 + } 34 48 set(currentStationAtom, station); 35 49 set(isPlayingAtom, true); 36 50 set(playbackStatusAtom, "loading"); 37 51 }); 38 52 39 53 export const togglePlayAtom = atom(null, (get, set) => { 54 + const targetId = get(remoteTargetIdAtom); 55 + if (targetId) { 56 + getConnectClient()?.command(targetId, { action: "playPause" }); 57 + return; 58 + } 40 59 if (!get(currentStationAtom)) return; 41 60 set(isPlayingAtom, !get(isPlayingAtom)); 42 61 });
+115
apps/web/src/components/ConnectBanner.tsx
··· 1 + import { useAtomValue, useSetAtom } from "jotai"; 2 + import { useTranslation } from "react-i18next"; 3 + import { 4 + IconBroadcast, 5 + IconPlayerPlayFilled, 6 + IconPlayerPauseFilled, 7 + IconPlayerStopFilled, 8 + IconVolume, 9 + IconDeviceDesktop, 10 + } from "@tabler/icons-react"; 11 + import { 12 + remoteTargetAtom, 13 + remoteTargetIdAtom, 14 + selectDeviceAtom, 15 + } from "@/atoms/connect"; 16 + import { getConnectClient } from "@/lib/connect/client"; 17 + import { liteToStation } from "@/lib/connect/device"; 18 + import type { Command } from "@/lib/connect/protocol"; 19 + import { StationLogo } from "./StationLogo"; 20 + 21 + /** 22 + * Bottom banner shown while this browser is controlling a remote device 23 + * (Spotify-Connect "Playing on …" bar). Reflects the remote's now-playing and 24 + * routes transport there; "Play here" pulls playback back to this device. 25 + */ 26 + export function ConnectBanner() { 27 + const { t } = useTranslation("connect"); 28 + const target = useAtomValue(remoteTargetAtom); 29 + const targetId = useAtomValue(remoteTargetIdAtom); 30 + const selectDevice = useSetAtom(selectDeviceAtom); 31 + 32 + if (!target || !targetId) return null; 33 + 34 + const send = (cmd: Command) => getConnectClient()?.command(targetId, cmd); 35 + const { state } = target; 36 + const station = state.station; 37 + 38 + return ( 39 + <div className="fixed inset-x-0 bottom-0 z-50 border-t border-synth-cyan/30 bg-synth-panel/95 backdrop-blur-xl"> 40 + <div className="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:px-6"> 41 + {station ? ( 42 + <StationLogo 43 + station={liteToStation(station)} 44 + size={40} 45 + className="h-10 w-10 shrink-0" 46 + /> 47 + ) : ( 48 + <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/5"> 49 + <IconBroadcast size={18} className="text-synth-cyan" /> 50 + </div> 51 + )} 52 + 53 + <div className="min-w-0 flex-1"> 54 + <div className="truncate text-sm font-medium text-foreground/90"> 55 + {station?.name ?? t("nothingPlaying")} 56 + </div> 57 + <div className="flex items-center gap-1.5 truncate text-xs text-synth-cyan"> 58 + <IconBroadcast size={12} className="shrink-0" /> 59 + <span className="truncate"> 60 + {state.title ? `${state.title} · ` : ""} 61 + {t("playingOn", { name: target.name })} 62 + </span> 63 + </div> 64 + </div> 65 + 66 + <button 67 + type="button" 68 + aria-label={state.playing ? t("pause") : t("play")} 69 + onClick={() => send({ action: "playPause" })} 70 + className="flex h-9 w-9 items-center justify-center rounded-full text-foreground/80 transition-colors hover:text-synth-cyan" 71 + > 72 + {state.playing ? ( 73 + <IconPlayerPauseFilled size={20} /> 74 + ) : ( 75 + <IconPlayerPlayFilled size={20} /> 76 + )} 77 + </button> 78 + 79 + <button 80 + type="button" 81 + aria-label={t("stop")} 82 + onClick={() => send({ action: "stop" })} 83 + className="hidden h-9 w-9 items-center justify-center rounded-full text-foreground/60 transition-colors hover:text-synth-cyan sm:flex" 84 + > 85 + <IconPlayerStopFilled size={18} /> 86 + </button> 87 + 88 + <div className="hidden items-center gap-2 md:flex"> 89 + <IconVolume size={16} className="text-foreground/50" /> 90 + <input 91 + type="range" 92 + min={0} 93 + max={1} 94 + step={0.01} 95 + value={state.volume} 96 + onChange={(e) => 97 + send({ action: "setVolume", value: Number(e.target.value) }) 98 + } 99 + className="h-1 w-24 cursor-pointer accent-synth-cyan" 100 + aria-label={t("remoteVolume")} 101 + /> 102 + </div> 103 + 104 + <button 105 + type="button" 106 + onClick={() => selectDevice(null)} 107 + className="flex shrink-0 items-center gap-1.5 rounded-full bg-synth-cyan/15 px-3 py-1.5 text-xs font-semibold text-synth-cyan transition-colors hover:bg-synth-cyan/25" 108 + > 109 + <IconDeviceDesktop size={14} /> 110 + {t("playHere")} 111 + </button> 112 + </div> 113 + </div> 114 + ); 115 + }
+163
apps/web/src/components/ConnectProvider.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + import { useAtomValue, useSetAtom } from "jotai"; 3 + import { consola } from "consola"; 4 + import { clientAtom, didAtom } from "@/atoms/auth"; 5 + import { 6 + currentStationAtom, 7 + isPlayingAtom, 8 + mutedAtom, 9 + nowPlayingAtom, 10 + playbackStatusAtom, 11 + volumeAtom, 12 + } from "@/atoms/player"; 13 + import { 14 + connectStatusAtom, 15 + devicesAtom, 16 + remoteTargetIdAtom, 17 + selfDeviceIdAtom, 18 + } from "@/atoms/connect"; 19 + import { 20 + ConnectClient, 21 + setConnectClient, 22 + type ConnectHandlers, 23 + } from "@/lib/connect/client"; 24 + import { 25 + buildPlaybackState, 26 + getDeviceId, 27 + getDeviceName, 28 + liteToStation, 29 + } from "@/lib/connect/device"; 30 + import type { Command, PlaybackState } from "@/lib/connect/protocol"; 31 + import { deleteActorStatus } from "@/lib/atproto/records"; 32 + 33 + /** 34 + * Owns the Connect WebSocket for the logged-in user: reports this browser as a 35 + * controllable device, keeps the device roster in sync, applies remote-control 36 + * commands to the local player, and deletes the durable listening-status record 37 + * when the hub says no player is online. Renders nothing. 38 + */ 39 + export function ConnectProvider() { 40 + const client = useAtomValue(clientAtom); 41 + const did = useAtomValue(didAtom); 42 + 43 + const setDevices = useSetAtom(devicesAtom); 44 + const setStatus = useSetAtom(connectStatusAtom); 45 + const setSelfId = useSetAtom(selfDeviceIdAtom); 46 + const setRemoteTarget = useSetAtom(remoteTargetIdAtom); 47 + 48 + const setCurrentStation = useSetAtom(currentStationAtom); 49 + const setIsPlaying = useSetAtom(isPlayingAtom); 50 + const setPlaybackStatus = useSetAtom(playbackStatusAtom); 51 + const setVolume = useSetAtom(volumeAtom); 52 + const setMuted = useSetAtom(mutedAtom); 53 + 54 + // Latest local playback, so the client's getState() is always current. 55 + const station = useAtomValue(currentStationAtom); 56 + const isPlaying = useAtomValue(isPlayingAtom); 57 + const nowPlaying = useAtomValue(nowPlayingAtom); 58 + const volume = useAtomValue(volumeAtom); 59 + const muted = useAtomValue(mutedAtom); 60 + 61 + const stateRef = useRef<PlaybackState>( 62 + buildPlaybackState({ station, playing: isPlaying, title: nowPlaying, volume, muted }), 63 + ); 64 + const connRef = useRef<ConnectClient | null>(null); 65 + 66 + // Apply a command received from a peer to the local player. 67 + const applyCommand = (cmd: Command) => { 68 + switch (cmd.action) { 69 + case "play": 70 + setRemoteTarget(null); 71 + setIsPlaying(true); 72 + break; 73 + case "pause": 74 + setIsPlaying(false); 75 + break; 76 + case "playPause": 77 + setIsPlaying((v) => !v); 78 + break; 79 + case "stop": 80 + setIsPlaying(false); 81 + setCurrentStation(null); 82 + setPlaybackStatus("idle"); 83 + break; 84 + case "setVolume": 85 + setVolume(Math.min(1, Math.max(0, cmd.value))); 86 + break; 87 + case "toggleMute": 88 + setMuted((v) => !v); 89 + break; 90 + case "playStation": 91 + setRemoteTarget(null); // this device becomes the active player 92 + setCurrentStation(liteToStation(cmd.station)); 93 + setIsPlaying(true); 94 + setPlaybackStatus("loading"); 95 + break; 96 + } 97 + }; 98 + 99 + // (Re)build the client whenever the identity/session changes. 100 + useEffect(() => { 101 + if (!client || !did) { 102 + connRef.current?.stop(); 103 + connRef.current = null; 104 + setConnectClient(null); 105 + setStatus("offline"); 106 + setDevices([]); 107 + setSelfId(null); 108 + return; 109 + } 110 + 111 + const handlers: ConnectHandlers = { 112 + onStatus: (s) => setStatus(s), 113 + onWelcome: (_d, deviceId) => setSelfId(deviceId), 114 + onDevices: (devices) => setDevices(devices), 115 + onCommand: (_from, cmd) => applyCommand(cmd), 116 + onPresence: (_anyPlaying, cleanup) => { 117 + if (cleanup) void deleteActorStatus(client, did); 118 + }, 119 + }; 120 + 121 + const conn = new ConnectClient({ 122 + device: { id: getDeviceId(), name: getDeviceName(), platform: "web" }, 123 + mintToken: async (aud, lxm) => { 124 + const res = await client.get("com.atproto.server.getServiceAuth", { 125 + params: { 126 + aud: aud as `did:${string}:${string}`, 127 + lxm: lxm as `${string}.${string}.${string}`, 128 + exp: Math.floor(Date.now() / 1000) + 60, 129 + }, 130 + }); 131 + if (!res.ok) throw new Error("getServiceAuth failed"); 132 + return res.data.token; 133 + }, 134 + getState: () => stateRef.current, 135 + handlers, 136 + }); 137 + connRef.current = conn; 138 + setConnectClient(conn); 139 + void conn.start().catch((err) => consola.warn("[connect] start failed", err)); 140 + 141 + return () => { 142 + conn.stop(); 143 + connRef.current = null; 144 + setConnectClient(null); 145 + }; 146 + // eslint-disable-next-line react-hooks/exhaustive-deps 147 + }, [client, did]); 148 + 149 + // Broadcast this device's playback whenever it changes. 150 + useEffect(() => { 151 + const next = buildPlaybackState({ 152 + station, 153 + playing: isPlaying, 154 + title: nowPlaying, 155 + volume, 156 + muted, 157 + }); 158 + stateRef.current = next; 159 + connRef.current?.sendState(next); 160 + }, [station, isPlaying, nowPlaying, volume, muted]); 161 + 162 + return null; 163 + }
+157
apps/web/src/components/DevicePicker.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + import { useAtomValue, useSetAtom } from "jotai"; 3 + import { useTranslation } from "react-i18next"; 4 + import { 5 + IconBroadcast, 6 + IconDeviceDesktop, 7 + IconDeviceMobile, 8 + IconTerminal2, 9 + IconCheck, 10 + } from "@tabler/icons-react"; 11 + import { isLoggedInAtom } from "@/atoms/auth"; 12 + import { 13 + connectStatusAtom, 14 + devicesAtom, 15 + remoteTargetIdAtom, 16 + selectDeviceAtom, 17 + } from "@/atoms/connect"; 18 + import type { DeviceInfo, Platform } from "@/lib/connect/protocol"; 19 + 20 + function platformIcon(platform: Platform) { 21 + if (platform === "cli") return IconTerminal2; 22 + if (platform === "web") return IconDeviceDesktop; 23 + return IconDeviceMobile; 24 + } 25 + 26 + function DeviceRow({ 27 + device, 28 + active, 29 + onSelect, 30 + }: { 31 + device: DeviceInfo; 32 + active: boolean; 33 + onSelect: () => void; 34 + }) { 35 + const { t } = useTranslation("connect"); 36 + const Icon = platformIcon(device.platform); 37 + const sub = device.state.station 38 + ? t("stateWith", { 39 + state: device.state.playing ? t("statePlaying") : t("statePaused"), 40 + station: device.state.station.name, 41 + }) 42 + : t("stateIdle"); 43 + return ( 44 + <button 45 + type="button" 46 + onClick={onSelect} 47 + className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-white/5 ${ 48 + active ? "bg-synth-cyan/10" : "" 49 + }`} 50 + > 51 + <Icon 52 + size={18} 53 + className={active ? "text-synth-cyan" : "text-foreground/60"} 54 + /> 55 + <span className="min-w-0 flex-1"> 56 + <span className="block truncate text-sm text-foreground/90"> 57 + {device.name} 58 + {device.self ? ` ${t("thisDevice")}` : ""} 59 + </span> 60 + <span className="block truncate text-xs text-foreground/50">{sub}</span> 61 + </span> 62 + {active ? ( 63 + <IconCheck size={16} className="shrink-0 text-synth-cyan" /> 64 + ) : null} 65 + </button> 66 + ); 67 + } 68 + 69 + /** 70 + * "atradio Connect" device picker (navbar). Lists the account's connected 71 + * devices and lets the user pick which one to play/control — like Spotify's 72 + * devices menu. Hidden when logged out. 73 + */ 74 + export function DevicePicker() { 75 + const { t } = useTranslation("connect"); 76 + const loggedIn = useAtomValue(isLoggedInAtom); 77 + const status = useAtomValue(connectStatusAtom); 78 + const devices = useAtomValue(devicesAtom); 79 + const remoteTargetId = useAtomValue(remoteTargetIdAtom); 80 + const selectDevice = useSetAtom(selectDeviceAtom); 81 + 82 + const [open, setOpen] = useState(false); 83 + const rootRef = useRef<HTMLDivElement>(null); 84 + 85 + useEffect(() => { 86 + if (!open) return; 87 + const onDown = (e: MouseEvent) => { 88 + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); 89 + }; 90 + document.addEventListener("mousedown", onDown); 91 + return () => document.removeEventListener("mousedown", onDown); 92 + }, [open]); 93 + 94 + if (!loggedIn) return null; 95 + 96 + const self = devices.find((d) => d.self) ?? null; 97 + const others = devices.filter((d) => !d.self); 98 + const remoteActive = !!remoteTargetId && others.some((d) => d.id === remoteTargetId); 99 + 100 + return ( 101 + <div ref={rootRef} className="relative"> 102 + <button 103 + type="button" 104 + aria-label={t("buttonAria")} 105 + title={t("buttonTitle")} 106 + onClick={() => setOpen((v) => !v)} 107 + className={`relative flex h-9 w-9 items-center justify-center rounded-full transition-colors hover:text-foreground ${ 108 + remoteActive ? "text-synth-cyan" : "text-foreground/60" 109 + }`} 110 + > 111 + <IconBroadcast size={18} /> 112 + {status === "online" && others.length > 0 ? ( 113 + <span 114 + className={`absolute right-1.5 top-1.5 h-2 w-2 rounded-full ${ 115 + remoteActive ? "bg-synth-cyan" : "bg-synth-magenta" 116 + }`} 117 + /> 118 + ) : null} 119 + </button> 120 + 121 + {open ? ( 122 + <div className="absolute right-0 z-50 mt-2 w-72 overflow-hidden rounded-xl border border-white/10 bg-synth-panel/95 p-2 shadow-xl backdrop-blur-xl"> 123 + <div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-foreground/50"> 124 + {t("title")} 125 + </div> 126 + {self ? ( 127 + <DeviceRow 128 + device={self} 129 + active={!remoteActive} 130 + onSelect={() => { 131 + selectDevice(null); 132 + setOpen(false); 133 + }} 134 + /> 135 + ) : null} 136 + {others.length === 0 ? ( 137 + <div className="px-3 py-3 text-xs text-foreground/40"> 138 + {t("noDevices")} 139 + </div> 140 + ) : ( 141 + others.map((d) => ( 142 + <DeviceRow 143 + key={d.id} 144 + device={d} 145 + active={remoteTargetId === d.id} 146 + onSelect={() => { 147 + selectDevice(d.id); 148 + setOpen(false); 149 + }} 150 + /> 151 + )) 152 + )} 153 + </div> 154 + ) : null} 155 + </div> 156 + ); 157 + }
+4
apps/web/src/components/Layout.tsx
··· 7 7 import { Footer } from "./Footer"; 8 8 import { PermissionBanner } from "./PermissionBanner"; 9 9 import { Player } from "./Player"; 10 + import { ConnectBanner } from "./ConnectBanner"; 11 + import { ConnectProvider } from "./ConnectProvider"; 10 12 import { PlayerReactionRain } from "./PlayerReactionRain"; 11 13 import { AddStationModal } from "./AddStationModal"; 12 14 import { CommentsModal } from "./CommentsModal"; ··· 51 53 </button> 52 54 53 55 <Player /> 56 + <ConnectBanner /> 54 57 <PlayerReactionRain /> 55 58 <AddStationModal /> 56 59 <CommentsModal /> ··· 62 65 <AudioSettingsSync /> 63 66 <UserDataSync /> 64 67 <PlayStatusSync /> 68 + <ConnectProvider /> 65 69 </div> 66 70 ); 67 71 }
+2
apps/web/src/components/Navbar.tsx
··· 19 19 import { useRequireAuth } from "@/hooks/useRequireAuth"; 20 20 import { useAuth } from "@/hooks/useAuth"; 21 21 import { NotificationBell } from "./NotificationBell"; 22 + import { DevicePicker } from "./DevicePicker"; 22 23 import { IconTangled } from "./IconTangled"; 23 24 import { LanguageSwitcher } from "./LanguageSwitcher"; 24 25 ··· 117 118 <span className="sm:hidden">{t("add", { ns: "common" })}</span> 118 119 </Button> 119 120 121 + {isLoggedIn && <DevicePicker />} 120 122 {isLoggedIn && <NotificationBell />} 121 123 122 124 {isLoggedIn ? (
+13 -1
apps/web/src/components/Player.tsx
··· 31 31 volumeAtom, 32 32 type StreamInfo, 33 33 } from "@/atoms/player"; 34 + import { isRemoteActiveAtom } from "@/atoms/connect"; 34 35 import { favoriteIdsAtom, toggleFavoriteAtom } from "@/atoms/favorites"; 35 36 import { 36 37 audioSettingsOpenAtom, ··· 83 84 const [muted, setMuted] = useAtom(mutedAtom); 84 85 const [nowPlaying, setNowPlaying] = useAtom(nowPlayingAtom); 85 86 const [streamInfo, setStreamInfo] = useAtom(streamInfoAtom); 87 + // When controlling a remote device, this browser is a controller, not a 88 + // player: local audio is silenced and the Connect banner replaces the bar. 89 + const remoteActive = useAtomValue(isRemoteActiveAtom); 86 90 const favoriteIds = useAtomValue(favoriteIdsAtom); 87 91 const toggleFavorite = useSetAtom(toggleFavoriteAtom); 88 92 const openAudioSettings = useSetAtom(audioSettingsOpenAtom); ··· 348 352 const p = getRockboxPlayer(); 349 353 if (p.ready) p.setVolume(muted ? 0 : volume); 350 354 }, [volume, muted, station?.id]); 355 + 356 + // Handing off to a remote device: silence local playback. The play/pause 357 + // effect above then pauses whichever backend is active. 358 + useEffect(() => { 359 + if (remoteActive && isPlaying) setIsPlaying(false); 360 + }, [remoteActive]); // eslint-disable-line react-hooks/exhaustive-deps 351 361 352 362 const isFavorite = station ? favoriteIds.has(station.id) : false; 353 363 ··· 704 714 705 715 <div 706 716 className={`pointer-events-none fixed inset-x-0 bottom-0 z-50 px-3 pb-3 sm:px-4 sm:pb-4 transition-transform duration-300 ${ 707 - station ? "translate-y-0" : "translate-y-[calc(100%+1.5rem)]" 717 + station && !remoteActive 718 + ? "translate-y-0" 719 + : "translate-y-[calc(100%+1.5rem)]" 708 720 }`} 709 721 > 710 722 <div className="pointer-events-auto mx-auto max-w-7xl overflow-hidden rounded-2xl border border-white/10 bg-synth-surface/40 shadow-2xl shadow-black/40 backdrop-blur-2xl">
+18
apps/web/src/i18n/locales/en/connect.json
··· 1 + { 2 + "title": "Connect to a device", 3 + "buttonAria": "Connect to a device", 4 + "buttonTitle": "Devices", 5 + "thisDevice": "(this device)", 6 + "noDevices": "No other devices online. Open atradio on another device or the CLI to control it from here.", 7 + "statePlaying": "Playing", 8 + "statePaused": "Paused", 9 + "stateIdle": "Idle", 10 + "stateWith": "{{state}} · {{station}}", 11 + "nothingPlaying": "Nothing playing", 12 + "playingOn": "Playing on {{name}}", 13 + "play": "Play", 14 + "pause": "Pause", 15 + "stop": "Stop", 16 + "remoteVolume": "Remote volume", 17 + "playHere": "Play here" 18 + }
+18
apps/web/src/i18n/locales/fr/connect.json
··· 1 + { 2 + "title": "Connecter à un appareil", 3 + "buttonAria": "Connecter à un appareil", 4 + "buttonTitle": "Appareils", 5 + "thisDevice": "(cet appareil)", 6 + "noDevices": "Aucun autre appareil en ligne. Ouvrez atradio sur un autre appareil ou la CLI pour le contrôler d'ici.", 7 + "statePlaying": "En lecture", 8 + "statePaused": "En pause", 9 + "stateIdle": "Inactif", 10 + "stateWith": "{{state}} · {{station}}", 11 + "nothingPlaying": "Aucune lecture", 12 + "playingOn": "Lecture sur {{name}}", 13 + "play": "Lecture", 14 + "pause": "Pause", 15 + "stop": "Arrêter", 16 + "remoteVolume": "Volume distant", 17 + "playHere": "Lire ici" 18 + }
+18
apps/web/src/i18n/locales/pt/connect.json
··· 1 + { 2 + "title": "Conectar a um dispositivo", 3 + "buttonAria": "Conectar a um dispositivo", 4 + "buttonTitle": "Dispositivos", 5 + "thisDevice": "(este dispositivo)", 6 + "noDevices": "Nenhum outro dispositivo online. Abra o atradio em outro dispositivo ou na CLI para controlá-lo daqui.", 7 + "statePlaying": "Tocando", 8 + "statePaused": "Pausado", 9 + "stateIdle": "Inativo", 10 + "stateWith": "{{state}} · {{station}}", 11 + "nothingPlaying": "Nada tocando", 12 + "playingOn": "Tocando em {{name}}", 13 + "play": "Tocar", 14 + "pause": "Pausar", 15 + "stop": "Parar", 16 + "remoteVolume": "Volume remoto", 17 + "playHere": "Tocar aqui" 18 + }
+22
apps/web/src/lib/atproto/records.ts
··· 173 173 ); 174 174 } 175 175 176 + /** Delete the actor's listening-status record (rkey `self`). Used when no 177 + * player is online anymore, so the user stops appearing as "listening". 178 + * Idempotent — a missing record is treated as already-clean. */ 179 + export async function deleteActorStatus( 180 + client: Client, 181 + did: Did, 182 + ): Promise<void> { 183 + try { 184 + await ok( 185 + client.post("com.atproto.repo.deleteRecord", { 186 + input: { 187 + repo: did, 188 + collection: NSID.actorStatus as Nsid, 189 + rkey: ACTOR_STATUS_RKEY, 190 + }, 191 + }), 192 + ); 193 + } catch { 194 + /* already gone / transient — nothing to clean up */ 195 + } 196 + } 197 + 176 198 /** Write a comment on a station to the user's repo; returns its rkey + uri. */ 177 199 export async function putComment( 178 200 client: Client,
+179
apps/web/src/lib/connect/client.ts
··· 1 + import { consola } from "consola"; 2 + import { APPVIEW_URL } from "@/lib/appview"; 3 + import { 4 + CONNECT_LXM, 5 + type Command, 6 + type DeviceInfo, 7 + type PlaybackState, 8 + type Platform, 9 + type ServerMsg, 10 + } from "./protocol"; 11 + 12 + const DEFAULT_SERVICE_DID = "did:web:api.atradio.fm"; 13 + 14 + export interface ConnectHandlers { 15 + onStatus?: (status: "connecting" | "online" | "offline") => void; 16 + onWelcome?: (did: string, deviceId: string) => void; 17 + onDevices?: (devices: DeviceInfo[]) => void; 18 + onCommand?: (from: string, cmd: Command) => void; 19 + onPresence?: (anyPlaying: boolean, cleanup: boolean) => void; 20 + } 21 + 22 + export interface ConnectOptions { 23 + device: { id: string; name: string; platform: Platform }; 24 + /** Mint a fresh service-auth JWT bound to `aud` + `CONNECT_LXM`. */ 25 + mintToken: (aud: string, lxm: string) => Promise<string>; 26 + /** Snapshot of this device's current playback, sent on (re)connect. */ 27 + getState: () => PlaybackState; 28 + handlers: ConnectHandlers; 29 + } 30 + 31 + /** 32 + * A resilient WebSocket client for the Connect hub. Mints a service-auth token, 33 + * connects, identifies this device, and relays roster / command / presence 34 + * events back to the caller. Auto-reconnects with backoff. 35 + */ 36 + export class ConnectClient { 37 + private ws: WebSocket | null = null; 38 + private closed = false; 39 + private backoff = 1000; 40 + private reconnectTimer: ReturnType<typeof setTimeout> | null = null; 41 + private serviceDid = DEFAULT_SERVICE_DID; 42 + 43 + constructor(private readonly opts: ConnectOptions) {} 44 + 45 + async start(): Promise<void> { 46 + this.closed = false; 47 + // Discover the AppView's Connect DID (the token audience); fall back to the 48 + // production default if /health is unreachable. 49 + try { 50 + const res = await fetch(`${APPVIEW_URL}/health`); 51 + const data = (await res.json()) as { connectDid?: string }; 52 + if (data.connectDid) this.serviceDid = data.connectDid; 53 + } catch { 54 + /* keep default */ 55 + } 56 + this.connect(); 57 + } 58 + 59 + private connect(): void { 60 + if (this.closed) return; 61 + this.opts.handlers.onStatus?.("connecting"); 62 + const url = `${APPVIEW_URL.replace(/^http/, "ws")}/connect`; 63 + let ws: WebSocket; 64 + try { 65 + ws = new WebSocket(url); 66 + } catch (err) { 67 + consola.warn("[connect] socket construct failed", err); 68 + this.scheduleReconnect(); 69 + return; 70 + } 71 + this.ws = ws; 72 + 73 + ws.onopen = async () => { 74 + try { 75 + const token = await this.opts.mintToken(this.serviceDid, CONNECT_LXM); 76 + if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) return; 77 + ws.send( 78 + JSON.stringify({ 79 + t: "hello", 80 + token, 81 + device: { 82 + id: this.opts.device.id, 83 + name: this.opts.device.name, 84 + platform: this.opts.device.platform, 85 + state: this.opts.getState(), 86 + }, 87 + }), 88 + ); 89 + } catch (err) { 90 + consola.warn("[connect] token mint failed", err); 91 + ws.close(); 92 + } 93 + }; 94 + 95 + ws.onmessage = (ev) => { 96 + let msg: ServerMsg; 97 + try { 98 + msg = JSON.parse(ev.data as string) as ServerMsg; 99 + } catch { 100 + return; 101 + } 102 + switch (msg.t) { 103 + case "welcome": 104 + this.backoff = 1000; 105 + this.opts.handlers.onStatus?.("online"); 106 + this.opts.handlers.onWelcome?.(msg.did, msg.deviceId); 107 + break; 108 + case "devices": 109 + this.opts.handlers.onDevices?.(msg.devices); 110 + break; 111 + case "command": 112 + this.opts.handlers.onCommand?.(msg.from, msg.cmd); 113 + break; 114 + case "presence": 115 + this.opts.handlers.onPresence?.(msg.anyPlaying, !!msg.cleanup); 116 + break; 117 + case "error": 118 + consola.warn("[connect] hub error", msg.code, msg.message); 119 + break; 120 + } 121 + }; 122 + 123 + ws.onclose = () => { 124 + if (this.ws === ws) this.ws = null; 125 + this.opts.handlers.onStatus?.("offline"); 126 + this.scheduleReconnect(); 127 + }; 128 + 129 + ws.onerror = () => { 130 + /* onclose does the reconnect */ 131 + }; 132 + } 133 + 134 + private scheduleReconnect(): void { 135 + if (this.closed || this.reconnectTimer) return; 136 + const delay = this.backoff; 137 + this.backoff = Math.min(this.backoff * 2, 15000); 138 + this.reconnectTimer = setTimeout(() => { 139 + this.reconnectTimer = null; 140 + this.connect(); 141 + }, delay); 142 + } 143 + 144 + /** Broadcast this device's current playback state to peers. */ 145 + sendState(state: PlaybackState): void { 146 + if (this.ws?.readyState === WebSocket.OPEN) { 147 + this.ws.send(JSON.stringify({ t: "state", state })); 148 + } 149 + } 150 + 151 + /** Send a control command to a peer device. */ 152 + command(target: string, cmd: Command): void { 153 + if (this.ws?.readyState === WebSocket.OPEN) { 154 + this.ws.send(JSON.stringify({ t: "command", target, cmd })); 155 + } 156 + } 157 + 158 + stop(): void { 159 + this.closed = true; 160 + if (this.reconnectTimer) { 161 + clearTimeout(this.reconnectTimer); 162 + this.reconnectTimer = null; 163 + } 164 + if (this.ws?.readyState === WebSocket.OPEN) { 165 + this.ws.send(JSON.stringify({ t: "bye" })); 166 + } 167 + this.ws?.close(); 168 + this.ws = null; 169 + } 170 + } 171 + 172 + /** Module-level handle so atoms/components can send commands without prop drilling. */ 173 + let current: ConnectClient | null = null; 174 + export function setConnectClient(c: ConnectClient | null): void { 175 + current = c; 176 + } 177 + export function getConnectClient(): ConnectClient | null { 178 + return current; 179 + }
+73
apps/web/src/lib/connect/device.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import type { Station } from "@/lib/types"; 3 + import { 4 + buildPlaybackState, 5 + liteToStation, 6 + stationToLite, 7 + } from "./device"; 8 + 9 + const station: Station = { 10 + id: "rb:abc", 11 + name: "Synthwave FM", 12 + streamUrl: "http://example.com/stream", 13 + favicon: "http://example.com/fav.png", 14 + source: "radio-browser", 15 + }; 16 + 17 + describe("connect device helpers", () => { 18 + it("reduces a Station to the wire shape", () => { 19 + expect(stationToLite(station)).toEqual({ 20 + id: "rb:abc", 21 + name: "Synthwave FM", 22 + url: "http://example.com/stream", 23 + favicon: "http://example.com/fav.png", 24 + }); 25 + }); 26 + 27 + it("round-trips a station through lite and back", () => { 28 + const lite = stationToLite(station); 29 + const back = liteToStation(lite); 30 + expect(back.id).toBe(station.id); 31 + expect(back.name).toBe(station.name); 32 + expect(back.streamUrl).toBe(station.streamUrl); 33 + expect(back.favicon).toBe(station.favicon); 34 + }); 35 + 36 + it("infers the source from the station id prefix", () => { 37 + expect(liteToStation({ id: "tunein:s1", name: "t", url: "u" }).source).toBe( 38 + "tunein", 39 + ); 40 + expect(liteToStation({ id: "custom:x", name: "c", url: "u" }).source).toBe( 41 + "custom", 42 + ); 43 + expect(liteToStation({ id: "rb:x", name: "r", url: "u" }).source).toBe( 44 + "radio-browser", 45 + ); 46 + }); 47 + 48 + it("marks playback state as not playing when no station is loaded", () => { 49 + const s = buildPlaybackState({ 50 + station: null, 51 + playing: true, 52 + title: null, 53 + volume: 0.4, 54 + muted: false, 55 + }); 56 + expect(s.playing).toBe(false); 57 + expect(s.station).toBeNull(); 58 + expect(s.volume).toBe(0.4); 59 + }); 60 + 61 + it("reflects a playing station with its now-playing title", () => { 62 + const s = buildPlaybackState({ 63 + station, 64 + playing: true, 65 + title: "Artist — Track", 66 + volume: 0.8, 67 + muted: false, 68 + }); 69 + expect(s.playing).toBe(true); 70 + expect(s.station?.name).toBe("Synthwave FM"); 71 + expect(s.title).toBe("Artist — Track"); 72 + }); 73 + });
+80
apps/web/src/lib/connect/device.ts
··· 1 + import type { Station } from "@/lib/types"; 2 + import type { PlaybackState, StationLite } from "./protocol"; 3 + 4 + /** Stable id for this browser (one per browser profile). */ 5 + export function getDeviceId(): string { 6 + const KEY = "atradio:deviceId"; 7 + let id = localStorage.getItem(KEY); 8 + if (!id) { 9 + id = crypto.randomUUID(); 10 + localStorage.setItem(KEY, id); 11 + } 12 + return id; 13 + } 14 + 15 + /** A friendly device name from the user agent, e.g. "Chrome · macOS". */ 16 + export function getDeviceName(): string { 17 + const ua = navigator.userAgent; 18 + const browser = 19 + /Edg\//.test(ua) 20 + ? "Edge" 21 + : /OPR\//.test(ua) 22 + ? "Opera" 23 + : /Firefox\//.test(ua) 24 + ? "Firefox" 25 + : /Chrome\//.test(ua) 26 + ? "Chrome" 27 + : /Safari\//.test(ua) 28 + ? "Safari" 29 + : "Browser"; 30 + const os = /Mac OS X|Macintosh/.test(ua) 31 + ? "macOS" 32 + : /Windows/.test(ua) 33 + ? "Windows" 34 + : /Android/.test(ua) 35 + ? "Android" 36 + : /iPhone|iPad|iOS/.test(ua) 37 + ? "iOS" 38 + : /Linux/.test(ua) 39 + ? "Linux" 40 + : ""; 41 + return os ? `${browser} · ${os}` : browser; 42 + } 43 + 44 + /** Reduce a full Station to the minimal shape sent over the wire. */ 45 + export function stationToLite(s: Station): StationLite { 46 + return { id: s.id, name: s.name, url: s.streamUrl, favicon: s.favicon }; 47 + } 48 + 49 + /** Reconstruct a playable Station from a wire StationLite (source from id). */ 50 + export function liteToStation(s: StationLite): Station { 51 + const source: Station["source"] = s.id.startsWith("tunein:") 52 + ? "tunein" 53 + : s.id.startsWith("custom:") 54 + ? "custom" 55 + : "radio-browser"; 56 + return { 57 + id: s.id, 58 + name: s.name, 59 + streamUrl: s.url, 60 + favicon: s.favicon, 61 + source, 62 + }; 63 + } 64 + 65 + /** Build the wire PlaybackState this device broadcasts to its peers. */ 66 + export function buildPlaybackState(opts: { 67 + station: Station | null; 68 + playing: boolean; 69 + title: string | null; 70 + volume: number; 71 + muted: boolean; 72 + }): PlaybackState { 73 + return { 74 + playing: opts.playing && !!opts.station, 75 + station: opts.station ? stationToLite(opts.station) : null, 76 + title: opts.title ?? undefined, 77 + volume: opts.volume, 78 + muted: opts.muted, 79 + }; 80 + }
+46
apps/web/src/lib/connect/protocol.ts
··· 1 + /** 2 + * atradio Connect wire protocol (web copy). Mirrors 3 + * `apps/api/src/connect/protocol.ts` — keep in sync. 4 + */ 5 + export const CONNECT_LXM = "fm.atradio.connect"; 6 + 7 + export interface StationLite { 8 + id: string; 9 + name: string; 10 + url: string; 11 + favicon?: string; 12 + } 13 + 14 + export interface PlaybackState { 15 + playing: boolean; 16 + station: StationLite | null; 17 + title?: string; 18 + volume: number; 19 + muted: boolean; 20 + } 21 + 22 + export type Platform = "web" | "cli" | "other"; 23 + 24 + export interface DeviceInfo { 25 + id: string; 26 + name: string; 27 + platform: Platform; 28 + self?: boolean; 29 + state: PlaybackState; 30 + } 31 + 32 + export type Command = 33 + | { action: "playPause" } 34 + | { action: "play" } 35 + | { action: "pause" } 36 + | { action: "stop" } 37 + | { action: "setVolume"; value: number } 38 + | { action: "toggleMute" } 39 + | { action: "playStation"; station: StationLite }; 40 + 41 + export type ServerMsg = 42 + | { t: "welcome"; did: string; deviceId: string } 43 + | { t: "devices"; devices: DeviceInfo[] } 44 + | { t: "command"; from: string; cmd: Command } 45 + | { t: "presence"; anyPlaying: boolean; cleanup?: boolean } 46 + | { t: "error"; code: string; message: string };
+72
bun.lock
··· 17 17 "@atcute/client": "^4.2.1", 18 18 "@atcute/identity-resolver": "^1.2.2", 19 19 "@atcute/lexicons": "^1.3.1", 20 + "@atproto/identity": "^0.5.6", 21 + "@atproto/xrpc-server": "^0.11.10", 20 22 "@atradio/lexicons": "workspace:*", 21 23 "consola": "^3.3.3", 22 24 "cors": "^2.8.5", ··· 152 154 "@atcute/util-fetch": ["@atcute/util-fetch@1.0.5", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig=="], 153 155 154 156 "@atcute/util-text": ["@atcute/util-text@1.3.3", "", { "dependencies": { "unicode-segmenter": "^0.14.5" } }, "sha512-WhedTmg/msFhrdwXw9RjnNcDl8Vmisxl4+Vzyf5k3+8Gj5TKQg72dLSDtBNmNLd61RbHjgfQRBgE0ez6q/jciw=="], 157 + 158 + "@atproto/common": ["@atproto/common@0.7.2", "", { "dependencies": { "@atproto/common-web": "^0.5.6", "@atproto/lex-cbor": "^0.1.4", "@atproto/lex-data": "^0.1.5", "multiformats": "^13.0.0", "pino": "^10.3.1" } }, "sha512-D43QVEnSgWhzEoelJTWouPrwdjfHPAe5xVEc5RtsA6OTKEPaEJN0LSFd495ZtanMrvvC+KrgW8Tk02C8kvp45A=="], 159 + 160 + "@atproto/common-web": ["@atproto/common-web@0.5.6", "", { "dependencies": { "@atproto/lex-data": "^0.1.5", "@atproto/lex-json": "^0.1.4", "@atproto/syntax": "^0.7.2", "zod": "^3.23.8" } }, "sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw=="], 161 + 162 + "@atproto/crypto": ["@atproto/crypto@0.5.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "^5.0.0" } }, "sha512-UR0BkuYNYuFtw+dA+y/oPPxzX0SWRnGJ+1Cfh/jGP1BvjRUyezK3omjpeLms5fYrXbM9vnfX+ckJFJqkBgLOdw=="], 163 + 164 + "@atproto/identity": ["@atproto/identity@0.5.6", "", { "dependencies": { "@atproto/common-web": "^0.5.6", "@atproto/crypto": "^0.5.4" } }, "sha512-xGmg8HnYhTsqsVwpFIPKjQzwnHRddXnuD8N+f/tWlblX0119L7b8GyXFKY84TRbumKx7xvy/IanAncyDxBx+/A=="], 165 + 166 + "@atproto/lex-cbor": ["@atproto/lex-cbor@0.1.4", "", { "dependencies": { "@atproto/lex-data": "^0.1.5", "cborg": "^4.5.8", "tslib": "^2.8.1" } }, "sha512-1reaooZfNFunJn2Fz21Up7iFQBPZlifQKlBsRvQoLG67mv43nmH4dZWgnGLD54ShCu8dyZ8Y6Wgh7BCg+2gRSw=="], 167 + 168 + "@atproto/lex-client": ["@atproto/lex-client@0.3.0", "", { "dependencies": { "@atproto/lex-data": "^0.1.5", "@atproto/lex-json": "^0.1.4", "@atproto/lex-schema": "^0.2.2", "tslib": "^2.8.1" } }, "sha512-P+S3hWdIIOgNTun7J42dvxSyCkQog3Q2ShTjvcwP761TqiRex7zDWaRAaT9gvCy6I4pRuNdBCStysjSMlJeSdg=="], 169 + 170 + "@atproto/lex-data": ["@atproto/lex-data@0.1.5", "", { "dependencies": { "multiformats": "^13.0.0", "tslib": "^2.8.1", "unicode-segmenter": "^0.14.0" } }, "sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA=="], 171 + 172 + "@atproto/lex-json": ["@atproto/lex-json@0.1.4", "", { "dependencies": { "@atproto/lex-data": "^0.1.5", "tslib": "^2.8.1" } }, "sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ=="], 173 + 174 + "@atproto/lex-schema": ["@atproto/lex-schema@0.2.2", "", { "dependencies": { "@atproto/lex-data": "^0.1.5", "@atproto/syntax": "^0.7.2", "@standard-schema/spec": "^1.1.0", "tslib": "^2.8.1" } }, "sha512-UFK0EH8pw31dvL27aoZ5K78z+UMiD3ybIgkCyd7Idm267KM1IpycerTDeABqo4CYP3/IAnzjGpQaUaVc12bELA=="], 175 + 176 + "@atproto/lexicon": ["@atproto/lexicon@0.7.7", "", { "dependencies": { "@atproto/common-web": "^0.5.6", "@atproto/syntax": "^0.7.2", "multiformats": "^13.0.0", "zod": "^3.23.8" } }, "sha512-92VH2oEsJdrIVNy7WY8rGn99ANNVglyUffoN7GJc0mxKi+fXN5iVlJ2cOyTfYABhr2ldSiptZWXEPEdBaIR9/A=="], 177 + 178 + "@atproto/syntax": ["@atproto/syntax@0.7.2", "", { "dependencies": { "iso-datestring-validator": "^2.2.2", "tslib": "^2.8.1" } }, "sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w=="], 179 + 180 + "@atproto/ws-client": ["@atproto/ws-client@0.1.7", "", { "dependencies": { "@atproto/common": "^0.7.2", "ws": "^8.12.0" } }, "sha512-Ghj54G+1qN79GwFt/F2tqpKkCiil9L+e7gYGe4ipuwbnwpEw7jiuM1e+L+5PN/Jwc0O0bNMoglR6vDxpXtetEA=="], 181 + 182 + "@atproto/xrpc": ["@atproto/xrpc@0.8.6", "", { "dependencies": { "@atproto/lexicon": "^0.7.7", "zod": "^3.23.8" } }, "sha512-yVfKrlwZBBm44Ft9jDvHcTCwQ1ElqSlzXHWhT/KcqE+u24EAhWVFosfABfGbUByB/PmN8eLJ06FsWo5pUQcMNQ=="], 183 + 184 + "@atproto/xrpc-server": ["@atproto/xrpc-server@0.11.10", "", { "dependencies": { "@atproto/common": "^0.7.2", "@atproto/crypto": "^0.5.4", "@atproto/lex-cbor": "^0.1.4", "@atproto/lex-client": "^0.3.0", "@atproto/lex-data": "^0.1.5", "@atproto/lex-json": "^0.1.4", "@atproto/lex-schema": "^0.2.2", "@atproto/lexicon": "^0.7.7", "@atproto/ws-client": "^0.1.7", "@atproto/xrpc": "^0.8.6", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "ws": "^8.12.0" } }, "sha512-LmmediZf+zUSy/MkJe/BSt4kkXzYK34DBn1y1y4x9TNEJdQ1W0pq/+nnkliRyf5L87lPE9pku9VJgXwjDVmnIA=="], 155 185 156 186 "@atradio/api": ["@atradio/api@workspace:apps/api"], 157 187 ··· 529 559 530 560 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 531 561 562 + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], 563 + 564 + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 565 + 532 566 "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], 567 + 568 + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], 533 569 534 570 "@pkl-community/pkl": ["@pkl-community/pkl@0.28.2", "", { "optionalDependencies": { "@pkl-community/pkl-darwin-arm64": "0.28.2", "@pkl-community/pkl-darwin-x64": "0.28.2", "@pkl-community/pkl-linux-arm64": "0.28.2", "@pkl-community/pkl-linux-x64": "0.28.2" }, "bin": { "pkl": "lib/main.js" } }, "sha512-seSZrwGvDEd1BeT9+dRRnqvyNit8vpFL2I+YBEJ+t3pBYpnpHaVHwQNPlMLvDQ0KZUCQkiWGZnbCMF9WtaxHNA=="], 535 571 ··· 835 871 836 872 "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], 837 873 874 + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 875 + 838 876 "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], 839 877 840 878 "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], ··· 868 906 "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], 869 907 870 908 "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="], 909 + 910 + "cborg": ["cborg@4.5.8", "", { "bin": { "cborg": "lib/bin.js" } }, "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw=="], 871 911 872 912 "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], 873 913 ··· 1189 1229 1190 1230 "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], 1191 1231 1232 + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 1233 + 1192 1234 "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], 1193 1235 1194 1236 "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], ··· 1281 1323 1282 1324 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 1283 1325 1326 + "multiformats": ["multiformats@13.4.2", "", {}, "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ=="], 1327 + 1284 1328 "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], 1285 1329 1286 1330 "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], ··· 1296 1340 "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], 1297 1341 1298 1342 "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], 1343 + 1344 + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], 1299 1345 1300 1346 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], 1301 1347 ··· 1323 1369 1324 1370 "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], 1325 1371 1372 + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], 1373 + 1374 + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], 1375 + 1376 + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], 1377 + 1326 1378 "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 1327 1379 1328 1380 "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], ··· 1332 1384 "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], 1333 1385 1334 1386 "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], 1387 + 1388 + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], 1335 1389 1336 1390 "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], 1337 1391 ··· 1341 1395 1342 1396 "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], 1343 1397 1398 + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], 1399 + 1344 1400 "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], 1401 + 1402 + "rate-limiter-flexible": ["rate-limiter-flexible@2.4.2", "", {}, "sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw=="], 1345 1403 1346 1404 "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], 1347 1405 ··· 1367 1425 1368 1426 "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], 1369 1427 1428 + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 1429 + 1370 1430 "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], 1371 1431 1372 1432 "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], ··· 1406 1466 "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], 1407 1467 1408 1468 "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], 1469 + 1470 + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], 1409 1471 1410 1472 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 1411 1473 ··· 1457 1519 1458 1520 "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], 1459 1521 1522 + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], 1523 + 1460 1524 "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], 1461 1525 1462 1526 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 1463 1527 1464 1528 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], 1529 + 1530 + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 1465 1531 1466 1532 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], 1467 1533 ··· 1509 1575 1510 1576 "terser": ["terser@5.49.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA=="], 1511 1577 1578 + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], 1579 + 1512 1580 "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], 1513 1581 1514 1582 "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], ··· 1552 1620 "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], 1553 1621 1554 1622 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 1623 + 1624 + "uint8arrays": ["uint8arrays@5.1.1", "", { "dependencies": { "multiformats": "^13.0.0" } }, "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ=="], 1555 1625 1556 1626 "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], 1557 1627 ··· 1738 1808 "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1739 1809 1740 1810 "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], 1811 + 1812 + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], 1741 1813 1742 1814 "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], 1743 1815
+59 -2
cli/Cargo.lock
··· 306 306 "serde_json", 307 307 "smol_str", 308 308 "tokio", 309 + "tokio-tungstenite", 309 310 "toml", 310 311 ] 311 312 ··· 1855 1856 "tokio", 1856 1857 "tokio-rustls", 1857 1858 "tower-service", 1858 - "webpki-roots", 1859 + "webpki-roots 1.0.8", 1859 1860 ] 1860 1861 1861 1862 [[package]] ··· 3604 3605 "wasm-bindgen-futures", 3605 3606 "wasm-streams", 3606 3607 "web-sys", 3607 - "webpki-roots", 3608 + "webpki-roots 1.0.8", 3608 3609 ] 3609 3610 3610 3611 [[package]] ··· 4024 4025 "proc-macro2", 4025 4026 "quote", 4026 4027 "syn", 4028 + ] 4029 + 4030 + [[package]] 4031 + name = "sha1" 4032 + version = "0.10.7" 4033 + source = "registry+https://github.com/rust-lang/crates.io-index" 4034 + checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" 4035 + dependencies = [ 4036 + "cfg-if", 4037 + "cpufeatures 0.2.17", 4038 + "digest", 4027 4039 ] 4028 4040 4029 4041 [[package]] ··· 4469 4481 ] 4470 4482 4471 4483 [[package]] 4484 + name = "tokio-tungstenite" 4485 + version = "0.24.0" 4486 + source = "registry+https://github.com/rust-lang/crates.io-index" 4487 + checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" 4488 + dependencies = [ 4489 + "futures-util", 4490 + "log", 4491 + "rustls", 4492 + "rustls-pki-types", 4493 + "tokio", 4494 + "tokio-rustls", 4495 + "tungstenite", 4496 + "webpki-roots 0.26.11", 4497 + ] 4498 + 4499 + [[package]] 4472 4500 name = "tokio-util" 4473 4501 version = "0.7.18" 4474 4502 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4686 4714 version = "0.2.5" 4687 4715 source = "registry+https://github.com/rust-lang/crates.io-index" 4688 4716 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 4717 + 4718 + [[package]] 4719 + name = "tungstenite" 4720 + version = "0.24.0" 4721 + source = "registry+https://github.com/rust-lang/crates.io-index" 4722 + checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" 4723 + dependencies = [ 4724 + "byteorder", 4725 + "bytes", 4726 + "data-encoding", 4727 + "http", 4728 + "httparse", 4729 + "log", 4730 + "rand 0.8.7", 4731 + "rustls", 4732 + "rustls-pki-types", 4733 + "sha1", 4734 + "thiserror 1.0.69", 4735 + "utf-8", 4736 + ] 4689 4737 4690 4738 [[package]] 4691 4739 name = "typenum" ··· 4958 5006 "markup5ever_rcdom", 4959 5007 "serde_json", 4960 5008 "url", 5009 + ] 5010 + 5011 + [[package]] 5012 + name = "webpki-roots" 5013 + version = "0.26.11" 5014 + source = "registry+https://github.com/rust-lang/crates.io-index" 5015 + checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" 5016 + dependencies = [ 5017 + "webpki-roots 1.0.8", 4961 5018 ] 4962 5019 4963 5020 [[package]]
+4 -1
cli/Cargo.toml
··· 28 28 rockbox-playback = ">=0.4.1" 29 29 30 30 # async runtime + HTTP (AppView reads, radio-browser search, ICY) 31 - tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } 31 + tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "signal"] } 32 32 reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] } 33 33 serde = { version = "1", features = ["derive"] } 34 34 serde_json = "1" 35 35 futures = "0.3" 36 + 37 + # atradio Connect: remote-control WebSocket client to the AppView hub. 38 + tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } 36 39 37 40 # CLI + TUI 38 41 clap = { version = "4.5", features = ["derive"] }
+56
cli/src/atproto/mod.rs
··· 335 335 let rkey: RecordKey<Rkey> = "self".parse().map_err(|e| anyhow!("rkey: {e}"))?; 336 336 self.put(rkey, record, "update play status").await 337 337 } 338 + 339 + /// Delete the actor's play-status singleton (rkey `self`). Used when no 340 + /// player is online anymore, so the user stops appearing as "listening". 341 + pub async fn delete_play_status(&self) -> Result<()> { 342 + let rkey: RecordKey<Rkey> = "self".parse().map_err(|e| anyhow!("rkey: {e}"))?; 343 + if self.is_oauth() { 344 + let session = self.resume_oauth().await?; 345 + Agent::from(session) 346 + .delete_record::<ActorStatus>(rkey) 347 + .await 348 + .map_err(to_anyhow) 349 + .context("delete play status")?; 350 + } else { 351 + self.credential_agent() 352 + .await? 353 + .delete_record::<ActorStatus>(rkey) 354 + .await 355 + .map_err(to_anyhow) 356 + .context("delete play status")?; 357 + } 358 + Ok(()) 359 + } 360 + 361 + /// Mint an atproto **service-auth JWT** bound to `aud` (the AppView's DID) 362 + /// and `lxm` (the lexicon method). This is what proves to the Connect hub 363 + /// that a WebSocket connection genuinely belongs to this account. 364 + pub async fn mint_service_auth(&self, aud: &str, lxm: &str) -> Result<String> { 365 + use jacquard::api::com_atproto::server::get_service_auth::GetServiceAuth; 366 + use jacquard::types::string::Nsid; 367 + use jacquard_common::xrpc::XrpcClient; 368 + use smol_str::SmolStr; 369 + 370 + let nsid = Nsid::<SmolStr>::new_owned(lxm).map_err(|e| anyhow!("lxm: {e}"))?; 371 + let exp = chrono::Utc::now().timestamp() + 60; 372 + let req = GetServiceAuth::<SmolStr> { 373 + aud: SmolStr::new(aud), 374 + exp: Some(exp), 375 + lxm: Some(nsid), 376 + }; 377 + 378 + let resp = if self.is_oauth() { 379 + let agent = Agent::from(self.resume_oauth().await?); 380 + XrpcClient::send(&agent, req) 381 + .await 382 + .map_err(|e| anyhow!("service auth request failed: {e:?}"))? 383 + } else { 384 + let agent = self.credential_agent().await?; 385 + XrpcClient::send(&agent, req) 386 + .await 387 + .map_err(|e| anyhow!("service auth request failed: {e:?}"))? 388 + }; 389 + let out = resp 390 + .parse::<SmolStr>() 391 + .map_err(|e| anyhow!("service auth decode: {e:?}"))?; 392 + Ok(out.token.to_string()) 393 + } 338 394 } 339 395 340 396 /// The OAuth scope set the CLI requests: atproto + write to the fm.atradio.*
+116 -1
cli/src/cli.rs
··· 23 23 pub struct Cli { 24 24 #[command(subcommand)] 25 25 pub command: Option<Command>, 26 + 27 + /// Run headless as an atradio Connect device (no TUI): stay online and let 28 + /// the web app or other clients control playback. Waits until Ctrl-C. 29 + #[arg(long, global = true)] 30 + pub no_tui: bool, 26 31 } 27 32 28 33 #[derive(Subcommand)] ··· 69 74 70 75 pub async fn run(cli: Cli) -> Result<()> { 71 76 let config = Config::from_env(); 77 + let no_tui = cli.no_tui; 72 78 73 79 match cli.command.unwrap_or(Command::Tui) { 74 - Command::Tui => crate::tui::run(config).await, 80 + Command::Tui => { 81 + if no_tui { 82 + cmd_daemon(config).await 83 + } else { 84 + crate::tui::run(config).await 85 + } 86 + } 75 87 Command::Search { query, limit } => cmd_search(query.join(" "), limit).await, 76 88 Command::Play { target } => cmd_play(target.join(" "), config).await, 77 89 Command::Trending { limit } => cmd_trending(limit, &config).await, ··· 180 192 } 181 193 } 182 194 } 195 + } 196 + 197 + /// Headless atradio Connect device: no TUI, just an online controllable player. 198 + async fn cmd_daemon(config: Config) -> Result<()> { 199 + use std::sync::Arc; 200 + use std::time::Duration; 201 + 202 + use crate::appview::StationInfo; 203 + use crate::player::State as PlayState; 204 + use crate::remote::{RemoteCmd, RemoteConfig, RemoteEvent, StationLite, WireState}; 205 + 206 + let atproto = Arc::new(Atproto::new(config.session_path.clone())); 207 + if !atproto.is_logged_in() { 208 + anyhow::bail!("atradio Connect requires sign-in — run `atradio login` first"); 209 + } 210 + 211 + let settings = crate::settings::Settings::load(&config.session_path); 212 + let player = Arc::new(crate::player::Player::new()?); 213 + player.set_volume(settings.volume); 214 + player.apply_dsp(&settings.audio()); 215 + 216 + let device_id = crate::remote::load_or_create_device_id(&config.session_path); 217 + let device_name = settings 218 + .device_name 219 + .clone() 220 + .filter(|s| !s.trim().is_empty()) 221 + .unwrap_or_else(crate::remote::default_device_name); 222 + let (state_tx, state_rx) = tokio::sync::watch::channel(WireState::default()); 223 + let mut remote = crate::remote::spawn( 224 + RemoteConfig { 225 + base_url: config.appview_url.clone(), 226 + device_id, 227 + device_name: device_name.clone(), 228 + atproto: atproto.clone(), 229 + }, 230 + state_rx, 231 + ); 232 + 233 + println!("atradio Connect device “{device_name}” is online."); 234 + println!("Control it from the web app or another client. Press Ctrl-C to stop."); 235 + 236 + let mut current: Option<StationLite> = None; 237 + let mut ticker = tokio::time::interval(Duration::from_millis(500)); 238 + loop { 239 + tokio::select! { 240 + _ = tokio::signal::ctrl_c() => break, 241 + Some(cmd) = remote.cmd_rx.recv() => match cmd { 242 + RemoteCmd::Play => player.play(), 243 + RemoteCmd::Pause => player.pause(), 244 + RemoteCmd::PlayPause => player.toggle(), 245 + RemoteCmd::Stop => { player.stop(); current = None; } 246 + RemoteCmd::SetVolume(v) => player.set_volume(v), 247 + RemoteCmd::ToggleMute => player.toggle_mute(), 248 + RemoteCmd::LoadStation(s) => { 249 + player.play_url(&s.url); 250 + println!("▶ {}", s.name); 251 + let source = if s.id.starts_with("tunein:") { 252 + "tunein" 253 + } else if s.id.starts_with("custom:") { 254 + "custom" 255 + } else { 256 + "radio-browser" 257 + }; 258 + let station = StationInfo { 259 + station_id: s.id.clone(), 260 + name: s.name.clone(), 261 + stream_url: s.url.clone(), 262 + source: source.to_string(), 263 + logo: s.favicon.clone(), 264 + ..Default::default() 265 + }; 266 + current = Some(s); 267 + let at = atproto.clone(); 268 + tokio::spawn(async move { let _ = at.set_play_status(&station).await; }); 269 + } 270 + }, 271 + Some(evt) = remote.evt_rx.recv() => match evt { 272 + RemoteEvent::Status(online) => { 273 + println!("{}", if online { "● connected" } else { "○ disconnected — retrying" }); 274 + } 275 + RemoteEvent::Presence { cleanup, .. } => { 276 + if cleanup { 277 + let at = atproto.clone(); 278 + tokio::spawn(async move { let _ = at.delete_play_status().await; }); 279 + } 280 + } 281 + _ => {} 282 + }, 283 + _ = ticker.tick() => { 284 + let np = player.now_playing(); 285 + let _ = state_tx.send(WireState { 286 + playing: matches!(np.state, PlayState::Playing), 287 + station: current.clone(), 288 + title: np.line(), 289 + volume: player.volume(), 290 + muted: player.is_muted(), 291 + }); 292 + } 293 + } 294 + } 295 + 296 + println!("\nStopping atradio Connect device."); 297 + Ok(()) 183 298 } 184 299 185 300 async fn cmd_login(identifier: Option<String>, oauth: bool, config: &Config) -> Result<()> {
+1
cli/src/main.rs
··· 16 16 mod mpris; 17 17 mod player; 18 18 mod radio; 19 + mod remote; 19 20 mod settings; 20 21 mod theme; 21 22 mod tui;
+362
cli/src/remote.rs
··· 1 + //! atradio Connect — remote-control client (Spotify-Connect style). 2 + //! 3 + //! Opens a WebSocket to the AppView hub (`/connect`), authenticates with an 4 + //! atproto service-auth JWT, registers this process as a controllable device on 5 + //! the account, broadcasts its playback state, and relays remote-control 6 + //! commands back for the owning thread to apply. Modeled on [`crate::mpris`]: 7 + //! the audio engine handle is `!Send`, so this module only ever holds Send-safe 8 + //! channels — commands flow out over an `mpsc`, state flows in over a `watch`. 9 + 10 + use std::path::Path; 11 + use std::sync::Arc; 12 + use std::time::Duration; 13 + 14 + use anyhow::Result; 15 + use futures::{SinkExt, StreamExt}; 16 + use serde::{Deserialize, Serialize}; 17 + use serde_json::{json, Value}; 18 + use tokio::sync::{mpsc, watch}; 19 + use tokio_tungstenite::tungstenite::Message; 20 + 21 + use crate::atproto::Atproto; 22 + 23 + /// The lexicon method the service-auth token is bound to (matches the hub). 24 + const CONNECT_LXM: &str = "fm.atradio.connect"; 25 + const DEFAULT_SERVICE_DID: &str = "did:web:api.atradio.fm"; 26 + 27 + /// Minimal station description exchanged over the wire. 28 + #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] 29 + pub struct StationLite { 30 + pub id: String, 31 + pub name: String, 32 + pub url: String, 33 + #[serde(default, skip_serializing_if = "Option::is_none")] 34 + pub favicon: Option<String>, 35 + } 36 + 37 + /// A device's playback snapshot, broadcast to peers and shown in the roster. 38 + #[derive(Clone, Debug, Default, Serialize, Deserialize)] 39 + pub struct WireState { 40 + pub playing: bool, 41 + #[serde(default)] 42 + pub station: Option<StationLite>, 43 + #[serde(default, skip_serializing_if = "Option::is_none")] 44 + pub title: Option<String>, 45 + pub volume: f32, 46 + pub muted: bool, 47 + } 48 + 49 + /// A device in the account roster. 50 + #[derive(Clone, Debug, Default, Deserialize)] 51 + pub struct Device { 52 + pub id: String, 53 + pub name: String, 54 + #[serde(default)] 55 + pub platform: String, 56 + #[serde(default, rename = "self")] 57 + pub is_self: bool, 58 + #[serde(default)] 59 + pub state: WireState, 60 + } 61 + 62 + /// A command to apply to *our* player (received from a peer). 63 + #[derive(Clone, Debug)] 64 + pub enum RemoteCmd { 65 + PlayPause, 66 + Play, 67 + Pause, 68 + Stop, 69 + SetVolume(f32), 70 + ToggleMute, 71 + LoadStation(StationLite), 72 + } 73 + 74 + /// An event surfaced to the UI/daemon. 75 + #[derive(Clone, Debug)] 76 + #[allow(dead_code)] // `any_playing` is carried for consumers that want it 77 + pub enum RemoteEvent { 78 + /// Connection status changed (true = online). 79 + Status(bool), 80 + /// The hub acknowledged us with our device id. 81 + Welcome(String), 82 + /// Fresh account roster. 83 + Devices(Vec<Device>), 84 + /// Presence summary; `cleanup` asks us to delete our `actor.status` record. 85 + Presence { any_playing: bool, cleanup: bool }, 86 + } 87 + 88 + /// Cloneable handle to send commands to *other* devices. 89 + #[derive(Clone)] 90 + pub struct RemoteControl { 91 + tx: mpsc::UnboundedSender<Outbound>, 92 + } 93 + 94 + enum Outbound { 95 + Command { target: String, cmd: RemoteCmd }, 96 + } 97 + 98 + impl RemoteControl { 99 + /// Send a control command to a peer device. 100 + pub fn command(&self, target: impl Into<String>, cmd: RemoteCmd) { 101 + let _ = self.tx.send(Outbound::Command { 102 + target: target.into(), 103 + cmd, 104 + }); 105 + } 106 + } 107 + 108 + pub struct RemoteConfig { 109 + pub base_url: String, 110 + pub device_id: String, 111 + pub device_name: String, 112 + pub atproto: Arc<Atproto>, 113 + } 114 + 115 + /// The spawned client's handles. 116 + pub struct Remote { 117 + /// Commands from peers to apply to the local player. 118 + pub cmd_rx: mpsc::UnboundedReceiver<RemoteCmd>, 119 + /// Roster / presence / status events for the UI. 120 + pub evt_rx: mpsc::UnboundedReceiver<RemoteEvent>, 121 + /// Send commands to other devices. 122 + pub control: RemoteControl, 123 + } 124 + 125 + /// Spawn the Connect client. Feed it this device's state through `state`; 126 + /// the returned channels carry inbound commands and UI events. 127 + pub fn spawn(cfg: RemoteConfig, state: watch::Receiver<WireState>) -> Remote { 128 + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); 129 + let (evt_tx, evt_rx) = mpsc::unbounded_channel(); 130 + let (out_tx, out_rx) = mpsc::unbounded_channel(); 131 + tokio::spawn(run(cfg, state, cmd_tx, evt_tx, out_rx)); 132 + Remote { 133 + cmd_rx, 134 + evt_rx, 135 + control: RemoteControl { tx: out_tx }, 136 + } 137 + } 138 + 139 + async fn run( 140 + cfg: RemoteConfig, 141 + mut state: watch::Receiver<WireState>, 142 + cmd_tx: mpsc::UnboundedSender<RemoteCmd>, 143 + evt_tx: mpsc::UnboundedSender<RemoteEvent>, 144 + mut out_rx: mpsc::UnboundedReceiver<Outbound>, 145 + ) { 146 + let mut backoff = 1000u64; 147 + loop { 148 + let service_did = discover_service_did(&cfg.base_url).await; 149 + match connect_once( 150 + &cfg, 151 + &service_did, 152 + &mut state, 153 + &cmd_tx, 154 + &evt_tx, 155 + &mut out_rx, 156 + ) 157 + .await 158 + { 159 + Ok(true) => return, // local shutdown (state/control dropped) 160 + Ok(false) | Err(_) => {} 161 + } 162 + let _ = evt_tx.send(RemoteEvent::Status(false)); 163 + tokio::time::sleep(Duration::from_millis(backoff)).await; 164 + backoff = (backoff * 2).min(15000); 165 + } 166 + } 167 + 168 + /// One connection attempt. Returns `Ok(true)` when the caller should stop 169 + /// (this device is shutting down), `Ok(false)`/`Err` to reconnect. 170 + async fn connect_once( 171 + cfg: &RemoteConfig, 172 + service_did: &str, 173 + state: &mut watch::Receiver<WireState>, 174 + cmd_tx: &mpsc::UnboundedSender<RemoteCmd>, 175 + evt_tx: &mpsc::UnboundedSender<RemoteEvent>, 176 + out_rx: &mut mpsc::UnboundedReceiver<Outbound>, 177 + ) -> Result<bool> { 178 + let token = cfg 179 + .atproto 180 + .mint_service_auth(service_did, CONNECT_LXM) 181 + .await?; 182 + let (ws, _resp) = tokio_tungstenite::connect_async(ws_url(&cfg.base_url)).await?; 183 + let (mut sink, mut stream) = ws.split(); 184 + 185 + let hello = json!({ 186 + "t": "hello", 187 + "token": token, 188 + "device": { 189 + "id": cfg.device_id, 190 + "name": cfg.device_name, 191 + "platform": "cli", 192 + "state": state.borrow().clone(), 193 + } 194 + }); 195 + sink.send(Message::Text(hello.to_string().into())).await?; 196 + 197 + loop { 198 + tokio::select! { 199 + msg = stream.next() => { 200 + let Some(msg) = msg else { return Ok(false) }; 201 + match msg? { 202 + Message::Text(txt) => handle_server_text(txt.as_str(), cmd_tx, evt_tx), 203 + Message::Ping(p) => sink.send(Message::Pong(p)).await?, 204 + Message::Close(_) => return Ok(false), 205 + _ => {} 206 + } 207 + } 208 + changed = state.changed() => { 209 + if changed.is_err() { return Ok(true); } // owner gone → shut down 210 + let cur = state.borrow().clone(); 211 + let frame = json!({ "t": "state", "state": cur }); 212 + sink.send(Message::Text(frame.to_string().into())).await?; 213 + } 214 + out = out_rx.recv() => { 215 + match out { 216 + Some(Outbound::Command { target, cmd }) => { 217 + let frame = json!({ 218 + "t": "command", 219 + "target": target, 220 + "cmd": cmd_to_json(&cmd), 221 + }); 222 + sink.send(Message::Text(frame.to_string().into())).await?; 223 + } 224 + None => return Ok(true), 225 + } 226 + } 227 + } 228 + } 229 + } 230 + 231 + fn handle_server_text( 232 + txt: &str, 233 + cmd_tx: &mpsc::UnboundedSender<RemoteCmd>, 234 + evt_tx: &mpsc::UnboundedSender<RemoteEvent>, 235 + ) { 236 + let Ok(v) = serde_json::from_str::<Value>(txt) else { 237 + return; 238 + }; 239 + match v.get("t").and_then(Value::as_str) { 240 + Some("welcome") => { 241 + if let Some(id) = v.get("deviceId").and_then(Value::as_str) { 242 + let _ = evt_tx.send(RemoteEvent::Welcome(id.to_string())); 243 + } 244 + let _ = evt_tx.send(RemoteEvent::Status(true)); 245 + } 246 + Some("devices") => { 247 + if let Some(devs) = v 248 + .get("devices") 249 + .and_then(|d| serde_json::from_value::<Vec<Device>>(d.clone()).ok()) 250 + { 251 + let _ = evt_tx.send(RemoteEvent::Devices(devs)); 252 + } 253 + } 254 + Some("command") => { 255 + if let Some(cmd) = v.get("cmd").and_then(json_to_cmd) { 256 + let _ = cmd_tx.send(cmd); 257 + } 258 + } 259 + Some("presence") => { 260 + let any_playing = v 261 + .get("anyPlaying") 262 + .and_then(Value::as_bool) 263 + .unwrap_or(false); 264 + let cleanup = v.get("cleanup").and_then(Value::as_bool).unwrap_or(false); 265 + let _ = evt_tx.send(RemoteEvent::Presence { 266 + any_playing, 267 + cleanup, 268 + }); 269 + } 270 + _ => {} 271 + } 272 + } 273 + 274 + fn cmd_to_json(cmd: &RemoteCmd) -> Value { 275 + match cmd { 276 + RemoteCmd::PlayPause => json!({ "action": "playPause" }), 277 + RemoteCmd::Play => json!({ "action": "play" }), 278 + RemoteCmd::Pause => json!({ "action": "pause" }), 279 + RemoteCmd::Stop => json!({ "action": "stop" }), 280 + RemoteCmd::SetVolume(v) => json!({ "action": "setVolume", "value": v }), 281 + RemoteCmd::ToggleMute => json!({ "action": "toggleMute" }), 282 + RemoteCmd::LoadStation(s) => json!({ "action": "playStation", "station": s }), 283 + } 284 + } 285 + 286 + fn json_to_cmd(v: &Value) -> Option<RemoteCmd> { 287 + match v.get("action").and_then(Value::as_str)? { 288 + "playPause" => Some(RemoteCmd::PlayPause), 289 + "play" => Some(RemoteCmd::Play), 290 + "pause" => Some(RemoteCmd::Pause), 291 + "stop" => Some(RemoteCmd::Stop), 292 + "toggleMute" => Some(RemoteCmd::ToggleMute), 293 + "setVolume" => v 294 + .get("value") 295 + .and_then(Value::as_f64) 296 + .map(|f| RemoteCmd::SetVolume(f as f32)), 297 + "playStation" => v 298 + .get("station") 299 + .and_then(|s| serde_json::from_value::<StationLite>(s.clone()).ok()) 300 + .map(RemoteCmd::LoadStation), 301 + _ => None, 302 + } 303 + } 304 + 305 + async fn discover_service_did(base: &str) -> String { 306 + #[derive(Deserialize)] 307 + struct Health { 308 + #[serde(rename = "connectDid")] 309 + connect_did: Option<String>, 310 + } 311 + match reqwest::get(format!("{}/health", base.trim_end_matches('/'))).await { 312 + Ok(r) => match r.json::<Health>().await { 313 + Ok(h) => h 314 + .connect_did 315 + .unwrap_or_else(|| DEFAULT_SERVICE_DID.to_string()), 316 + Err(_) => DEFAULT_SERVICE_DID.to_string(), 317 + }, 318 + Err(_) => DEFAULT_SERVICE_DID.to_string(), 319 + } 320 + } 321 + 322 + fn ws_url(base: &str) -> String { 323 + let b = base.trim_end_matches('/'); 324 + let b = if let Some(rest) = b.strip_prefix("https://") { 325 + format!("wss://{rest}") 326 + } else if let Some(rest) = b.strip_prefix("http://") { 327 + format!("ws://{rest}") 328 + } else { 329 + b.to_string() 330 + }; 331 + format!("{b}/connect") 332 + } 333 + 334 + /// A stable device id for this install, persisted next to the session. Avoids a 335 + /// uuid dependency — a time+pid hash is unique enough for one machine. 336 + pub fn load_or_create_device_id(session_path: &Path) -> String { 337 + let path = session_path.with_file_name("device_id"); 338 + if let Ok(existing) = std::fs::read_to_string(&path) { 339 + let t = existing.trim(); 340 + if !t.is_empty() { 341 + return t.to_string(); 342 + } 343 + } 344 + let nanos = std::time::SystemTime::now() 345 + .duration_since(std::time::UNIX_EPOCH) 346 + .map(|d| d.as_nanos()) 347 + .unwrap_or(0); 348 + let id = format!("cli-{:x}-{:x}", std::process::id(), nanos); 349 + let _ = std::fs::write(&path, &id); 350 + id 351 + } 352 + 353 + /// Default device name (settings.toml can override it): hostname-based. 354 + pub fn default_device_name() -> String { 355 + std::env::var("HOSTNAME") 356 + .ok() 357 + .or_else(|| std::env::var("COMPUTERNAME").ok()) 358 + .map(|s| s.trim().to_string()) 359 + .filter(|s| !s.is_empty()) 360 + .map(|h| format!("atradio CLI · {h}")) 361 + .unwrap_or_else(|| "atradio CLI".to_string()) 362 + }
+6
cli/src/settings.rs
··· 14 14 pub struct Settings { 15 15 pub volume: f32, 16 16 17 + /// atradio Connect device name shown to other clients. Editable in 18 + /// `settings.toml`; falls back to a hostname-based default when empty. 19 + #[serde(default, skip_serializing_if = "Option::is_none")] 20 + pub device_name: Option<String>, 21 + 17 22 pub eq_enabled: bool, 18 23 pub eq_gains: Vec<f32>, 19 24 pub bass: i32, ··· 35 40 let d = AudioSettings::default(); 36 41 Self { 37 42 volume: 0.8, 43 + device_name: None, 38 44 eq_enabled: d.eq_enabled, 39 45 eq_gains: d.eq_gains.to_vec(), 40 46 bass: d.bass,
+227 -4
cli/src/tui/mod.rs
··· 27 27 use crate::config::Config; 28 28 use crate::player::{Player, State as PlayState}; 29 29 use crate::radio::RadioBrowser; 30 + use crate::remote::{RemoteCmd, RemoteConfig, RemoteEvent, StationLite, WireState}; 30 31 use state::{AddStationForm, App, HomeTab, Overlay, View}; 31 32 32 33 type Term = Terminal<CrosstermBackend<Stdout>>; ··· 106 107 player.set_volume(settings.volume); 107 108 player.apply_dsp(&app.dsp); 108 109 110 + // atradio Connect: register this process as a controllable device and open 111 + // the remote-control channel. Snapshots go out over a watch channel; 112 + // inbound commands + roster/presence events come back over mpsc. 113 + let device_id = crate::remote::load_or_create_device_id(&config.session_path); 114 + let device_name = settings 115 + .device_name 116 + .clone() 117 + .filter(|s| !s.trim().is_empty()) 118 + .unwrap_or_else(crate::remote::default_device_name); 119 + let (local_state_tx, local_state_rx) = tokio::sync::watch::channel(WireState::default()); 120 + let (mut remote_cmd_rx, mut remote_evt_rx) = if atproto.is_logged_in() { 121 + let remote = crate::remote::spawn( 122 + RemoteConfig { 123 + base_url: config.appview_url.clone(), 124 + device_id: device_id.clone(), 125 + device_name, 126 + atproto: atproto.clone(), 127 + }, 128 + local_state_rx, 129 + ); 130 + app.remote_control = Some(remote.control); 131 + (remote.cmd_rx, remote.evt_rx) 132 + } else { 133 + // Logged out: closed channels, so the select! arms below never fire 134 + // (same trick as the non-Linux MPRIS branch). 135 + let (_c, cmd_rx) = mpsc::unbounded_channel::<RemoteCmd>(); 136 + let (_e, evt_rx) = mpsc::unbounded_channel::<RemoteEvent>(); 137 + (cmd_rx, evt_rx) 138 + }; 139 + app.self_device_id = Some(device_id); 140 + 109 141 // Set up the terminal. 110 142 let mut term = setup_terminal()?; 111 143 ··· 132 164 let _ = mpris_np_tx.send(np.clone()); 133 165 app.volume = player.volume(); 134 166 app.muted = player.is_muted(); 167 + // Broadcast this device's playback to the Connect hub. 168 + let _ = local_state_tx.send(WireState { 169 + playing: matches!(np.state, PlayState::Playing), 170 + station: app.current.as_ref().map(station_to_lite), 171 + title: np.line(), 172 + volume: player.volume(), 173 + muted: player.is_muted(), 174 + }); 135 175 if let Err(e) = term.draw(|f| ui::draw(f, &app, &np)) { 136 176 break Err(e.into()); 137 177 } ··· 160 200 MprisCmd::SetVolume(v) => player.set_volume(v), 161 201 } 162 202 } 203 + // atradio Connect: a peer is controlling this device. 204 + Some(cmd) = remote_cmd_rx.recv() => { 205 + apply_remote_cmd(cmd, &mut app, &player, &browser, &atproto, &appview, &tx); 206 + } 207 + // atradio Connect: roster / presence / status updates. 208 + Some(evt) = remote_evt_rx.recv() => { 209 + apply_remote_event(evt, &mut app, &atproto); 210 + } 163 211 _ = ticker.tick() => { 164 212 app.toast.tick(); 165 213 // Fire a debounced search fetch if the query changed. ··· 286 334 Overlay::Compose => return handle_compose_keys(key.code, app, atproto, tx), 287 335 Overlay::SignIn => return handle_signin_keys(key.code, app), 288 336 Overlay::AddStation => return handle_add_station_keys(key, app, appview, atproto, tx), 337 + Overlay::Devices => return handle_devices_keys(key.code, app), 289 338 Overlay::None => {} 290 339 } 291 340 ··· 342 391 } 343 392 } 344 393 KeyCode::Char(' ') => { 345 - player.toggle(); 394 + if !route_remote(app, RemoteCmd::PlayPause) { 395 + player.toggle(); 396 + } 346 397 } 347 398 KeyCode::Char('m') => { 348 - player.toggle_mute(); 399 + if !route_remote(app, RemoteCmd::ToggleMute) { 400 + player.toggle_mute(); 401 + } 402 + } 403 + KeyCode::Char('d') => { 404 + if app.logged_in { 405 + app.device_sel = 0; 406 + app.overlay = Overlay::Devices; 407 + } else { 408 + app.toast.set("Sign in first (press s) to use Connect."); 409 + } 349 410 } 350 411 KeyCode::Char('+') | KeyCode::Char('=') => { 351 412 if app.view == View::Dsp { 352 413 if dsp_rows::adjust(&mut app.dsp, app.dsp_row, 1) { 353 414 player.apply_dsp(&app.dsp); 354 415 } 355 - } else { 416 + } else if !route_remote_volume(app, 0.05) { 356 417 player.bump_volume(0.05); 357 418 } 358 419 } ··· 361 422 if dsp_rows::adjust(&mut app.dsp, app.dsp_row, -1) { 362 423 player.apply_dsp(&app.dsp); 363 424 } 364 - } else { 425 + } else if !route_remote_volume(app, -0.05) { 365 426 player.bump_volume(-0.05); 366 427 } 367 428 } ··· 448 509 let Some(station) = station else { 449 510 return; 450 511 }; 512 + // Controlling a remote device: send the station there instead of playing it. 513 + if app.remote_active() { 514 + if route_remote(app, RemoteCmd::LoadStation(station_to_lite(&station))) { 515 + app.toast.set(format!("▶ {} → remote", station.name)); 516 + return; 517 + } 518 + } 451 519 start_playing(app, player, browser, atproto, appview, tx, station); 452 520 } 453 521 ··· 491 559 } 492 560 } 493 561 }); 562 + } 563 + } 564 + 565 + // ---- atradio Connect (remote control) -------------------------------------- 566 + 567 + fn station_to_lite(s: &StationInfo) -> StationLite { 568 + StationLite { 569 + id: s.station_id.clone(), 570 + name: s.name.clone(), 571 + url: s.stream_url.clone(), 572 + favicon: s.logo.clone(), 573 + } 574 + } 575 + 576 + fn lite_to_station(s: StationLite) -> StationInfo { 577 + let source = if s.id.starts_with("tunein:") { 578 + "tunein" 579 + } else if s.id.starts_with("custom:") { 580 + "custom" 581 + } else { 582 + "radio-browser" 583 + }; 584 + StationInfo { 585 + station_id: s.id, 586 + name: s.name, 587 + stream_url: s.url, 588 + source: source.to_string(), 589 + logo: s.favicon, 590 + ..Default::default() 591 + } 592 + } 593 + 594 + /// Route a transport command to the controlled remote device. Returns true when 595 + /// it was sent (i.e. a remote is active) so callers can skip the local action. 596 + fn route_remote(app: &App, cmd: RemoteCmd) -> bool { 597 + if let (Some(target), Some(ctrl)) = ( 598 + app.remote_target.clone().filter(|_| app.remote_active()), 599 + app.remote_control.clone(), 600 + ) { 601 + ctrl.command(target, cmd); 602 + true 603 + } else { 604 + false 605 + } 606 + } 607 + 608 + /// Route a relative volume change to the remote (from its last-known volume). 609 + fn route_remote_volume(app: &App, delta: f32) -> bool { 610 + let Some(dev) = app.remote_target_device() else { 611 + return false; 612 + }; 613 + let next = (dev.state.volume + delta).clamp(0.0, 1.0); 614 + route_remote(app, RemoteCmd::SetVolume(next)) 615 + } 616 + 617 + /// Apply a command received from a peer to the local player. 618 + fn apply_remote_cmd( 619 + cmd: RemoteCmd, 620 + app: &mut App, 621 + player: &Arc<Player>, 622 + browser: &RadioBrowser, 623 + atproto: &Arc<Atproto>, 624 + appview: &AppView, 625 + tx: &mpsc::UnboundedSender<Msg>, 626 + ) { 627 + match cmd { 628 + RemoteCmd::Play => player.play(), 629 + RemoteCmd::Pause => player.pause(), 630 + RemoteCmd::PlayPause => player.toggle(), 631 + RemoteCmd::Stop => player.stop(), 632 + RemoteCmd::SetVolume(v) => player.set_volume(v), 633 + RemoteCmd::ToggleMute => player.toggle_mute(), 634 + RemoteCmd::LoadStation(s) => { 635 + // Being told to play a station means this device becomes active. 636 + app.remote_target = None; 637 + start_playing( 638 + app, 639 + player, 640 + browser, 641 + atproto, 642 + appview, 643 + tx, 644 + lite_to_station(s), 645 + ); 646 + } 647 + } 648 + } 649 + 650 + /// Apply a roster / presence / status event from the hub. 651 + fn apply_remote_event(evt: RemoteEvent, app: &mut App, atproto: &Arc<Atproto>) { 652 + match evt { 653 + RemoteEvent::Status(online) => app.connect_online = online, 654 + RemoteEvent::Welcome(id) => app.self_device_id = Some(id), 655 + RemoteEvent::Devices(devices) => { 656 + app.remote_devices = devices; 657 + // Forget a target that dropped off. 658 + if let Some(t) = app.remote_target.clone() { 659 + if !app.remote_devices.iter().any(|d| d.id == t && !d.is_self) { 660 + app.remote_target = None; 661 + } 662 + } 663 + let len = app.remote_devices.len(); 664 + if len > 0 && app.device_sel >= len { 665 + app.device_sel = len - 1; 666 + } 667 + } 668 + RemoteEvent::Presence { 669 + any_playing: _, 670 + cleanup, 671 + } => { 672 + if cleanup && atproto.is_logged_in() { 673 + let at = atproto.clone(); 674 + tokio::spawn(async move { 675 + let _ = at.delete_play_status().await; 676 + }); 677 + } 678 + } 679 + } 680 + } 681 + 682 + /// Keys for the device-picker overlay. The list is [this device, …peers]; 683 + /// Enter picks the active device (Spotify-style transfer handled by the hub + 684 + /// state broadcast). 685 + fn handle_devices_keys(code: KeyCode, app: &mut App) { 686 + let others = app.other_devices().len(); 687 + let total = 1 + others; // "this device" + peers 688 + match code { 689 + KeyCode::Esc | KeyCode::Char('d') | KeyCode::Char('q') => app.overlay = Overlay::None, 690 + KeyCode::Up | KeyCode::Char('k') => { 691 + app.device_sel = app.device_sel.saturating_sub(1); 692 + } 693 + KeyCode::Down | KeyCode::Char('j') => { 694 + if app.device_sel + 1 < total { 695 + app.device_sel += 1; 696 + } 697 + } 698 + KeyCode::Enter => { 699 + if app.device_sel == 0 { 700 + // "This device" — take back control. 701 + if let (Some(prev), Some(ctrl)) = 702 + (app.remote_target.take(), app.remote_control.clone()) 703 + { 704 + // Ask the device we were controlling to stop. 705 + ctrl.command(prev, RemoteCmd::Stop); 706 + } 707 + app.toast.set("Playing on this device"); 708 + } else if let Some(dev) = app.other_devices().get(app.device_sel - 1) { 709 + let id = dev.id.clone(); 710 + let name = dev.name.clone(); 711 + app.remote_target = Some(id); 712 + app.toast.set(format!("Controlling {name}")); 713 + } 714 + app.overlay = Overlay::None; 715 + } 716 + _ => {} 494 717 } 495 718 } 496 719
+40
cli/src/tui/state.rs
··· 35 35 SignIn, 36 36 /// Add-a-custom-station form. 37 37 AddStation, 38 + /// atradio Connect device picker. 39 + Devices, 38 40 } 39 41 40 42 /// The add-station form: an ordered set of text fields. ··· 202 204 pub volume: f32, 203 205 pub muted: bool, 204 206 207 + // ---- atradio Connect (remote control) ---- 208 + /// Handle for sending commands to other devices (None when logged out). 209 + pub remote_control: Option<crate::remote::RemoteControl>, 210 + /// Account roster (this device + peers), newest snapshot from the hub. 211 + pub remote_devices: Vec<crate::remote::Device>, 212 + /// The device we're currently controlling; None = play on this device. 213 + pub remote_target: Option<String>, 214 + /// This device's id, once the hub acknowledges it. 215 + pub self_device_id: Option<String>, 216 + /// Whether the Connect socket is currently online. 217 + pub connect_online: bool, 218 + /// Selection index within the device-picker overlay. 219 + pub device_sel: usize, 220 + 205 221 // ---- DSP ---- 206 222 pub dsp: AudioSettings, 207 223 pub dsp_row: usize, ··· 253 269 current: None, 254 270 volume: 0.8, 255 271 muted: false, 272 + remote_control: None, 273 + remote_devices: Vec::new(), 274 + remote_target: None, 275 + self_device_id: None, 276 + connect_online: false, 277 + device_sel: 0, 256 278 dsp: AudioSettings::default(), 257 279 dsp_row: 0, 258 280 logged_in, ··· 265 287 toast: Toast::default(), 266 288 matcher: SkimMatcherV2::default(), 267 289 } 290 + } 291 + 292 + /// The device we're controlling, if it's still present in the roster. 293 + pub fn remote_target_device(&self) -> Option<&crate::remote::Device> { 294 + let id = self.remote_target.as_ref()?; 295 + self.remote_devices 296 + .iter() 297 + .find(|d| &d.id == id && !d.is_self) 298 + } 299 + 300 + /// True when transport actions should be routed to a remote device. 301 + pub fn remote_active(&self) -> bool { 302 + self.remote_target_device().is_some() 303 + } 304 + 305 + /// Peers (devices other than this one) — the remote targets in the picker. 306 + pub fn other_devices(&self) -> Vec<&crate::remote::Device> { 307 + self.remote_devices.iter().filter(|d| !d.is_self).collect() 268 308 } 269 309 270 310 /// The station list backing the active home tab.
+160
cli/src/tui/ui.rs
··· 47 47 Overlay::Compose => draw_compose(f, f.area(), app), 48 48 Overlay::SignIn => draw_signin(f, f.area(), app), 49 49 Overlay::AddStation => draw_add_station(f, f.area(), app), 50 + Overlay::Devices => draw_devices(f, f.area(), app), 50 51 Overlay::None => {} 51 52 } 52 53 } ··· 597 598 ]) 598 599 .split(inner); 599 600 601 + // atradio Connect: when controlling a remote device, the player bar shows 602 + // that device's playback instead of local audio. 603 + if let Some(dev) = app.remote_target_device() { 604 + let st = &dev.state; 605 + let glyph = if st.playing { "▶" } else { "⏸" }; 606 + let name = st 607 + .station 608 + .as_ref() 609 + .map(|s| s.name.clone()) 610 + .unwrap_or_else(|| "—".into()); 611 + f.render_widget( 612 + Paragraph::new(Line::from(vec![ 613 + Span::styled("◉ ", Style::default().fg(theme::ORANGE)), 614 + Span::styled( 615 + format!("Controlling {} ", truncate(&dev.name, 24)), 616 + Style::default() 617 + .fg(theme::ORANGE) 618 + .add_modifier(Modifier::BOLD), 619 + ), 620 + Span::styled(format!("{glyph} "), Style::default().fg(theme::GREEN)), 621 + Span::styled(truncate(&name, 36), Style::default().fg(theme::FG)), 622 + ])), 623 + rows[0], 624 + ); 625 + let title = st.title.clone().unwrap_or_else(|| "—".into()); 626 + f.render_widget( 627 + Paragraph::new(Line::from(vec![ 628 + Span::styled("♪ ", Style::default().fg(theme::CYAN)), 629 + Span::styled(truncate(&title, 48), Style::default().fg(theme::CYAN)), 630 + ])), 631 + rows[1], 632 + ); 633 + let vol = ((st.volume * 100.0).round() as i64).clamp(0, 100) as u16; 634 + f.render_widget( 635 + Paragraph::new(Line::from(vec![ 636 + Span::styled("vol ", Style::default().fg(theme::MUTED)), 637 + Span::styled(volume_bar(vol), Style::default().fg(theme::TEAL)), 638 + Span::styled( 639 + format!(" {vol}% · d: switch device"), 640 + Style::default().fg(theme::MUTED), 641 + ), 642 + ])), 643 + rows[2], 644 + ); 645 + return; 646 + } 647 + 600 648 match &app.current { 601 649 Some(s) => { 602 650 let state = crate::tui::status_glyph(np.state); ··· 889 937 .wrap(ratatui::widgets::Wrap { trim: true }), 890 938 hint, 891 939 ); 940 + } 941 + 942 + fn render_device_row( 943 + f: &mut Frame, 944 + area: Rect, 945 + name: &str, 946 + platform: &str, 947 + sub: Option<String>, 948 + active: bool, 949 + selected: bool, 950 + ) { 951 + let dot = if active { "● " } else { "○ " }; 952 + let mut spans = vec![ 953 + Span::styled( 954 + if selected { "› " } else { " " }, 955 + Style::default().fg(theme::CYAN), 956 + ), 957 + Span::styled( 958 + dot, 959 + Style::default().fg(if active { theme::GREEN } else { theme::MUTED }), 960 + ), 961 + Span::styled( 962 + truncate(name, 24), 963 + Style::default() 964 + .fg(if selected { theme::TEAL } else { theme::FG }) 965 + .add_modifier(if selected { 966 + Modifier::BOLD 967 + } else { 968 + Modifier::empty() 969 + }), 970 + ), 971 + Span::styled(format!(" ({platform})"), Style::default().fg(theme::MUTED)), 972 + ]; 973 + if let Some(sub) = sub { 974 + spans.push(Span::styled( 975 + format!(" {}", truncate(&sub, 26)), 976 + Style::default().fg(theme::INDIGO), 977 + )); 978 + } 979 + f.render_widget(Paragraph::new(Line::from(spans)), area); 980 + } 981 + 982 + fn draw_devices(f: &mut Frame, area: Rect, app: &App) { 983 + let popup = centered(area, 62, 55); 984 + f.render_widget(Clear, popup); 985 + let title = if app.connect_online { 986 + "atradio Connect · devices" 987 + } else { 988 + "atradio Connect · connecting…" 989 + }; 990 + let block = panel(title, true); 991 + let inner = block.inner(popup); 992 + f.render_widget(block, popup); 993 + 994 + let others = app.other_devices(); 995 + let total = 1 + others.len(); 996 + 997 + let mut constraints: Vec<Constraint> = (0..total).map(|_| Constraint::Length(1)).collect(); 998 + constraints.push(Constraint::Length(1)); // spacer 999 + constraints.push(Constraint::Min(1)); // hint 1000 + let rows = Layout::default() 1001 + .direction(Direction::Vertical) 1002 + .constraints(constraints) 1003 + .split(inner); 1004 + 1005 + // "This device" is the active player unless we're controlling a peer. 1006 + render_device_row( 1007 + f, 1008 + rows[0], 1009 + "This device", 1010 + "here", 1011 + None, 1012 + !app.remote_active(), 1013 + app.device_sel == 0, 1014 + ); 1015 + 1016 + for (i, dev) in others.iter().enumerate() { 1017 + let idx = i + 1; 1018 + let active = app.remote_target.as_deref() == Some(dev.id.as_str()); 1019 + let sub = match &dev.state.station { 1020 + Some(s) => Some(format!( 1021 + "{} · {}", 1022 + if dev.state.playing { 1023 + "playing" 1024 + } else { 1025 + "paused" 1026 + }, 1027 + s.name 1028 + )), 1029 + None => Some("idle".to_string()), 1030 + }; 1031 + render_device_row( 1032 + f, 1033 + rows[idx], 1034 + &dev.name, 1035 + &dev.platform, 1036 + sub, 1037 + active, 1038 + app.device_sel == idx, 1039 + ); 1040 + } 1041 + 1042 + if let Some(hint) = rows.get(total + 1) { 1043 + f.render_widget( 1044 + Paragraph::new(Line::from(Span::styled( 1045 + "↑/↓ select · Enter play here / control · Esc close", 1046 + Style::default().fg(theme::MUTED), 1047 + ))) 1048 + .wrap(ratatui::widgets::Wrap { trim: true }), 1049 + *hint, 1050 + ); 1051 + } 892 1052 } 893 1053 894 1054 // ---- helpers -------------------------------------------------------------