# Music in a voice room, and what each channel decides

> Current status reviewed September 29, 2026. See [the release index](README.md) for shipped versus planned features, network, wallet signing and deployment boundaries. Older dated experiments below are retained as history.

Two features that arrived together, because they answer the same question: who is allowed to do
what, in one room, among other people.

## The room's speaker

Published Orb uses its configured LiveKit SFU for call media. The room music feature itself
is a synchronized client player: every listener plays the same source locally at the same
position. A separate authorized bot worker can publish permitted audio through the scoped
LiveKit grant, but a YouTube URL is played through YouTube's own player, not extracted into
the call. The synchronized player works as follows:

- the server keeps one player per voice room (`orb_music`): the queue, which track, where it is,
  volume, loop, shuffle, and who last moved it;
- every client renders that against its own clock (`components/orb-music.tsx`), correcting itself
  when the next snapshot disagrees by more than two and a half seconds.

Consequences the interface states instead of hiding:

- **the tab has to stay open.** The browser in your ear is the one making the sound.
- **a browser nobody has tapped stays silent.** Chrome refuses audio until the page gets one user
  activation; the panel then shows "Play here" (pt-BR: "Tocar aqui") rather than pretending to play.
- **YouTube only allows playback through its own player.** Orb stores the 11-character video
  id and hands it to `youtube.com/iframe_api`. Nothing is downloaded, proxied, re-streamed or
  extracted by this app, and a link is refused before it enters the queue if it is not a recognisable
  video or a direct `https` audio file.
- everyone hears the same track, but at their own speaker. A listener with a bad connection is
  corrected by the poll, not by a server resend.

Anything the room can see is derived state: `music` rides along in the snapshot, so a member who
joins the call sees the queue and the current second immediately.

### Who may move it

Reading is for anybody who can see the room. Moving it needs three things, all of them the same
checks the rest of the app uses:

1. the channel lets you `connect` - including any per-channel exception below;
2. you are **actually in the room** (a live seat in `calls`): controlling a speaker nobody in it can
   hear is how arguments start;
3. you are not silenced.

An application drives the same player through `POST /api/bot/v1/music` with the `music` scope, which
a community accepts by giving the app `connect` - so a community that says no to voice says no to
music without having to say it twice. A bot has to join the call like anybody else.

### Commands a member types

| Typed | Does |
| --- | --- |
| `/add <link>` | queues a YouTube link (or `.mp3`, `.ogg`, `.opus`, `.m4a`, `.aac`, `.wav`, `.webm`); starts it if the room is empty |
| `/queue`, `/now` | what comes next and who asked; what is playing and how far along |
| `/pause`, `/resume`, `/skip`, `/back` | the transport |
| `/jump <n>`, `/remove <n>` | play or remove a position |
| `/seek <seconds\|m:ss>` | jump inside the track |
| `/volume <0-100>` | the room's volume, for everybody |
| `/loop <off\|one\|all>`, `/shuffle <on\|off>` | loop and shuffle |
| `/clear` | empty the queue |

Command words are English in every interface language, the way an API is: one spelling to learn, and
autocomplete that does not change when somebody switches languages. What the command *says back*
follows the member's locale. `/play <name | id | link>` is the door most members want - it searches,
resolves a Deezer id, or expands an album or playlist link - and `/search <name>` lists matches
without touching the queue. `/add` is the narrow verb: paste a link, it goes in the queue.

The composer lists everything typeable when
you write `/`, and the **Commands** button opens the same list. A missing or nonsense argument is
refused with the verb's own usage, so `/volume` cannot quietly mute the room by reading the empty
string as zero.

## Per-channel rights

A server role answers *what is this person in this community*. It cannot answer *this channel is
visible to everyone but only two roles may write in it*, which is the question a growing community
actually has. `room_permissions` keeps exceptions per channel, per **role** and per **person**:

| Right | Means in this channel |
| --- | --- |
| `view_channel` | shown or hidden, on top of public/private |
| `send_messages`, `attach_files`, `manage_messages` | the text door |
| `connect`, `speak`, `stream`, `move_members` | the voice door, and the music with it |

Precedence, in the order a moderator expects:

1. the owner and anyone with `administrator` are not governed by exceptions;
2. the **person's own row** decides if it speaks about that right;
3. otherwise the **role's row**;
4. otherwise the server role, exactly as before.

Administrative rights (ban, kick, roles, invites, audit) are deliberately absent: a per-channel
"ban members" would silently mean "ban members of this channel", which is not a thing a community can
act on.

The same predicate answers everywhere - `roomVisibleSql` for lists and single rooms, `channelRightSql`
for the roster flags and the heartbeat that pins a microphone off, and `channelAllows` in
`lib/channel-rights.ts` for the browser drawing the button. It is one function and its SQL twin on
purpose: two answers to "can this person speak here" is one too many, and a composer that guessed
differently is a message that fails on submit.

A community with no exception rows behaves exactly as it did before this existed.

## Verification

```
npm run music:test       # scripts/test-orb-music.mjs - the queue, the commands, the exceptions, an app command
node scripts/test-orb-music-ui.mjs    # scripts/test-orb-music-ui.mjs - the panel, the picker and the grid in a browser
```

Both run against the local preview (`node work/focus-preview.mjs`) and its file database; the UI one
needs `ORBITA_PLAYWRIGHT_PATH` and joins a real call with fake media devices. Neither claims sound
came out of a speaker: what they prove is that the room's state is shared, that a member can reach it
by typing, and that a moderator's exception is honoured by the server that has to enforce it.
