Jared believes per-user volume is already persisted; confirm before building anything.
Questions (file:line evidence)
Where does EC v0.25.0 keep the per-participant volume (setVolume on the remote audio track / MediaViewModel)? Is it a Setting (persisted localStorage) keyed by user or device id, or React state that dies with the call?
If persisted: keyed how (userId vs userId:deviceId), and does it survive a page reload / a fork republish?
Interaction with the Lotus deafen path (io.lotus.set_deafen uses setAudioEnabled$ / setVolume — does deafen clobber a stored per-user volume on undeafen)?
Is the volume slider reachable in the fork's mobile UI?
Deliverable
A write-up here. If it is already persisted and keyed sensibly → close as "already works". If not → a small follow-up issue with the storage design (local only, never synced).
Jared believes per-user volume is already persisted; confirm before building anything.
### Questions (file:line evidence)
1. Where does EC v0.25.0 keep the per-participant volume (`setVolume` on the remote audio track / `MediaViewModel`)? Is it a Setting (persisted `localStorage`) keyed by user or device id, or React state that dies with the call?
2. If persisted: keyed how (userId vs `userId:deviceId`), and does it survive a page reload / a fork republish?
3. Interaction with the Lotus deafen path (`io.lotus.set_deafen` uses `setAudioEnabled$` / setVolume — does deafen clobber a stored per-user volume on undeafen)?
4. Is the volume slider reachable in the fork's mobile UI?
### Deliverable
A write-up here. If it is already persisted and keyed sensibly → close as "already works". If not → a small follow-up issue with the storage design (local only, never synced).
jared
added this to the Features 2026-Q4 milestone 2026-09-17 13:27:08 -04:00
Per-participant volume is NOT persisted today. It's pure in-memory RxJS state, scoped to the lifetime of a single call's tile, not a Setting written to localStorage.
1. Where the volume state actually lives
src/state/VolumeControls.ts:44-101 (createVolumeControls) — the volume/mute state is an RxJS Behavior built with accumulate({ volume: 1, committedVolume: 1 }, …) over three Subjects (toggleMuted$, adjustVolume$, commitVolume$). Every instance starts at volume: 1 — there is no read from storage anywhere in this function.
src/state/media/RemoteUserMediaViewModel.ts:39-56 (createRemoteUserMedia) — wires createVolumeControls's sink$ straight to LiveKit's RemoteParticipant.setVolume(volume) (line 53). No lookup/keying by userId or userId:deviceId happens here either.
src/state/media/WrappedUserMediaViewModel.ts:94-114 (createWrappedUserMedia) just forwards the scope it's given into createRemoteUserMedia; it doesn't add persistence.
src/state/CallViewModel/CallViewModel.ts:751-807 (userMedia$) is where that scope is actually created, via generateItems(..., mediaId, ...) with mediaId = \${userId}:${membership$.value.deviceId}`(line 765).generateItemsallocates a **new**ObservableScopeper distinct key and tears it down when the item disappears from the list (participant leaves the call / call ends). SinceCallViewModelitself is instantiated fresh per call, and the volumeBehaviorlives inside that per-item scope, the volume resets to1` (unmuted, full volume) on every new tile — including a rejoin within the same call, and certainly across separate calls.
src/settings/settings.ts:16-54 — the Setting<T> class is the fork's actual localStorage-persistence mechanism (this.key = \matrix-setting-${key}`;line 21,localStorage.setItem(...)line 54). Grepping the file for anything volume-related turns up exactly one hit:soundEffectVolumeatsrc/settings/settings.ts:132-133, a single **global** number (reaction/sound-effect volume), unrelated to per-participant playback volume. There is no Setting` for per-user/per-device volume anywhere in the tree.
Conclusion for Q1/Q2: it's plain React/RxJS state that dies with the call (in fact dies with the tile). Nothing is keyed by userId or userId:deviceId for this purpose, nothing survives a page reload, and there's nothing that would survive (or need to survive) a fork republish — because there is no stored key at all today.
2. Interaction with the Lotus deafen path
src/lotus/lotusDeafen.ts is actually where this exact clobbering hazard is already documented and was already fixed for the deafen case:
src/lotus/lotusDeafen.ts:35-41 (doc comment): "This deliberately does NOT use RemoteParticipant.setVolume for deafen any more: EC's per-participant volume slider / per-tile mute writes the same volumeMap from createVolumeControls... whenever its sink$ re-emits ... so a setVolume(0) deafen was silently undone for anyone joining while deafened, and an undeafen setVolume(1) clobbered the user's own per-tile volume/mute state."
Instead, deafen/undeafen now drives a global mute via setAudioEnabled$ (imported from ../controls, used at src/lotus/lotusDeafen.ts:18,93,106), which feeds muteAllAudio$ and is applied at the LivekitRoomAudioRenderer level — it never touches RemoteParticipant.setVolume or the per-tile VolumeControls state at all for the mic/deafen path. So today: no clobbering of per-user volume from the main deafen action.
The one remaining narrow exception: screenshareAudioMuted (a separate host control) still calls p.setVolume(0/1, Track.Source.ScreenShareAudio) directly at src/lotus/lotusDeafen.ts:116,119, bypassing VolumeControls entirely. The module's own comment at lines 50-53 calls this out as a known, accepted limitation: EC's screenshare-volume slider (src/state/media/RemoteScreenShareViewModel.ts:52) writes to the same LiveKit track and "a user who moves that slider while screenshare audio is host-muted wins" — but this is scoped to screenshare audio only, not the mic volume this issue is about.
3. Mobile reachability of the volume slider
Yes, it's reachable from a tap, not just right-click/hover:
src/tile/GridTile.tsx:355-409 (RemoteUserMediaTile) renders a <Slider> bound to vm.playbackVolume$ / vm.adjustPlaybackVolume / vm.commitPlaybackVolume inside a menuStart fragment (lines 383-405), alongside a "mute for me" ToggleMenuItem (lines 385-390).
That menu content is passed into UserMediaTile (src/tile/GridTile.tsx:237-266), which wraps it in both a <ContextMenu> (right-click/long-press) and an explicit <Menu trigger={menuTrigger}> button rendered as the tile's primaryButton (src/tile/GridTile.tsx:161-215) — an aria-label="common.options" "..." button. ContextMenu is passed hasAccessibleAlternative (line 261) specifically so the same menu is reachable via that tappable button, not only via a gesture a touchscreen may not deliver the same way.
Minor caveat (not the core answer, but worth flagging): the button's default CSS opacity is 0 outside of @media (hover)/focus/open state (src/tile/MediaView.module.css:280-306), so it's not visibly rendered on a stock mobile layout — it wants a visible-without-hover affordance for touch, but the wiring (component + accessible alternative) is there today, it's a small polish gap rather than a missing feature.
Verdict
Not persisted → proposed storage design (local only, never synced).
Add a new Setting<Record<string, number>> (e.g. perUserVolume, next to soundEffectVolume at src/settings/settings.ts:132), stored under matrix-setting-perUserVolume in localStorage per the existing Setting mechanism — local per-browser-profile, never round-tripped through room state or the widget API, satisfying "never synced".
Key by bare Matrix userId, not userId:deviceId. A volume preference is about the person, not their call-membership device id (which is realistically a per-session/reconnect identifier, not a stable hardware id) — keying by device would silently reset on every reconnect, defeating the point of the feature.
Hook the persistence into commitPlaybackVolume (write-through) and seed the accumulate initial state in createVolumeControls (src/state/VolumeControls.ts:54) from the stored map for that userId instead of the hardcoded 1, threading userId down from RemoteUserMediaViewModel/WrappedUserMediaViewModel.
Because this hooks the same commit path the slider already uses (not a low-level setVolume interceptor), it does not touch the Lotus deafen path — deafen no longer calls VolumeControls/setVolume for mic audio (see above), so no extra clobbering risk is introduced. The screenshare-mute sub-path remains a pre-existing, separately-scoped limitation.
Should add basic hygiene: cap map size / prune entries for users not seen recently, since localStorage is unbounded growth otherwise.
Effort estimate: small, roughly 0.5-1 day — one new Setting, a few lines threading userId into VolumeControls's init + commit, plus tests mirroring the existing coverage in src/state/media/MediaViewModel.test.ts:40-150 (which already exercises volume/mute/commit behavior thoroughly and would need a persistence-seed case added).
## Findings
**Per-participant volume is NOT persisted today.** It's pure in-memory RxJS state, scoped to the lifetime of a single call's tile, not a `Setting` written to `localStorage`.
### 1. Where the volume state actually lives
- `src/state/VolumeControls.ts:44-101` (`createVolumeControls`) — the volume/mute state is an RxJS `Behavior` built with `accumulate({ volume: 1, committedVolume: 1 }, …)` over three `Subject`s (`toggleMuted$`, `adjustVolume$`, `commitVolume$`). Every instance starts at `volume: 1` — there is no read from storage anywhere in this function.
- `src/state/media/RemoteUserMediaViewModel.ts:39-56` (`createRemoteUserMedia`) — wires `createVolumeControls`'s `sink$` straight to LiveKit's `RemoteParticipant.setVolume(volume)` (line 53). No lookup/keying by `userId` or `userId:deviceId` happens here either.
- `src/state/media/WrappedUserMediaViewModel.ts:94-114` (`createWrappedUserMedia`) just forwards the `scope` it's given into `createRemoteUserMedia`; it doesn't add persistence.
- `src/state/CallViewModel/CallViewModel.ts:751-807` (`userMedia$`) is where that `scope` is actually created, via `generateItems(..., mediaId, ...)` with `mediaId = \`${userId}:${membership$.value.deviceId}\`` (line 765). `generateItems` allocates a **new** `ObservableScope` per distinct key and tears it down when the item disappears from the list (participant leaves the call / call ends). Since `CallViewModel` itself is instantiated fresh per call, and the volume `Behavior` lives inside that per-item scope, the volume resets to `1` (unmuted, full volume) on every new tile — including a rejoin within the *same* call, and certainly across separate calls.
- `src/settings/settings.ts:16-54` — the `Setting<T>` class *is* the fork's actual localStorage-persistence mechanism (`this.key = \`matrix-setting-${key}\`;` line 21, `localStorage.setItem(...)` line 54). Grepping the file for anything volume-related turns up exactly one hit: `soundEffectVolume` at `src/settings/settings.ts:132-133`, a single **global** number (reaction/sound-effect volume), unrelated to per-participant playback volume. There is no `Setting` for per-user/per-device volume anywhere in the tree.
**Conclusion for Q1/Q2:** it's plain React/RxJS state that dies with the call (in fact dies with the tile). Nothing is keyed by `userId` or `userId:deviceId` for this purpose, nothing survives a page reload, and there's nothing that would survive (or need to survive) a fork republish — because there is no stored key at all today.
### 2. Interaction with the Lotus deafen path
`src/lotus/lotusDeafen.ts` is actually where this exact clobbering hazard is already documented and was already fixed for the deafen case:
- `src/lotus/lotusDeafen.ts:35-41` (doc comment): *"This deliberately does NOT use `RemoteParticipant.setVolume` for deafen any more: EC's per-participant volume slider / per-tile mute writes the same `volumeMap` from `createVolumeControls`... whenever its `sink$` re-emits ... so a `setVolume(0)` deafen was silently undone for anyone joining while deafened, and an undeafen `setVolume(1)` clobbered the user's own per-tile volume/mute state."*
- Instead, deafen/undeafen now drives a **global** mute via `setAudioEnabled$` (imported from `../controls`, used at `src/lotus/lotusDeafen.ts:18,93,106`), which feeds `muteAllAudio$` and is applied at the `LivekitRoomAudioRenderer` level — it never touches `RemoteParticipant.setVolume` or the per-tile `VolumeControls` state at all for the mic/deafen path. So today: **no clobbering** of per-user volume from the main deafen action.
- The one remaining narrow exception: `screenshareAudioMuted` (a separate host control) still calls `p.setVolume(0/1, Track.Source.ScreenShareAudio)` directly at `src/lotus/lotusDeafen.ts:116,119`, bypassing `VolumeControls` entirely. The module's own comment at lines 50-53 calls this out as a known, accepted limitation: EC's screenshare-volume slider (`src/state/media/RemoteScreenShareViewModel.ts:52`) writes to the same LiveKit track and "a user who moves that slider while screenshare audio is host-muted wins" — but this is scoped to screenshare audio only, not the mic volume this issue is about.
### 3. Mobile reachability of the volume slider
Yes, it's reachable from a tap, not just right-click/hover:
- `src/tile/GridTile.tsx:355-409` (`RemoteUserMediaTile`) renders a `<Slider>` bound to `vm.playbackVolume$` / `vm.adjustPlaybackVolume` / `vm.commitPlaybackVolume` inside a `menuStart` fragment (lines 383-405), alongside a "mute for me" `ToggleMenuItem` (lines 385-390).
- That `menu` content is passed into `UserMediaTile` (`src/tile/GridTile.tsx:237-266`), which wraps it in both a `<ContextMenu>` (right-click/long-press) **and** an explicit `<Menu trigger={menuTrigger}>` button rendered as the tile's `primaryButton` (`src/tile/GridTile.tsx:161-215`) — an `aria-label="common.options"` "..." button. `ContextMenu` is passed `hasAccessibleAlternative` (line 261) specifically so the same menu is reachable via that tappable button, not only via a gesture a touchscreen may not deliver the same way.
- Minor caveat (not the core answer, but worth flagging): the button's default CSS opacity is 0 outside of `@media (hover)`/focus/open state (`src/tile/MediaView.module.css:280-306`), so it's not visibly rendered on a stock mobile layout — it wants a `visible-without-hover` affordance for touch, but the wiring (component + accessible alternative) is there today, it's a small polish gap rather than a missing feature.
## Verdict
**Not persisted → proposed storage design (local only, never synced).**
- Add a new `Setting<Record<string, number>>` (e.g. `perUserVolume`, next to `soundEffectVolume` at `src/settings/settings.ts:132`), stored under `matrix-setting-perUserVolume` in `localStorage` per the existing `Setting` mechanism — local per-browser-profile, never round-tripped through room state or the widget API, satisfying "never synced".
- **Key by bare Matrix `userId`, not `userId:deviceId`.** A volume preference is about the person, not their call-membership device id (which is realistically a per-session/reconnect identifier, not a stable hardware id) — keying by device would silently reset on every reconnect, defeating the point of the feature.
- Hook the persistence into `commitPlaybackVolume` (write-through) and seed the `accumulate` initial state in `createVolumeControls` (`src/state/VolumeControls.ts:54`) from the stored map for that `userId` instead of the hardcoded `1`, threading `userId` down from `RemoteUserMediaViewModel`/`WrappedUserMediaViewModel`.
- Because this hooks the same commit path the slider already uses (not a low-level `setVolume` interceptor), it does **not** touch the Lotus deafen path — deafen no longer calls `VolumeControls`/`setVolume` for mic audio (see above), so no extra clobbering risk is introduced. The screenshare-mute sub-path remains a pre-existing, separately-scoped limitation.
- Should add basic hygiene: cap map size / prune entries for users not seen recently, since `localStorage` is unbounded growth otherwise.
- **Effort estimate: small, roughly 0.5-1 day** — one new `Setting`, a few lines threading `userId` into `VolumeControls`'s init + commit, plus tests mirroring the existing coverage in `src/state/media/MediaViewModel.test.ts:40-150` (which already exercises volume/mute/commit behavior thoroughly and would need a persistence-seed case added).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Jared believes per-user volume is already persisted; confirm before building anything.
Questions (file:line evidence)
setVolumeon the remote audio track /MediaViewModel)? Is it a Setting (persistedlocalStorage) keyed by user or device id, or React state that dies with the call?userId:deviceId), and does it survive a page reload / a fork republish?io.lotus.set_deafenusessetAudioEnabled$/ setVolume — does deafen clobber a stored per-user volume on undeafen)?Deliverable
A write-up here. If it is already persisted and keyed sensibly → close as "already works". If not → a small follow-up issue with the storage design (local only, never synced).
Findings
Per-participant volume is NOT persisted today. It's pure in-memory RxJS state, scoped to the lifetime of a single call's tile, not a
Settingwritten tolocalStorage.1. Where the volume state actually lives
src/state/VolumeControls.ts:44-101(createVolumeControls) — the volume/mute state is an RxJSBehaviorbuilt withaccumulate({ volume: 1, committedVolume: 1 }, …)over threeSubjects (toggleMuted$,adjustVolume$,commitVolume$). Every instance starts atvolume: 1— there is no read from storage anywhere in this function.src/state/media/RemoteUserMediaViewModel.ts:39-56(createRemoteUserMedia) — wirescreateVolumeControls'ssink$straight to LiveKit'sRemoteParticipant.setVolume(volume)(line 53). No lookup/keying byuserIdoruserId:deviceIdhappens here either.src/state/media/WrappedUserMediaViewModel.ts:94-114(createWrappedUserMedia) just forwards thescopeit's given intocreateRemoteUserMedia; it doesn't add persistence.src/state/CallViewModel/CallViewModel.ts:751-807(userMedia$) is where thatscopeis actually created, viagenerateItems(..., mediaId, ...)withmediaId = \${userId}:${membership$.value.deviceId}`(line 765).generateItemsallocates a **new**ObservableScopeper distinct key and tears it down when the item disappears from the list (participant leaves the call / call ends). SinceCallViewModelitself is instantiated fresh per call, and the volumeBehaviorlives inside that per-item scope, the volume resets to1` (unmuted, full volume) on every new tile — including a rejoin within the same call, and certainly across separate calls.src/settings/settings.ts:16-54— theSetting<T>class is the fork's actual localStorage-persistence mechanism (this.key = \matrix-setting-${key}`;line 21,localStorage.setItem(...)line 54). Grepping the file for anything volume-related turns up exactly one hit:soundEffectVolumeatsrc/settings/settings.ts:132-133, a single **global** number (reaction/sound-effect volume), unrelated to per-participant playback volume. There is noSetting` for per-user/per-device volume anywhere in the tree.Conclusion for Q1/Q2: it's plain React/RxJS state that dies with the call (in fact dies with the tile). Nothing is keyed by
userIdoruserId:deviceIdfor this purpose, nothing survives a page reload, and there's nothing that would survive (or need to survive) a fork republish — because there is no stored key at all today.2. Interaction with the Lotus deafen path
src/lotus/lotusDeafen.tsis actually where this exact clobbering hazard is already documented and was already fixed for the deafen case:src/lotus/lotusDeafen.ts:35-41(doc comment): "This deliberately does NOT useRemoteParticipant.setVolumefor deafen any more: EC's per-participant volume slider / per-tile mute writes the samevolumeMapfromcreateVolumeControls... whenever itssink$re-emits ... so asetVolume(0)deafen was silently undone for anyone joining while deafened, and an undeafensetVolume(1)clobbered the user's own per-tile volume/mute state."setAudioEnabled$(imported from../controls, used atsrc/lotus/lotusDeafen.ts:18,93,106), which feedsmuteAllAudio$and is applied at theLivekitRoomAudioRendererlevel — it never touchesRemoteParticipant.setVolumeor the per-tileVolumeControlsstate at all for the mic/deafen path. So today: no clobbering of per-user volume from the main deafen action.screenshareAudioMuted(a separate host control) still callsp.setVolume(0/1, Track.Source.ScreenShareAudio)directly atsrc/lotus/lotusDeafen.ts:116,119, bypassingVolumeControlsentirely. The module's own comment at lines 50-53 calls this out as a known, accepted limitation: EC's screenshare-volume slider (src/state/media/RemoteScreenShareViewModel.ts:52) writes to the same LiveKit track and "a user who moves that slider while screenshare audio is host-muted wins" — but this is scoped to screenshare audio only, not the mic volume this issue is about.3. Mobile reachability of the volume slider
Yes, it's reachable from a tap, not just right-click/hover:
src/tile/GridTile.tsx:355-409(RemoteUserMediaTile) renders a<Slider>bound tovm.playbackVolume$/vm.adjustPlaybackVolume/vm.commitPlaybackVolumeinside amenuStartfragment (lines 383-405), alongside a "mute for me"ToggleMenuItem(lines 385-390).menucontent is passed intoUserMediaTile(src/tile/GridTile.tsx:237-266), which wraps it in both a<ContextMenu>(right-click/long-press) and an explicit<Menu trigger={menuTrigger}>button rendered as the tile'sprimaryButton(src/tile/GridTile.tsx:161-215) — anaria-label="common.options""..." button.ContextMenuis passedhasAccessibleAlternative(line 261) specifically so the same menu is reachable via that tappable button, not only via a gesture a touchscreen may not deliver the same way.@media (hover)/focus/open state (src/tile/MediaView.module.css:280-306), so it's not visibly rendered on a stock mobile layout — it wants avisible-without-hoveraffordance for touch, but the wiring (component + accessible alternative) is there today, it's a small polish gap rather than a missing feature.Verdict
Not persisted → proposed storage design (local only, never synced).
Setting<Record<string, number>>(e.g.perUserVolume, next tosoundEffectVolumeatsrc/settings/settings.ts:132), stored undermatrix-setting-perUserVolumeinlocalStorageper the existingSettingmechanism — local per-browser-profile, never round-tripped through room state or the widget API, satisfying "never synced".userId, notuserId:deviceId. A volume preference is about the person, not their call-membership device id (which is realistically a per-session/reconnect identifier, not a stable hardware id) — keying by device would silently reset on every reconnect, defeating the point of the feature.commitPlaybackVolume(write-through) and seed theaccumulateinitial state increateVolumeControls(src/state/VolumeControls.ts:54) from the stored map for thatuserIdinstead of the hardcoded1, threadinguserIddown fromRemoteUserMediaViewModel/WrappedUserMediaViewModel.setVolumeinterceptor), it does not touch the Lotus deafen path — deafen no longer callsVolumeControls/setVolumefor mic audio (see above), so no extra clobbering risk is introduced. The screenshare-mute sub-path remains a pre-existing, separately-scoped limitation.localStorageis unbounded growth otherwise.Setting, a few lines threadinguserIdintoVolumeControls's init + commit, plus tests mirroring the existing coverage insrc/state/media/MediaViewModel.test.ts:40-150(which already exercises volume/mute/commit behavior thoroughly and would need a persistence-seed case added).