diff --git a/api/openapi.yaml b/api/openapi.yaml index 1af8e5e..c267190 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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" @@ -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] @@ -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 @@ -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 diff --git a/docs/adr/0019-room-devices.md b/docs/adr/0019-room-devices.md new file mode 100644 index 0000000..3a12354 --- /dev/null +++ b/docs/adr/0019-room-devices.md @@ -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. diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index 4e0336a..609692a 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -201,6 +201,24 @@ func (e BoxUpdateResult) Valid() bool { } } +// Defines values for DeviceCommandAction. +const ( + Speak DeviceCommandAction = "speak" + Stop DeviceCommandAction = "stop" +) + +// Valid indicates whether the value is a known member of the DeviceCommandAction enum. +func (e DeviceCommandAction) Valid() bool { + switch e { + case Speak: + return true + case Stop: + return true + default: + return false + } +} + // Defines values for DiscoveryFeedbackRequestKind. const ( DiscoveryFeedbackRequestKindDown DiscoveryFeedbackRequestKind = "down" @@ -1146,10 +1164,33 @@ func (e RoomAutopilotAdventure) Valid() bool { } } +// Defines values for RoomDeviceKind. +const ( + RoomDeviceKindComputer RoomDeviceKind = "computer" + RoomDeviceKindPhone RoomDeviceKind = "phone" + RoomDeviceKindScreen RoomDeviceKind = "screen" +) + +// Valid indicates whether the value is a known member of the RoomDeviceKind enum. +func (e RoomDeviceKind) Valid() bool { + switch e { + case RoomDeviceKindComputer: + return true + case RoomDeviceKindPhone: + return true + case RoomDeviceKindScreen: + return true + default: + return false + } +} + // Defines values for RoomEventType. const ( RoomEventTypeChatDeleted RoomEventType = "chat.deleted" RoomEventTypeChatMessage RoomEventType = "chat.message" + RoomEventTypeDeviceCommand RoomEventType = "device.command" + RoomEventTypeDevicesUpdated RoomEventType = "devices.updated" RoomEventTypeDisplayCommand RoomEventType = "display.command" RoomEventTypeDisplaysUpdated RoomEventType = "displays.updated" RoomEventTypeGameQueue RoomEventType = "game.queue" @@ -1178,6 +1219,10 @@ func (e RoomEventType) Valid() bool { return true case RoomEventTypeChatMessage: return true + case RoomEventTypeDeviceCommand: + return true + case RoomEventTypeDevicesUpdated: + return true case RoomEventTypeDisplayCommand: return true case RoomEventTypeDisplaysUpdated: @@ -2438,6 +2483,24 @@ type DeleteMeRequest struct { Password *string `json:"password,omitempty"` } +// DeviceCommand 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. +type DeviceCommand struct { + Action DeviceCommandAction `json:"action"` + DeviceId string `json:"deviceId"` + RoomId string `json:"roomId"` +} + +// DeviceCommandAction defines model for DeviceCommand.Action. +type DeviceCommandAction string + +// DevicesChanged defines model for DevicesChanged. +type DevicesChanged struct { + RoomId string `json:"roomId"` +} + // DiscoveryArtist defines model for DiscoveryArtist. type DiscoveryArtist struct { // Fans Fans on Deezer, a measure of how well known. @@ -4372,6 +4435,40 @@ type RoomAutopilot struct { // half `explore` falls in (`discovery` from 50). type RoomAutopilotAdventure string +// RoomDevice Something connected to a room: a paired screen, or someone's +// browser. A browser is listed while it has the room open. +type RoomDevice struct { + // CanSpeak You may make it the speaker from here (makeDeviceSpeaker). + CanSpeak bool `json:"canSpeak"` + + // CanStop You may stop it being the speaker, when it is (stopDeviceSpeaker). + CanStop bool `json:"canStop"` + + // DisplayId Set for a paired screen. + DisplayId *string `json:"displayId,omitempty"` + + // Id The device ID it plays the room as (a paired screen's display ID). + Id string `json:"id"` + + // Kind `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. + Kind RoomDeviceKind `json:"kind"` + + // Name A screen's name, or what the browser says it is (Mac, iPhone). + Name string `json:"name"` + Online bool `json:"online"` + + // UserId Whose device it is. Absent for a paired screen. + UserId *string `json:"userId,omitempty"` + UserName *string `json:"userName,omitempty"` +} + +// RoomDeviceKind `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. +type RoomDeviceKind string + // RoomEvent A message on the room WebSocket, `GET /ws/rooms/{roomId}?since=`. // The socket needs the session cookie and is server-push only (but for // a box's status, below): send changes over the REST API. @@ -4409,6 +4506,15 @@ type RoomAutopilotAdventure string // 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 @@ -6239,6 +6345,15 @@ type ServerInterface interface { // GetRoomArtistPlays An artist's songs the room played most // (GET /rooms/{roomId}/artist-plays) GetRoomArtistPlays(w http.ResponseWriter, r *http.Request, roomId RoomId, params GetRoomArtistPlaysParams) + // ListRoomDevices Everything connected to the room + // (GET /rooms/{roomId}/devices) + ListRoomDevices(w http.ResponseWriter, r *http.Request, roomId RoomId) + // StopDeviceSpeaker Stop a device being the speaker + // (DELETE /rooms/{roomId}/devices/{deviceId}/speaker) + StopDeviceSpeaker(w http.ResponseWriter, r *http.Request, roomId RoomId, deviceId string) + // MakeDeviceSpeaker Make another device the speaker + // (PUT /rooms/{roomId}/devices/{deviceId}/speaker) + MakeDeviceSpeaker(w http.ResponseWriter, r *http.Request, roomId RoomId, deviceId string) // ListDisplays The room's paired displays // (GET /rooms/{roomId}/displays) ListDisplays(w http.ResponseWriter, r *http.Request, roomId RoomId) @@ -9765,6 +9880,102 @@ func (siw *ServerInterfaceWrapper) GetRoomArtistPlays(w http.ResponseWriter, r * handler.ServeHTTP(w, r) } +// ListRoomDevices operation middleware +func (siw *ServerInterfaceWrapper) ListRoomDevices(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "roomId" ------------- + var roomId RoomId + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListRoomDevices(w, r, roomId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StopDeviceSpeaker operation middleware +func (siw *ServerInterfaceWrapper) StopDeviceSpeaker(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "roomId" ------------- + var roomId RoomId + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + return + } + + // ------------- Path parameter "deviceId" ------------- + var deviceId string + + err = runtime.BindStyledParameterWithOptions("simple", "deviceId", r.PathValue("deviceId"), &deviceId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "deviceId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StopDeviceSpeaker(w, r, roomId, deviceId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// MakeDeviceSpeaker operation middleware +func (siw *ServerInterfaceWrapper) MakeDeviceSpeaker(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "roomId" ------------- + var roomId RoomId + + err = runtime.BindStyledParameterWithOptions("simple", "roomId", r.PathValue("roomId"), &roomId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "roomId", Err: err}) + return + } + + // ------------- Path parameter "deviceId" ------------- + var deviceId string + + err = runtime.BindStyledParameterWithOptions("simple", "deviceId", r.PathValue("deviceId"), &deviceId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "deviceId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.MakeDeviceSpeaker(w, r, roomId, deviceId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListDisplays operation middleware func (siw *ServerInterfaceWrapper) ListDisplays(w http.ResponseWriter, r *http.Request) { @@ -12516,6 +12727,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}", wrapper.UpdateDisplay) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}/reload", wrapper.ReloadDisplay) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/rooms/{roomId}/displays/{displayId}/restart", wrapper.RestartDisplay) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/devices", wrapper.ListRoomDevices) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/rooms/{roomId}/devices/{deviceId}/speaker", wrapper.StopDeviceSpeaker) + m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/rooms/{roomId}/devices/{deviceId}/speaker", wrapper.MakeDeviceSpeaker) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/rooms/{roomId}/displays/pairing/{code}", wrapper.LookupDisplayPairing) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/display/pairing", wrapper.PollDisplayPairing) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/display/pairing", wrapper.BeginDisplayPairing) @@ -17664,6 +17878,119 @@ func (response GetRoomArtistPlaysdefaultJSONResponse) VisitGetRoomArtistPlaysRes return err } +type ListRoomDevicesRequestObject struct { + RoomId RoomId `json:"roomId"` +} + +type ListRoomDevicesResponseObject interface { + VisitListRoomDevicesResponse(w http.ResponseWriter) error +} + +type ListRoomDevices200JSONResponse []RoomDevice + +func (response ListRoomDevices200JSONResponse) VisitListRoomDevicesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ListRoomDevicesdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ListRoomDevicesdefaultJSONResponse) VisitListRoomDevicesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type StopDeviceSpeakerRequestObject struct { + RoomId RoomId `json:"roomId"` + DeviceId string `json:"deviceId"` +} + +type StopDeviceSpeakerResponseObject interface { + VisitStopDeviceSpeakerResponse(w http.ResponseWriter) error +} + +type StopDeviceSpeaker200JSONResponse NowPlaying + +func (response StopDeviceSpeaker200JSONResponse) VisitStopDeviceSpeakerResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type StopDeviceSpeakerdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response StopDeviceSpeakerdefaultJSONResponse) VisitStopDeviceSpeakerResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + +type MakeDeviceSpeakerRequestObject struct { + RoomId RoomId `json:"roomId"` + DeviceId string `json:"deviceId"` +} + +type MakeDeviceSpeakerResponseObject interface { + VisitMakeDeviceSpeakerResponse(w http.ResponseWriter) error +} + +type MakeDeviceSpeaker202Response struct { +} + +func (response MakeDeviceSpeaker202Response) VisitMakeDeviceSpeakerResponse(w http.ResponseWriter) error { + w.WriteHeader(202) + return nil +} + +type MakeDeviceSpeakerdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response MakeDeviceSpeakerdefaultJSONResponse) VisitMakeDeviceSpeakerResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + type ListDisplaysRequestObject struct { RoomId RoomId `json:"roomId"` } @@ -21012,6 +21339,15 @@ type StrictServerInterface interface { // GetRoomArtistPlays An artist's songs the room played most // (GET /rooms/{roomId}/artist-plays) GetRoomArtistPlays(ctx context.Context, request GetRoomArtistPlaysRequestObject) (GetRoomArtistPlaysResponseObject, error) + // ListRoomDevices Everything connected to the room + // (GET /rooms/{roomId}/devices) + ListRoomDevices(ctx context.Context, request ListRoomDevicesRequestObject) (ListRoomDevicesResponseObject, error) + // StopDeviceSpeaker Stop a device being the speaker + // (DELETE /rooms/{roomId}/devices/{deviceId}/speaker) + StopDeviceSpeaker(ctx context.Context, request StopDeviceSpeakerRequestObject) (StopDeviceSpeakerResponseObject, error) + // MakeDeviceSpeaker Make another device the speaker + // (PUT /rooms/{roomId}/devices/{deviceId}/speaker) + MakeDeviceSpeaker(ctx context.Context, request MakeDeviceSpeakerRequestObject) (MakeDeviceSpeakerResponseObject, error) // ListDisplays The room's paired displays // (GET /rooms/{roomId}/displays) ListDisplays(ctx context.Context, request ListDisplaysRequestObject) (ListDisplaysResponseObject, error) @@ -24840,6 +25176,86 @@ func (sh *strictHandler) GetRoomArtistPlays(w http.ResponseWriter, r *http.Reque } } +// ListRoomDevices operation middleware +func (sh *strictHandler) ListRoomDevices(w http.ResponseWriter, r *http.Request, roomId RoomId) { + var request ListRoomDevicesRequestObject + + request.RoomId = roomId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListRoomDevices(ctx, request.(ListRoomDevicesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListRoomDevices") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListRoomDevicesResponseObject); ok { + if err := validResponse.VisitListRoomDevicesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// StopDeviceSpeaker operation middleware +func (sh *strictHandler) StopDeviceSpeaker(w http.ResponseWriter, r *http.Request, roomId RoomId, deviceId string) { + var request StopDeviceSpeakerRequestObject + + request.RoomId = roomId + request.DeviceId = deviceId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.StopDeviceSpeaker(ctx, request.(StopDeviceSpeakerRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StopDeviceSpeaker") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StopDeviceSpeakerResponseObject); ok { + if err := validResponse.VisitStopDeviceSpeakerResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// MakeDeviceSpeaker operation middleware +func (sh *strictHandler) MakeDeviceSpeaker(w http.ResponseWriter, r *http.Request, roomId RoomId, deviceId string) { + var request MakeDeviceSpeakerRequestObject + + request.RoomId = roomId + request.DeviceId = deviceId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.MakeDeviceSpeaker(ctx, request.(MakeDeviceSpeakerRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "MakeDeviceSpeaker") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(MakeDeviceSpeakerResponseObject); ok { + if err := validResponse.VisitMakeDeviceSpeakerResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListDisplays operation middleware func (sh *strictHandler) ListDisplays(w http.ResponseWriter, r *http.Request, roomId RoomId) { var request ListDisplaysRequestObject diff --git a/server/internal/httpapi/devices.go b/server/internal/httpapi/devices.go new file mode 100644 index 0000000..9ec72c1 --- /dev/null +++ b/server/internal/httpapi/devices.go @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi + +import ( + "cmp" + "context" + "errors" + "slices" + "strings" + "sync" + + "github.com/madeofpendletonwool/syncphony/server/internal/auth" + "github.com/madeofpendletonwool/syncphony/server/internal/playback" + "github.com/madeofpendletonwool/syncphony/server/internal/realtime" + "github.com/madeofpendletonwool/syncphony/server/internal/rooms" + "github.com/madeofpendletonwool/syncphony/server/internal/store" +) + +// The room's devices: its paired screens, and every signed-in browser +// with the room open. From any of them, someone may hand the speaker to +// a screen or to another of their own computers, or stop the speaker, +// without walking over to it. The device acts on it itself, over its +// room socket: it claims the room as it would from its own button, so +// it's the one that starts the audio. Phones aren't sent the speaker: +// their browsers only play after a tap on them. Everything here lives in +// memory; a browser is listed while its socket is open. + +// Device errors. +var ( + ErrDeviceOffline = errors.New("that device isn't connected right now") + ErrDeviceIsPhone = errors.New("a phone can only become the speaker from the phone itself: its browser needs a tap there") + ErrNotYourDevice = errors.New("you can only make your own devices the speaker") +) + +// deviceNameMax caps the name a browser gives itself. +const deviceNameMax = 40 + +// deviceRegistry is the browsers that have each room open right now. +type deviceRegistry struct { + mu sync.Mutex + m map[string]map[deviceKey]*deviceLive +} + +// deviceKey is one browser of one user: a browser's tabs share its +// device ID, and two people could, in theory, share a browser. +type deviceKey struct{ user, device string } + +type deviceLive struct { + conns int + kind RoomDeviceKind + name string + userName string +} + +// connect records a connection and reports whether it's the browser's +// first in the room (it just came). The latest connection says what the +// browser is, so opening the big screen on a computer makes it a screen. +func (d *deviceRegistry) connect(roomID string, u store.User, device string, kind RoomDeviceKind, name string) bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.m == nil { + d.m = map[string]map[deviceKey]*deviceLive{} + } + room := d.m[roomID] + if room == nil { + room = map[deviceKey]*deviceLive{} + d.m[roomID] = room + } + k := deviceKey{u.ID, device} + l := room[k] + if l == nil { + l = &deviceLive{} + room[k] = l + } + l.conns++ + l.kind, l.name, l.userName = kind, name, u.DisplayName + return l.conns == 1 +} + +// disconnect records a closed connection and reports whether it was the +// browser's last in the room (it just went). +func (d *deviceRegistry) disconnect(roomID, userID, device string) bool { + d.mu.Lock() + defer d.mu.Unlock() + room := d.m[roomID] + k := deviceKey{userID, device} + l := room[k] + if l == nil { + return false + } + l.conns-- + if l.conns > 0 { + return false + } + delete(room, k) + if len(room) == 0 { + delete(d.m, roomID) + } + return true +} + +// roomDevice is one browser with a room open. +type roomDevice struct { + userID, device string + deviceLive +} + +// list returns a room's browsers, by whose they are, then by name. +func (d *deviceRegistry) list(roomID string) []roomDevice { + d.mu.Lock() + defer d.mu.Unlock() + out := make([]roomDevice, 0, len(d.m[roomID])) + for k, l := range d.m[roomID] { + out = append(out, roomDevice{userID: k.user, device: k.device, deviceLive: *l}) + } + slices.SortFunc(out, func(a, b roomDevice) int { + return cmp.Or(cmp.Compare(a.userName, b.userName), cmp.Compare(a.name, b.name), cmp.Compare(a.device, b.device)) + }) + return out +} + +// find returns userID's browser with a room open, and whether anyone +// else has a browser of that ID there. +func (d *deviceRegistry) find(roomID, userID, device string) (l deviceLive, ok, others bool) { + d.mu.Lock() + defer d.mu.Unlock() + for k, v := range d.m[roomID] { + if k.device != device { + continue + } + if k.user == userID { + l, ok = *v, true + } else { + others = true + } + } + return l, ok, others +} + +// socketDevice is what a signed-in browser's socket says it is: a big +// screen (display=1) is a screen; otherwise its own word, phone if it +// says nothing, so a browser that might need a tap is never sent the +// speaker. +func socketDevice(display bool, kind, name string) (RoomDeviceKind, string) { + name = strings.TrimSpace(name) + if len(name) > deviceNameMax { + name = name[:deviceNameMax] + } + if display { + return RoomDeviceKindScreen, cmp.Or(name, "Big screen") + } + k := RoomDeviceKind(kind) + if k != RoomDeviceKindComputer { + k = RoomDeviceKindPhone + } + return k, cmp.Or(name, "Browser") +} + +// devicesChanged tells the room a browser came or went. +func (s *Server) devicesChanged(roomID string) { + s.Bus.Publish(realtime.RoomTopic(roomID), realtime.Event{Type: realtime.DevicesUpdated, Data: DevicesChanged{RoomId: roomID}}) +} + +// sendDeviceCommand sends a command for one device on topic: its +// display's, or its user's (where only the browser with that device ID +// acts on it). +func (s *Server) sendDeviceCommand(topic, roomID, deviceID string, action DeviceCommandAction) { + s.Bus.Publish(topic, realtime.Event{Type: realtime.DeviceCommand, Data: DeviceCommand{Action: action, DeviceId: deviceID, RoomId: roomID}}) +} + +// maySpeak reports whether userID may be the room's speaker. +func maySpeak(room store.Room, userID string) bool { + return rooms.Allowed(rooms.ParseSettings(room.Settings).Permissions.Speaker, room.OwnerID, userID) +} + +// canManageDisplay reports whether u may change one of room's displays: +// the room's owner, whoever paired it, or an admin. +func canManageDisplay(u store.User, room store.Room, d store.Display) bool { + return u.Role == store.RoleAdmin || room.OwnerID == u.ID || d.PairedBy.String == u.ID +} + +// displaySpeakable reports whether sess may make display d the speaker, +// leaving aside whether it's connected: it plays as whoever paired it, +// so they need the say-so too. +func displaySpeakable(sess *auth.Session, room store.Room, d store.Display) bool { + return sess.Guest == nil && canManageDisplay(sess.User, room, d) && d.Audio && d.PairedBy.Valid && + maySpeak(room, sess.User.ID) && maySpeak(room, d.PairedBy.String) +} + +// roomDisplay returns the room's paired display with this ID, if it's one. +func (s *Server) roomDisplay(ctx context.Context, roomID, id string) (store.Display, bool, error) { + d, err := s.Auth.Display(ctx, id) + if errors.Is(err, auth.ErrNotFound) { + return store.Display{}, false, nil + } else if err != nil { + return store.Display{}, false, err + } + return d, d.RoomID == roomID, nil +} + +// ListRoomDevices lists the room's screens and the browsers that have it open. +func (s *Server) ListRoomDevices(ctx context.Context, req ListRoomDevicesRequestObject) (ListRoomDevicesResponseObject, error) { + sess := sessionFrom(ctx) + u := sess.User + room, err := s.Rooms.Get(ctx, req.RoomId) + if err != nil { + return nil, err + } + ds, err := s.Auth.Displays(ctx, room.ID) + if err != nil { + return nil, err + } + devices := s.devices.list(room.ID) + out := make(ListRoomDevices200JSONResponse, 0, len(ds)+len(devices)) + for _, d := range ds { + online, _, _ := s.boxes.live(d.ID) + out = append(out, RoomDevice{ + Id: d.ID, Kind: RoomDeviceKindScreen, Name: d.Name, Online: online, DisplayId: ptr(d.ID), + CanSpeak: online && displaySpeakable(sess, room, d), + CanStop: canManageDisplay(u, room, d), + }) + } + mine := sess.Guest == nil && maySpeak(room, u.ID) + for _, d := range devices { + own := d.userID == u.ID + out = append(out, RoomDevice{ + Id: d.device, Kind: d.kind, Name: d.name, Online: true, UserId: ptr(d.userID), UserName: ptr(d.userName), + CanSpeak: own && mine && d.kind != RoomDeviceKindPhone, + CanStop: own || room.OwnerID == u.ID || u.Role == store.RoleAdmin, + }) + } + return out, nil +} + +// MakeDeviceSpeaker tells a screen, or one of the caller's computers, to +// become the room's speaker. +func (s *Server) MakeDeviceSpeaker(ctx context.Context, req MakeDeviceSpeakerRequestObject) (MakeDeviceSpeakerResponseObject, error) { + sess := sessionFrom(ctx) + u := sess.User + room, err := s.Rooms.Get(ctx, req.RoomId) + if err != nil { + return nil, err + } + d, isDisplay, err := s.roomDisplay(ctx, room.ID, req.DeviceId) + if err != nil { + return nil, err + } + if isDisplay { + switch { + case !canManageDisplay(u, room, d): + return nil, auth.ErrForbidden + case !d.Audio: + return nil, ErrDisplayNoAudio + case !d.PairedBy.Valid: + return nil, ErrDisplayOrphan + case !displaySpeakable(sess, room, d): + return nil, playback.ErrForbidden + } + if online, _, _ := s.boxes.live(d.ID); !online { + return nil, ErrDisplayOffline + } + s.sendDeviceCommand(realtime.DisplayTopic(d.ID), room.ID, d.ID, Speak) + return MakeDeviceSpeaker202Response{}, nil + } + if sess.Guest != nil || !maySpeak(room, u.ID) { + return nil, playback.ErrForbidden + } + dev, ok, others := s.devices.find(room.ID, u.ID, req.DeviceId) + switch { + case !ok && others: + return nil, ErrNotYourDevice + case !ok: + return nil, ErrDeviceOffline + case dev.kind == RoomDeviceKindPhone: + return nil, ErrDeviceIsPhone + } + s.sendDeviceCommand(realtime.UserTopic(u.ID), room.ID, req.DeviceId, Speak) + return MakeDeviceSpeaker202Response{}, nil +} + +// StopDeviceSpeaker tells the room's speaker to stop, and lets go of it. +// The device hears first, so it doesn't take the room straight back. +func (s *Server) StopDeviceSpeaker(ctx context.Context, req StopDeviceSpeakerRequestObject) (StopDeviceSpeakerResponseObject, error) { + u := sessionFrom(ctx).User + room, err := s.Rooms.Get(ctx, req.RoomId) + if err != nil { + return nil, err + } + np, err := s.Playback.NowPlaying(ctx, room.ID) + if err != nil { + return nil, err + } + p := np.Player + if p == nil || p.DeviceID != req.DeviceId { + return nil, playback.ErrNotPlayer + } + d, isDisplay, err := s.roomDisplay(ctx, room.ID, req.DeviceId) + if err != nil { + return nil, err + } + if isDisplay { + if !canManageDisplay(u, room, d) { + return nil, auth.ErrForbidden + } + s.sendDeviceCommand(realtime.DisplayTopic(d.ID), room.ID, d.ID, Stop) + } else { + if p.UserID != u.ID && room.OwnerID != u.ID && u.Role != store.RoleAdmin { + return nil, playback.ErrForbidden + } + s.sendDeviceCommand(realtime.UserTopic(p.UserID), room.ID, p.DeviceID, Stop) + } + if err := s.Playback.Drop(ctx, room.ID, req.DeviceId); err != nil { + return nil, err + } + if np, err = s.Playback.NowPlaying(ctx, room.ID); err != nil { + return nil, err + } + return StopDeviceSpeaker200JSONResponse(s.nowPlayingFor(np, false)), nil +} diff --git a/server/internal/httpapi/devices_test.go b/server/internal/httpapi/devices_test.go new file mode 100644 index 0000000..a31c052 --- /dev/null +++ b/server/internal/httpapi/devices_test.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package httpapi_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/madeofpendletonwool/syncphony/server/internal/httpapi" +) + +// roomDevices fetches a room's devices, by ID. +func roomDevices(t *testing.T, c *client, roomID string) map[string]httpapi.RoomDevice { + t.Helper() + var ds []httpapi.RoomDevice + c.want(http.StatusOK, "GET", "/rooms/"+roomID+"/devices", nil).decode(t, &ds) + out := map[string]httpapi.RoomDevice{} + for _, d := range ds { + out[d.Id] = d + } + return out +} + +func mustUnmarshal(t *testing.T, raw json.RawMessage, v any) { + t.Helper() + if err := json.Unmarshal(raw, v); err != nil { + t.Fatal(err) + } +} + +// awaitOnline waits until a paired display's socket is counted. +func awaitOnline(t *testing.T, c *client, roomID, displayID string, online bool) { + t.Helper() + for i := 0; displayByName(t, c, roomID, displayID).Online != online; i++ { + if i > 50 { + t.Fatalf("display %s never went online=%v", displayID, online) + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestRoomDevicesList(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + carol := e.member(alice, "carol") + room := e.room(t, me(t, bob).Id) + tv, d := pairTVAudio(t, e, bob, room.ID, true) + + b := bob.mustDial(room.ID, "device=bob-mac&kind=computer&name=Mac") + b.expect("hello", nil) + // Its own coming, first. + b.await("devices.updated", nil) + tvSock := tv.mustDial(room.ID, "") + tvSock.expect("hello", nil) + awaitOnline(t, bob, room.ID, d.Id, true) + c := carol.mustDial(room.ID, "device=carol-phone&kind=phone&name=iPhone") + c.expect("hello", nil) + // Carol coming tells the room. + var changed httpapi.DevicesChanged + b.await("devices.updated", &changed) + if changed.RoomId != room.ID { + t.Fatalf("devices.updated: %+v", changed) + } + + // Bob owns the room and paired the screen: he may hand the speaker to + // it and to his own computer, but not to carol's phone. He may stop it. + got := roomDevices(t, bob, room.ID) + if len(got) != 3 { + t.Fatalf("bob sees %d devices: %+v", len(got), got) + } + if s := got[d.Id]; s.Kind != httpapi.RoomDeviceKindScreen || !s.Online || !s.CanSpeak || !s.CanStop || s.DisplayId == nil || s.UserId != nil { + t.Fatalf("screen, as bob: %+v", s) + } + if m := got["bob-mac"]; m.Kind != httpapi.RoomDeviceKindComputer || m.Name != "Mac" || !m.CanSpeak || !m.CanStop || m.UserId == nil || *m.UserId != me(t, bob).Id { + t.Fatalf("bob's mac, as bob: %+v", m) + } + if p := got["carol-phone"]; p.Kind != httpapi.RoomDeviceKindPhone || p.CanSpeak || !p.CanStop { + t.Fatalf("carol's phone, as bob: %+v", p) + } + + // Carol may do nothing to the screen or bob's computer, and her phone + // only becomes the speaker from itself. + got = roomDevices(t, carol, room.ID) + if s := got[d.Id]; s.CanSpeak || s.CanStop { + t.Fatalf("screen, as carol: %+v", s) + } + if m := got["bob-mac"]; m.CanSpeak || m.CanStop { + t.Fatalf("bob's mac, as carol: %+v", m) + } + if p := got["carol-phone"]; p.CanSpeak || !p.CanStop { + t.Fatalf("carol's phone, as carol: %+v", p) + } + + // A browser that says nothing about itself counts as a phone; a big + // screen opened while signed in is a screen. + big := carol.mustDial(room.ID, "device=carol-tv&display=1") + big.expect("hello", nil) + b.await("devices.updated", nil) + old := alice.mustDial(room.ID, "device=alice-old") + old.expect("hello", nil) + b.await("devices.updated", nil) + got = roomDevices(t, carol, room.ID) + if s := got["carol-tv"]; s.Kind != httpapi.RoomDeviceKindScreen || !s.CanSpeak { + t.Fatalf("carol's big screen: %+v", s) + } + if o := got["alice-old"]; o.Kind != httpapi.RoomDeviceKindPhone { + t.Fatalf("a browser that didn't say: %+v", o) + } + + // Leaving: gone from the list. A paired screen stays, offline. + _ = c.c.CloseNow() + b.await("devices.updated", nil) + _ = tvSock.c.CloseNow() + awaitOnline(t, bob, room.ID, d.Id, false) + got = roomDevices(t, bob, room.ID) + if _, ok := got["carol-phone"]; ok { + t.Fatalf("carol's phone still listed: %+v", got) + } + if s := got[d.Id]; s.Online || s.CanSpeak { + t.Fatalf("offline screen: %+v", s) + } +} + +func TestMakeDeviceSpeaker(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + carol := e.member(alice, "carol") + room := e.room(t, me(t, bob).Id) + tv, d := pairTVAudio(t, e, bob, room.ID, true) + quiet, qd := pairTVAudio(t, e, bob, room.ID, false) + path := func(id string) string { return "/rooms/" + room.ID + "/devices/" + id + "/speaker" } + + tvSock := tv.mustDial(room.ID, "") + tvSock.expect("hello", nil) + quietSock := quiet.mustDial(room.ID, "") + quietSock.expect("hello", nil) + awaitOnline(t, bob, room.ID, d.Id, true) + awaitOnline(t, bob, room.ID, qd.Id, true) + b := bob.mustDial(room.ID, "device=bob-mac&kind=computer&name=Mac") + b.expect("hello", nil) + bp := bob.mustDial(room.ID, "device=bob-phone&kind=phone&name=iPhone") + bp.expect("hello", nil) + c := carol.mustDial(room.ID, "device=carol-mac&kind=computer&name=Mac") + c.expect("hello", nil) + + // The screen hears it on its own socket. + bob.want(http.StatusAccepted, "PUT", path(d.Id), nil) + var cmd httpapi.DeviceCommand + tvSock.await("device.command", &cmd) + if cmd.Action != httpapi.Speak || cmd.DeviceId != d.Id || cmd.RoomId != room.ID { + t.Fatalf("screen got %+v", cmd) + } + // Carol can't manage it; nor play through a screen with its audio off. + if r := carol.do("PUT", path(d.Id), nil); r.status != http.StatusForbidden { + t.Fatalf("carol making the screen the speaker: %d %s", r.status, r.body) + } + if r := bob.do("PUT", path(qd.Id), nil); r.status != http.StatusForbidden { + t.Fatalf("a screen without audio: %d %s", r.status, r.body) + } + + // Bob's computer hears it on his sockets, with its device ID. + bob.want(http.StatusAccepted, "PUT", path("bob-mac"), nil) + b.await("device.command", &cmd) + if cmd.Action != httpapi.Speak || cmd.DeviceId != "bob-mac" { + t.Fatalf("bob's mac got %+v", cmd) + } + // Not someone else's computer, not a phone, not a browser that's gone. + if r := bob.do("PUT", path("carol-mac"), nil); r.status != http.StatusForbidden { + t.Fatalf("bob making carol's mac the speaker: %d %s", r.status, r.body) + } + if r := bob.do("PUT", path("bob-phone"), nil); r.status != http.StatusConflict || r.code() != "device_is_phone" { + t.Fatalf("bob's phone: %d %s", r.status, r.body) + } + if r := bob.do("PUT", path("nowhere"), nil); r.status != http.StatusConflict || r.code() != "device_offline" { + t.Fatalf("an unknown device: %d %s", r.status, r.body) + } + _ = tvSock.c.CloseNow() + awaitOnline(t, bob, room.ID, d.Id, false) + if r := bob.do("PUT", path(d.Id), nil); r.status != http.StatusConflict || r.code() != "display_offline" { + t.Fatalf("an offline screen: %d %s", r.status, r.body) + } + + // Only the owner may be the speaker: carol can't hand it to her own computer. + owner := httpapi.PermissionLevelOwner + bob.want(http.StatusOK, "PATCH", "/rooms/"+room.ID, httpapi.UpdateRoomRequest{Permissions: &httpapi.RoomPermissionsChange{Speaker: &owner}}) + if r := carol.do("PUT", path("carol-mac"), nil); r.status != http.StatusForbidden { + t.Fatalf("carol, with the speaker owner-only: %d %s", r.status, r.body) + } +} + +func TestStopDeviceSpeaker(t *testing.T) { + e := newEnv(t) + alice := e.admin() + bob := e.member(alice, "bob") + carol := e.member(alice, "carol") + room := e.room(t, me(t, bob).Id) + base := "/rooms/" + room.ID + tv, d := pairTVAudio(t, e, bob, room.ID, true) + tvSock := tv.mustDial(room.ID, "") + tvSock.expect("hello", nil) + path := func(id string) string { return base + "/devices/" + id + "/speaker" } + + // Nothing to stop yet. + if r := bob.do("DELETE", path(d.Id), nil); r.status != http.StatusConflict || r.code() != "not_player" { + t.Fatalf("stopping a screen that isn't the speaker: %d %s", r.status, r.body) + } + + tv.want(http.StatusOK, "PUT", base+"/player", httpapi.ClaimPlayerRequest{DeviceId: d.Id, Name: "TV"}) + if r := carol.do("DELETE", path(d.Id), nil); r.status != http.StatusForbidden { + t.Fatalf("carol stopping the screen: %d %s", r.status, r.body) + } + var np httpapi.NowPlaying + bob.want(http.StatusOK, "DELETE", path(d.Id), nil).decode(t, &np) + if np.Player != nil { + t.Fatalf("still a speaker: %+v", np.Player) + } + // The screen hears it's stopped before it hears it lost the room, so + // it doesn't claim the room straight back. + speaking := false + for { + ev := tvSock.next() + if ev.Type == "nowplaying.updated" { + var got httpapi.NowPlaying + mustUnmarshal(t, ev.Data, &got) + if got.Player != nil { + speaking = true + } else if speaking { + t.Fatal("nowplaying without a speaker came before the stop") + } + continue + } + if ev.Type == "device.command" { + var cmd httpapi.DeviceCommand + mustUnmarshal(t, ev.Data, &cmd) + if cmd.Action != httpapi.Stop || cmd.DeviceId != d.Id { + t.Fatalf("screen got %+v", cmd) + } + break + } + } + + // Carol's own computer: she may stop it, so may the owner; alice is an admin. + c := carol.mustDial(room.ID, "device=carol-mac&kind=computer") + c.expect("hello", nil) + for _, who := range []*client{carol, bob, alice} { + carol.want(http.StatusOK, "PUT", base+"/player", httpapi.ClaimPlayerRequest{DeviceId: "carol-mac", Name: "Carol's Mac"}) + who.want(http.StatusOK, "DELETE", path("carol-mac"), nil) + } + var cmd httpapi.DeviceCommand + c.await("device.command", &cmd) + if cmd.Action != httpapi.Stop || cmd.DeviceId != "carol-mac" { + t.Fatalf("carol's mac got %+v", cmd) + } + // But not someone else in the room. + dave := e.member(alice, "dave") + carol.want(http.StatusOK, "PUT", base+"/player", httpapi.ClaimPlayerRequest{DeviceId: "carol-mac", Name: "Carol's Mac"}) + if r := dave.do("DELETE", path("carol-mac"), nil); r.status != http.StatusForbidden { + t.Fatalf("dave stopping carol's mac: %d %s", r.status, r.body) + } +} diff --git a/server/internal/httpapi/displays.go b/server/internal/httpapi/displays.go index a3d5fc9..ad37ef6 100644 --- a/server/internal/httpapi/displays.go +++ b/server/internal/httpapi/displays.go @@ -338,7 +338,7 @@ func (s *Server) manageableDisplay(ctx context.Context, roomID, displayID string if d.RoomID != room.ID { return store.Display{}, auth.ErrNotFound } - if u.Role != store.RoleAdmin && room.OwnerID != u.ID && d.PairedBy.String != u.ID { + if !canManageDisplay(u, room, d) { return store.Display{}, auth.ErrForbidden } return d, nil diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go index 69f82be..e9f1573 100644 --- a/server/internal/httpapi/realtime.go +++ b/server/internal/httpapi/realtime.go @@ -49,7 +49,8 @@ func (s *Server) RoomSocket() http.Handler { if c, err := r.Cookie(SessionCookie); err == nil { token = c.Value } - rc := &roomConn{s: s, token: token, display: r.URL.Query().Get("display") == "1", device: r.URL.Query().Get("device")} + q := r.URL.Query() + rc := &roomConn{s: s, token: token, display: q.Get("display") == "1", device: q.Get("device"), kind: q.Get("kind"), name: q.Get("name")} if sess, err := s.Auth.Authenticate(ctx, token); err == nil { // A guest may watch their own room only. if sess.Guest != nil && sess.Guest.RoomID != r.PathValue("id") { @@ -132,6 +133,9 @@ type roomConn struct { // a game round hides: its lock screen shows the song anyway. device string player *rooms.Player + // kind and name are what a signed-in browser says it is, for the + // room's devices (socketDevice). + kind, name string } // speaker reports whether the connection is the room's speaker. @@ -175,6 +179,21 @@ func (rc *roomConn) serve(ctx context.Context) { }() } + // A signed-in browser shows up in the room's devices, so someone can + // hand it the speaker from elsewhere. A paired display is a screen + // there already. + if !rc.paired && rc.device != "" { + kind, name := socketDevice(rc.display, rc.kind, rc.name) + if s.devices.connect(roomID, rc.user, rc.device, kind, name) { + s.devicesChanged(roomID) + } + defer func() { + if s.devices.disconnect(roomID, userID, rc.device) { + s.devicesChanged(roomID) + } + }() + } + // A big screen watches the room without being in it: it doesn't // count toward who's here, or skip votes. if !rc.display { @@ -391,6 +410,15 @@ func (rc *roomConn) send(ctx context.Context, e realtime.Event) error { data = d case DisplayCommand: data = d + case DevicesChanged: + data = d + case DeviceCommand: + // On the user's topic, every one of their sockets hears it: only + // the device it's for, in this room, gets it. + if d.RoomId != rc.room.ID || d.DeviceId != rc.device && d.DeviceId != rc.displayID { + return nil + } + data = d default: slog.Error("realtime: no API form for event", "type", e.Type, "data", e.Data) return nil diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 9b6c68d..b6108a7 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -120,7 +120,9 @@ type Server struct { reactions userLimiter chats userLimiter // boxes is what the displays connected right now say about themselves. - boxes boxRegistry + boxes boxRegistry + // devices is the signed-in browsers with each room open. + devices deviceRegistry playlists playlistCache } @@ -441,6 +443,9 @@ var errorCodes = []struct { {ErrDisplayOrphan, http.StatusForbidden, "forbidden"}, {ErrDisplayOffline, http.StatusConflict, "display_offline"}, {ErrNotABox, http.StatusConflict, "not_a_box"}, + {ErrDeviceOffline, http.StatusConflict, "device_offline"}, + {ErrDeviceIsPhone, http.StatusConflict, "device_is_phone"}, + {ErrNotYourDevice, http.StatusForbidden, "forbidden"}, {nights.ErrNotFound, http.StatusNotFound, "not_found"}, {nights.ErrForbidden, http.StatusForbidden, "forbidden"}, {nights.ErrNotHost, http.StatusForbidden, "forbidden"}, diff --git a/server/internal/realtime/realtime.go b/server/internal/realtime/realtime.go index cc6c8e0..998e1e9 100644 --- a/server/internal/realtime/realtime.go +++ b/server/internal/realtime/realtime.go @@ -67,6 +67,12 @@ const ( // DisplayCommand carries a command for one display (reload, restart), // on that display's own topic. DisplayCommand = "display.command" + // DevicesUpdated says a browser opened a room or closed it. It + // carries the room's ID. + DevicesUpdated = "devices.updated" + // DeviceCommand carries a command for one device (speak, stop), on + // its display's topic or its user's. + DeviceCommand = "device.command" ) // Event is something that happened. Data holds domain values (store rows, diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index f81f789..01598e3 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -2037,6 +2037,68 @@ export interface paths { patch?: never; trace?: never; }; + "/rooms/{roomId}/devices": { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + }; + cookie?: never; + }; + /** + * 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. + */ + get: operations["listRoomDevices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/rooms/{roomId}/devices/{deviceId}/speaker": { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + deviceId: string; + }; + cookie?: never; + }; + get?: never; + /** + * 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. + */ + put: operations["makeDeviceSpeaker"]; + post?: never; + /** + * 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. + */ + delete: operations["stopDeviceSpeaker"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/rooms/{roomId}/displays/pairing/{code}": { parameters: { query?: never; @@ -5200,6 +5262,48 @@ export interface components { DisplaysChanged: { roomId: string; }; + /** + * @description Something connected to a room: a paired screen, or someone's + * browser. A browser is listed while it has the room open. + */ + RoomDevice: { + /** @description The device ID it plays the room as (a paired screen's display ID). */ + id: string; + /** + * @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. + * @enum {string} + */ + kind: "screen" | "computer" | "phone"; + /** @description A screen's name, or what the browser says it is (Mac, iPhone). */ + name: string; + online: boolean; + /** @description Whose device it is. Absent for a paired screen. */ + userId?: string; + userName?: string; + /** @description Set for a paired screen. */ + displayId?: string; + /** @description You may make it the speaker from here (makeDeviceSpeaker). */ + canSpeak: boolean; + /** @description You may stop it being the speaker, when it is (stopDeviceSpeaker). */ + canStop: boolean; + }; + DevicesChanged: { + roomId: string; + }; + /** + * @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. + */ + DeviceCommand: { + /** @enum {string} */ + action: "speak" | "stop"; + deviceId: string; + roomId: string; + }; PairDisplayRequest: { /** @description The code the display shows. Case, spaces and dashes don't matter. */ code: string; @@ -5539,6 +5643,15 @@ export interface 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 @@ -5560,7 +5673,7 @@ export interface components { */ RoomEvent: { /** @enum {string} */ - type: "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"; + type: "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"; /** * Format: int64 * @description Queue version, on `queue.updated` only. @@ -9104,6 +9217,75 @@ export interface operations { default: components["responses"]["Error"]; }; }; + listRoomDevices: { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Paired screens first, then everyone's devices */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDevice"][]; + }; + }; + default: components["responses"]["Error"]; + }; + }; + makeDeviceSpeaker: { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + deviceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sent to the device */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; + stopDeviceSpeaker: { + parameters: { + query?: never; + header?: never; + path: { + roomId: components["parameters"]["RoomId"]; + deviceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The playback state without a speaker */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NowPlaying"]; + }; + }; + default: components["responses"]["Error"]; + }; + }; lookupDisplayPairing: { parameters: { query?: never; diff --git a/web/src/components/room-live.tsx b/web/src/components/room-live.tsx index 13814d4..6f71136 100644 --- a/web/src/components/room-live.tsx +++ b/web/src/components/room-live.tsx @@ -3,11 +3,13 @@ import { useEffect } from 'react' import { useChatSync } from '@/hooks/use-chat' import { useRoomControls } from '@/hooks/use-room-controls' import { useMe } from '@/lib/auth' +import { onDeviceCommand } from '@/lib/devices' import { player } from '@/lib/now-playing' import { newer, playbackQuery, toNowPlaying } from '@/lib/playback' import { useCurrentRoom } from '@/lib/room' import { useRoomSocket } from '@/lib/room-socket' -import { speaker, speakerState } from '@/lib/speaker' +import { speaker, speakerName, speakerState } from '@/lib/speaker' +import { toast } from '@/lib/toast' import { usersQuery } from '@/lib/users' /** @@ -34,6 +36,23 @@ export function RoomLive() { useEffect(() => { if (speaker.active && rooms.isSuccess && speakerRoom() !== room?.id) void speaker.stop() }, [room, rooms.isSuccess]) + // Someone handed this computer the speaker from another device, or + // stopped it. Stopping goes quiet first, so it doesn't claim the room + // back when the server lets go of it. + useEffect( + () => + onDeviceCommand((cmd) => { + if (cmd.action === 'stop') { + if (speaker.active && speakerRoom() === cmd.roomId) void speaker.stop() + return + } + if (me.guest) return + void speaker.start(cmd.roomId, speakerName(me.displayName)).then((np) => { + if (np) toast({ message: 'This device is the speaker now' }) + }) + }), + [me.guest, me.displayName], + ) // Signing out stops the speaker. Going to the big screen hands it over // instead, and coming back keeps whatever it's playing. useEffect(() => () => speaker.stopSoon(), []) diff --git a/web/src/components/room/big-screen-dialog.tsx b/web/src/components/room/big-screen-dialog.tsx index 3c5b3e2..e016e05 100644 --- a/web/src/components/room/big-screen-dialog.tsx +++ b/web/src/components/room/big-screen-dialog.tsx @@ -23,7 +23,8 @@ import { unpairDisplay, } from '@/lib/displays' import { easeOutExpo } from '@/lib/motion' -import { can } from '@/lib/playback' +import { makeSpeaker, stopSpeaker } from '@/lib/devices' +import { can, newer, playbackQuery } from '@/lib/playback' import type { Room } from '@/lib/room' import { relativeTime } from '@/lib/time' import { toast } from '@/lib/toast' @@ -100,6 +101,17 @@ export function BigScreenDialog({ room, open, onOpenChange }: { room: Room; open toast({ message: action === 'restart' ? `${name} is restarting. It'll be back in about a minute.` : `${name} is reloading` }), onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), }) + // Send the music to a screen from here, or stop it (the Devices list does the same). + const player = useQuery({ ...playbackQuery(room.id), enabled: false }).data?.player + const speakTo = useMutation({ + mutationFn: ({ id, on }: { id: string; name: string; on: boolean }) => + on ? makeSpeaker(room.id, id) : stopSpeaker(room.id, id), + onSuccess: (np, { name, on }) => { + if (np) queryClient.setQueryData(playbackQuery(room.id).queryKey, (old) => newer(old, np)) + if (on) toast({ message: `Handing the speaker to ${name}` }) + }, + onError: (e) => toast({ message: errorMessage(e), tone: 'error' }), + }) const submit = (e: FormEvent) => { e.preventDefault() if (code.trim()) pair.mutate() @@ -262,6 +274,17 @@ export function BigScreenDialog({ room, open, onOpenChange }: { room: Room; open )} {mayManage && d.online && (
+ {d.audio && maySpeak && ( + + )} + ) + } + /> + )} + {list.map((d) => { + const isSpeaker = player?.deviceId === d.id + const mine = d.userId === me.id && d.id === deviceId() + let action = null + if (isSpeaker && d.canStop) { + action = ( + + ) + } else if (!isSpeaker && mine && maySpeakHere) { + action = ( + + ) + } else if (!isSpeaker && d.canSpeak) { + action = ( + + ) + } + return ( + + ) + })} + + {devices.isSuccess && list.length === 0 && !missing && ( +

Nothing's connected right now.

+ )} +

+ A phone becomes the speaker from the phone itself: its browser only plays after a tap there. +

+ + ) +} + +function Row({ + icon, + name, + detail, + speaker, + playing, + dim, + action, +}: { + icon: ReactNode + name: string + detail: string + speaker?: boolean + playing: boolean + dim?: boolean + action?: ReactNode +}) { + return ( +
  • + + {speaker ? : icon} + +
    +

    {name}

    +

    {detail}

    +
    + {action} +
  • + ) +} + +function kindIcon(kind: RoomDevice['kind']) { + if (kind === 'screen') return + if (kind === 'computer') return + return +} diff --git a/web/src/components/room/speaker-panel.tsx b/web/src/components/room/speaker-panel.tsx index 20b6625..73ecb0d 100644 --- a/web/src/components/room/speaker-panel.tsx +++ b/web/src/components/room/speaker-panel.tsx @@ -1,9 +1,10 @@ import { useQuery } from '@tanstack/react-query' -import { Headphones, MonitorSmartphone, Speaker, Volume2 } from 'lucide-react' +import { Headphones, MonitorSmartphone, MonitorSpeaker, Speaker, Volume2 } from 'lucide-react' import { useState } from 'react' import { Equalizer } from '@/components/equalizer' import { Switch } from '@/components/ui/switch' import { Button } from '@/components/ui/button' +import { DevicesDialog } from './devices-dialog' import { useMe } from '@/lib/auth' import { can, playbackQuery, sendCommand } from '@/lib/playback' import type { Room } from '@/lib/room' @@ -18,6 +19,23 @@ import { deviceName } from '@/lib/webauthn' * on it from anywhere. */ export function SpeakerPanel({ room, prominent }: { room: Room; prominent?: boolean }) { + const me = useMe() + const [devices, setDevices] = useState(false) + return ( +
    + + {!me.guest && ( + + )} + +
    + ) +} + +function SpeakerControls({ room, prominent }: { room: Room; prominent?: boolean }) { const me = useMe() const state = useStore(speakerState) const playback = useQuery({ ...playbackQuery(room.id), enabled: false }) diff --git a/web/src/components/tv/tv-audio.test.tsx b/web/src/components/tv/tv-audio.test.tsx index f8edd50..ca6d438 100644 --- a/web/src/components/tv/tv-audio.test.tsx +++ b/web/src/components/tv/tv-audio.test.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runDeviceCommand } from '@/lib/devices' import { playbackQuery, type Playback } from '@/lib/playback' import { TvAudio } from './tv-audio' @@ -10,6 +11,7 @@ const speaker = vi.hoisted(() => ({ active: false, keep: vi.fn(() => false), start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), apply: vi.fn(), stopSoon: vi.fn(), onState: undefined as ((np: unknown) => void) | undefined, @@ -42,6 +44,8 @@ function renderAudio(playback: Playback | undefined, props: Partial { localStorage.clear() speaker.start.mockClear() + speaker.stop.mockClear() + speaker.active = false }) afterEach(cleanup) @@ -74,4 +78,21 @@ describe('TvAudio', () => { renderAudio(np({ state: 'idle' })) expect(speaker.start).toHaveBeenCalledTimes(1) }) + + it('takes the speaker when someone hands it over from their phone', () => { + renderAudio(np({ state: 'playing', player: { deviceId: 'bobs-phone', userId: 'bob', name: "Bob's phone", lastSeen: '2026-10-09T00:00:00Z' } })) + expect(speaker.start).not.toHaveBeenCalled() + runDeviceCommand({ action: 'speak', deviceId: 'tv-1', roomId: 'r1' }) + expect(speaker.start).toHaveBeenCalledWith('r1', 'Living room TV', 'tv-1') + // And picks it up again after a reload. + expect(localStorage.getItem('syncphony-tv-audio')).toBe('1') + }) + + it('stops when stopped from another device, and stays stopped after a reload', () => { + speaker.active = true + renderAudio(np({ state: 'playing', player: { deviceId: 'tv-1', userId: 'bob', name: 'Living room TV', lastSeen: '2026-10-09T00:00:00Z' } })) + runDeviceCommand({ action: 'stop', deviceId: 'tv-1', roomId: 'r1' }) + expect(speaker.stop).toHaveBeenCalledTimes(1) + expect(localStorage.getItem('syncphony-tv-audio')).toBe('0') + }) }) diff --git a/web/src/components/tv/tv-audio.tsx b/web/src/components/tv/tv-audio.tsx index 3c0ac2d..727bc11 100644 --- a/web/src/components/tv/tv-audio.tsx +++ b/web/src/components/tv/tv-audio.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react' import { errorMessage } from '@/api/errors' import { Equalizer } from '@/components/equalizer' import { Button } from '@/components/ui/button' +import { onDeviceCommand } from '@/lib/devices' import { newer, playbackQuery, sendCommand, type PlaybackCommand } from '@/lib/playback' import { speaker, speakerState } from '@/lib/speaker' import { useStore } from '@/lib/store' @@ -123,6 +124,21 @@ export function TvAudio({ roomId, device, name, onStopped, canPlayPause = true, writeFlag(false) void speaker.stop() } + // Handed the speaker from someone's phone, or stopped from there: the + // same as pressing the buttons here. A box plays without the press; any + // other screen may still want OK pressed, and shows the button. + const remote = useRef({ start, stop }) + useEffect(() => { + remote.current = { start, stop } + }) + useEffect( + () => + onDeviceCommand((cmd) => { + if (cmd.action === 'speak') remote.current.start() + else if (speaker.active) remote.current.stop() + }), + [], + ) if (here && state.status === 'blocked') { return ( diff --git a/web/src/lib/devices.test.ts b/web/src/lib/devices.test.ts new file mode 100644 index 0000000..e980627 --- /dev/null +++ b/web/src/lib/devices.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { deviceDetail, deviceKind, onDeviceCommand, runDeviceCommand, type RoomDevice } from './devices' + +const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148' +const ANDROID = 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/130.0 Mobile Safari/537.36' +const MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15' +const WINDOWS = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36' + +describe('deviceKind', () => { + it('calls phones and tablets phones: they need a tap before they play', () => { + expect(deviceKind(IPHONE, 5)).toBe('phone') + expect(deviceKind(ANDROID, 5)).toBe('phone') + // An iPad says it's a Mac; its touch screen gives it away. + expect(deviceKind(MAC, 5)).toBe('phone') + }) + + it('calls desktop browsers computers', () => { + expect(deviceKind(MAC, 0)).toBe('computer') + expect(deviceKind(WINDOWS, 0)).toBe('computer') + }) +}) + +describe('deviceDetail', () => { + const d = (over: Partial = {}): RoomDevice => ({ + id: 'd1', kind: 'computer', name: 'Mac', online: true, canSpeak: false, canStop: false, userId: 'u1', userName: 'Collin Pendleton', ...over, + }) + + it('says whose it is, and whether it plays', () => { + expect(deviceDetail(d(), false, false)).toBe('Collin') + expect(deviceDetail(d(), false, true)).toBe('Collin · the speaker') + expect(deviceDetail(d(), true, false)).toBe('This device') + }) + + it('names a paired screen as one, offline or not', () => { + const screen = d({ kind: 'screen', displayId: 'd1', userId: undefined, userName: undefined, online: false }) + expect(deviceDetail(screen, false, false)).toBe('Paired screen · offline') + }) +}) + +describe('device commands', () => { + it('go to the newest handler, then the one before when it leaves', () => { + const app = vi.fn() + const tv = vi.fn() + const offApp = onDeviceCommand(app) + const offTv = onDeviceCommand(tv) + const cmd = { action: 'speak', deviceId: 'd1', roomId: 'r1' } as const + runDeviceCommand(cmd) + expect(tv).toHaveBeenCalledWith(cmd) + expect(app).not.toHaveBeenCalled() + offTv() + runDeviceCommand(cmd) + expect(app).toHaveBeenCalledTimes(1) + offApp() + }) +}) diff --git a/web/src/lib/devices.ts b/web/src/lib/devices.ts new file mode 100644 index 0000000..d4f2353 --- /dev/null +++ b/web/src/lib/devices.ts @@ -0,0 +1,70 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/api/client' +import { unwrap } from '@/api/errors' +import type { components } from '@/api/schema.gen' + +// The room's devices: its paired screens and every signed-in browser with +// the room open. From any of them, someone can hand the speaker to a +// screen or another of their own computers, or stop it, without walking +// over. The device itself claims the room when told to (device.command), +// so it's the one that starts the audio. Phones aren't sent the speaker: +// their browsers only play after a tap on them. + +export type RoomDevice = components['schemas']['RoomDevice'] +export type DeviceCommand = components['schemas']['DeviceCommand'] + +export const devicesQuery = (roomId: string) => + queryOptions({ + queryKey: ['devices', roomId], + queryFn: () => unwrap(api.GET('/rooms/{roomId}/devices', { params: { path: { roomId } } })), + }) + +/** Tells a device to become the room's speaker. */ +export function makeSpeaker(roomId: string, deviceId: string) { + return unwrap(api.PUT('/rooms/{roomId}/devices/{deviceId}/speaker', { params: { path: { roomId, deviceId } } })) +} + +/** Tells the room's speaker to stop, and lets go of it. */ +export function stopSpeaker(roomId: string, deviceId: string) { + return unwrap(api.DELETE('/rooms/{roomId}/devices/{deviceId}/speaker', { params: { path: { roomId, deviceId } } })) +} + +/** + * What this browser is, for the room's devices. Phones and tablets need a + * tap on them before they play, so they're never sent the speaker. An iPad + * says it's a Mac; its touch screen gives it away. + */ +export function deviceKind(ua = navigator.userAgent, touchPoints = navigator.maxTouchPoints): 'phone' | 'computer' { + if (/iPhone|iPad|iPod|Android|Mobile/.test(ua)) return 'phone' + if (/Macintosh/.test(ua) && touchPoints > 1) return 'phone' + return 'computer' +} + +type Handler = (cmd: DeviceCommand) => void +const handlers: Handler[] = [] + +/** + * Acts on device commands for this browser: whichever screen is up (the + * app, or the big screen) knows what to call this device as the speaker. + * The newest handler wins. Returns the way to stop. + */ +export function onDeviceCommand(fn: Handler) { + handlers.push(fn) + return () => { + const i = handlers.lastIndexOf(fn) + if (i >= 0) handlers.splice(i, 1) + } +} + +/** A device command arrived on the room socket. */ +export function runDeviceCommand(cmd: DeviceCommand) { + handlers.at(-1)?.(cmd) +} + +/** Under a device's name: "Collin · the speaker", "Paired screen · offline", "This device". */ +export function deviceDetail(d: RoomDevice, mine: boolean, isSpeaker: boolean) { + const parts = [mine ? 'This device' : d.displayId ? 'Paired screen' : (d.userName?.split(' ')[0] ?? 'Someone')] + if (isSpeaker) parts.push('the speaker') + if (!d.online) parts.push('offline') + return parts.join(' · ') +} diff --git a/web/src/lib/room-socket.ts b/web/src/lib/room-socket.ts index b5c8271..4386268 100644 --- a/web/src/lib/room-socket.ts +++ b/web/src/lib/room-socket.ts @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import type { components } from '@/api/schema.gen' import { invitesQuery, membersQuery } from './access' import { box, isBox, runDisplayCommand, type BoxInfo, type DisplayCommand } from './box' +import { deviceKind, devicesQuery, runDeviceCommand, type DeviceCommand } from './devices' import { chatQuery, dropChatMessage, putChatMessage, type ChatMessage } from './chat' import { meQuery } from './auth' import { syncServerClock } from './clock' @@ -19,6 +20,7 @@ import { linksQuery, usableLinksQuery } from './services' import { deviceId, speaker } from './speaker' import { createStore } from './store' import { toast } from './toast' +import { deviceName } from './webauthn' import { usersQuery } from './users' type User = components['schemas']['User'] @@ -77,6 +79,12 @@ export function useRoomSocket(roomId: string | undefined, { display = false, onS if (display) query.set('display', '1') // The speaker sees what a game round hides: its lock screen shows the song anyway. query.set('device', device ?? deviceId()) + // What this browser is, for the room's devices. A big screen is a screen. + if (!display) { + query.set('kind', deviceKind()) + const name = deviceName() + if (name !== 'Passkey') query.set('name', name) + } const qs = query.toString() const url = `${proto}://${location.host}/ws/rooms/${encodeURIComponent(roomId)}${qs ? `?${qs}` : ''}` const sock = new WebSocket(url) @@ -256,7 +264,18 @@ function handle(queryClient: QueryClient, roomId: string, ev: RoomEvent) { // A screen came or went, or a box said something new: an open // Screens list refetches. void queryClient.invalidateQueries({ queryKey: displaysQuery(roomId).queryKey }) + void queryClient.invalidateQueries({ queryKey: devicesQuery(roomId).queryKey }) + break + case 'devices.updated': + // A browser opened the room or closed it. + void queryClient.invalidateQueries({ queryKey: devicesQuery(roomId).queryKey }) break + case 'device.command': { + // The server only sends it to the device it's for: be the speaker, or stop. + const cmd = ev.data as DeviceCommand + if (cmd.roomId === roomId) runDeviceCommand(cmd) + break + } case 'display.command': // Only ever sent to this display: reload, or (a box) restart. void runDisplayCommand(ev.data as DisplayCommand) diff --git a/web/src/routes/_app/_authed/room.tsx b/web/src/routes/_app/_authed/room.tsx index 0803509..5b91c9f 100644 --- a/web/src/routes/_app/_authed/room.tsx +++ b/web/src/routes/_app/_authed/room.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { createFileRoute, Link } from '@tanstack/react-router' -import { AudioLines, Check, ChevronDown, Crown, Dices, History, LogOut, MonitorPlay, Play, Plus, QrCode, Settings2, Sparkles, Speaker, Swords, Users } from 'lucide-react' +import { AudioLines, Check, ChevronDown, Crown, Dices, History, LogOut, MonitorPlay, MonitorSpeaker, Play, Plus, QrCode, Settings2, Sparkles, Speaker, Swords, Users } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { DropdownMenu } from 'radix-ui' import { useEffect, useState, type ReactNode } from 'react' @@ -13,6 +13,7 @@ import { SaveToPlaylistButton } from '@/components/playlists/save-to-playlist' import { AutopilotBadge } from '@/components/room/autopilot-badge' import { QueueGameDialog } from '@/components/games/queue-game-dialog' import { BigScreenDialog } from '@/components/room/big-screen-dialog' +import { DevicesDialog } from '@/components/room/devices-dialog' import { GuestsDialog } from '@/components/room/guests-dialog' import { HeartButton } from '@/components/room/heart-button' import { MembersDialog } from '@/components/room/members-dialog' @@ -171,6 +172,7 @@ function RoomHeader({ room, rooms }: { room: RoomInfo; rooms: RoomInfo[] }) { const { members, status } = useStore(live) const [settings, setSettings] = useState(false) const [bigScreen, setBigScreen] = useState(false) + const [devices, setDevices] = useState(false) const [guests, setGuests] = useState(false) const [membersOpen, setMembersOpen] = useState(false) const [queueGames, setQueueGames] = useState(false) @@ -215,6 +217,12 @@ function RoomHeader({ room, rooms }: { room: RoomInfo; rooms: RoomInfo[] }) { Big screen )} + {!guest && ( + setDevices(true)} className={menuItem}> + + Devices + + )} {!guest && room.visibility !== 'open' && ( setMembersOpen(true)} className={menuItem}> @@ -292,6 +300,7 @@ function RoomHeader({ room, rooms }: { room: RoomInfo; rooms: RoomInfo[] }) { /> +