Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2612,6 +2612,75 @@ paths:
default:
$ref: "#/components/responses/Error"

/rooms/{roomId}/devices:
parameters:
- $ref: "#/components/parameters/RoomId"
get:
operationId: listRoomDevices
summary: Everything connected to the room
description: |
The room's paired screens (online or not), and every signed-in
device with the room open right now: phones, computers, and big
screens opened while signed in. A browser's tabs share one device.
Each says whether you may make it the speaker (`canSpeak`) and stop
it being the speaker (`canStop`) from here. Lives in memory: a
device is listed while its room socket is open.
tags: [devices]
responses:
"200":
description: Paired screens first, then everyone's devices
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/RoomDevice"
default:
$ref: "#/components/responses/Error"

/rooms/{roomId}/devices/{deviceId}/speaker:
parameters:
- $ref: "#/components/parameters/RoomId"
- name: deviceId
in: path
required: true
schema:
type: string
put:
operationId: makeDeviceSpeaker
summary: Make another device the speaker
description: |
Tells that device, over its room socket, to become the room's
speaker; it claims the room itself, as with `claimPlayer`. Needs
the room's `speaker` permission. A paired screen also needs its
audio on, and you must be able to manage it (the room's owner,
whoever paired it, or an admin). Any other device must be your
own, and not a phone: phones only start audio after a tap on
them (`device_is_phone`). `device_offline` if it isn't connected.
tags: [devices]
responses:
"202":
description: Sent to the device
default:
$ref: "#/components/responses/Error"
delete:
operationId: stopDeviceSpeaker
summary: Stop a device being the speaker
description: |
Tells the device to stop, and lets go of it: the song pauses. A
paired screen: whoever may manage it. Anything else: its own user,
the room's owner, or an admin. `not_player` if it isn't the speaker.
tags: [devices]
responses:
"200":
description: The playback state without a speaker
content:
application/json:
schema:
$ref: "#/components/schemas/NowPlaying"
default:
$ref: "#/components/responses/Error"

/rooms/{roomId}/displays/pairing/{code}:
parameters:
- $ref: "#/components/parameters/RoomId"
Expand Down Expand Up @@ -7511,6 +7580,67 @@ components:
roomId:
type: string

RoomDevice:
type: object
description: |
Something connected to a room: a paired screen, or someone's
browser. A browser is listed while it has the room open.
required: [id, kind, name, online, canSpeak, canStop]
properties:
id:
type: string
description: The device ID it plays the room as (a paired screen's display ID).
kind:
type: string
enum: [screen, computer, phone]
description: |
`screen`: a paired display, or a big screen opened while signed
in. `phone` covers tablets too: anything that needs a tap on it
to play.
name:
type: string
description: A screen's name, or what the browser says it is (Mac, iPhone).
online:
type: boolean
userId:
type: string
description: Whose device it is. Absent for a paired screen.
userName:
type: string
displayId:
type: string
description: Set for a paired screen.
canSpeak:
type: boolean
description: You may make it the speaker from here (makeDeviceSpeaker).
canStop:
type: boolean
description: You may stop it being the speaker, when it is (stopDeviceSpeaker).

DevicesChanged:
type: object
required: [roomId]
properties:
roomId:
type: string

DeviceCommand:
type: object
description: |
For one device only, from someone in the room: become the speaker,
or stop being it. Sent to a paired screen's own connections, or to
its user's, and only the browser whose device ID is `deviceId`
acts on it.
required: [action, deviceId, roomId]
properties:
action:
type: string
enum: [speak, stop]
deviceId:
type: string
roomId:
type: string

PairDisplayRequest:
type: object
required: [code]
Expand Down Expand Up @@ -8147,6 +8277,15 @@ components:
fetch the displays again.
- `display.command`: DisplayCommand. To one paired display only:
reload, or (a box) restart. See reloadDisplay and restartDisplay.
- `devices.updated`: DevicesChanged. A browser opened the room or
closed it: fetch the devices again (listRoomDevices). Screens
coming and going say `displays.updated`.
- `device.command`: DeviceCommand. To one device: become the
speaker, or stop. See makeDeviceSpeaker.

A signed-in browser may say what it is with `kind` (`computer` or
`phone`) and `name` (like `Mac`, at most 40 characters), alongside
its `device`, for the room's device list.

A paired display connects with its display cookie instead, to its
own room only. So does a signed-in user with `display=1`: either way
Expand All @@ -8169,7 +8308,7 @@ components:
properties:
type:
type: string
enum: [hello, queue.updated, nowplaying.updated, playback.notice, member.joined, member.left, link.status, room.updated, room.deleted, reaction.sent, hearts.updated, night.ended, guests.updated, members.updated, game.round, game.scores, game.queue, chat.message, chat.deleted, displays.updated, display.command]
enum: [hello, queue.updated, nowplaying.updated, playback.notice, member.joined, member.left, link.status, room.updated, room.deleted, reaction.sent, hearts.updated, night.ended, guests.updated, members.updated, game.round, game.scores, game.queue, chat.message, chat.deleted, displays.updated, display.command, devices.updated, device.command]
version:
type: integer
format: int64
Expand Down
44 changes: 44 additions & 0 deletions docs/adr/0019-room-devices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR 0019: Room devices

- **Status:** accepted
- **Date:** 2026-10-17

## Context

Only the device itself can become a room's speaker: `PUT /rooms/{id}/player` takes the caller's own device (ADR 0003). That's fine for a phone in your hand, but not for a screen across the room. Once a Syncphony box (ADR 0016) stops being the speaker — someone takes over, or presses Stop — the only way back is to bring the TV up and press "Play the audio here" with the remote.

A room should show what's connected to it, and let people hand the speaker around from wherever they are.

## Decision

### The room lists its devices

`GET /rooms/{id}/devices` lists the room's paired screens (online or not), then every signed-in browser with the room open right now. Paired screens' online state already comes from ADR 0016. Browsers now say what they are on the room socket: `device` (as before), `kind` (`computer` or `phone`), and `name` (`Mac`, `iPhone`). A browser that doesn't say is a phone, so it's never sent the speaker by mistake. A big screen opened while signed in (`display=1`) is a screen. Browser tabs share a device ID, so they're one device.

All of this is in memory, like the box registry (no migration). The room hears `devices.updated` when a browser first opens it or last closes it; screens still say `displays.updated`.

Each device carries `canSpeak` and `canStop` for whoever's asking, so clients don't re-derive the rules.

### The device claims the room itself

`PUT /rooms/{id}/devices/{deviceId}/speaker` doesn't make the device the speaker. It sends `device.command {action: "speak"}` to that device alone, and the device claims the room through the usual `PUT /player`, under its own credentials and its own name. The device is the one that has to start the audio, so it's the one that should claim. A screen's command goes on its display topic. A browser's goes on its user's topic, and the server only passes it to the socket whose device ID and room match.

On a box, Chromium allows autoplay, so the music starts by itself. On any other screen or computer that still blocks it, the existing "Press OK / Tap to start the audio" button appears.

The `/tv` page acts on it the same way as pressing its own button: it starts the room if it was paused and nobody else had it, and it remembers to pick up again after a reload. In the app, the room's live component does the same as "Play on this device".

### Who may send it where

- **A paired screen:** whoever may manage it (the room's owner, whoever paired it, or an admin), with its audio on, while it's online. It plays as whoever paired it, so both they and the caller need the room's `speaker` permission.
- **A browser:** only your own, only a computer or a signed-in big screen, and you need the `speaker` permission. You can't push audio onto someone else's device. Phones are never sent the speaker (`device_is_phone`): their browsers only play after a tap on the phone itself.

### Stopping

Releasing a speaker from somewhere else used to be undone at once: a speaker that finds the server forgot it claims the room again (it assumes a server restart). So `DELETE /rooms/{id}/devices/{deviceId}/speaker` first sends `device.command {action: "stop"}` to the device, then lets go of it (`Playback.Drop`). The command reaches the device before the `nowplaying.updated` without a speaker, so the device has already gone quiet and doesn't reclaim. A screen can be stopped by whoever may manage it. A browser can be stopped by its user, the room's owner, or an admin. A speaker that isn't connected (a locked phone) can still be stopped: the release alone is enough there.

## Consequences

- The Devices list is in the room menu and under the speaker panel. The Screens list in the Big screen dialog gets "Make it the speaker" and "Stop the speaker" for screens with audio.
- From the Devices list, your own browser gets "Play here", which runs from the tap, so a phone can still make itself the speaker there.
- A box that dropped off as the speaker can be brought back from any phone in the room.
- Old clients that don't know `device.command` ignore it. Their sockets don't send `kind`, so they're listed as phones and never sent the speaker.
Loading
Loading