Add seperation of voice channels, creation of new ones, and move around users

This commit is contained in:
2026-03-30 02:11:39 +02:00
parent 83694570e3
commit 727059fb52
19 changed files with 614 additions and 50 deletions

View File

@@ -92,7 +92,7 @@ sequenceDiagram
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server.
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.
If a room has no text channels, the room shell in `features/room/chat-room/` renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists.

View File

@@ -136,7 +136,7 @@ The API service normalises every `ServerInfo` response, filling in `sourceId`, `
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation also deduplicates channel names before persistence.
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation deduplicates channel names within each channel type, so a text `general` channel and a voice `General` channel can coexist while duplicate voice-to-voice or text-to-text names are still rejected.
## Default endpoint management

View File

@@ -91,4 +91,8 @@ export class VoiceConnectionFacade {
stopVoiceHeartbeat(): void {
this.realtime.stopVoiceHeartbeat();
}
syncOutgoingVoiceRouting(allowedPeerIds: string[]): void {
this.realtime.syncOutgoingVoiceRouting(allowedPeerIds);
}
}

View File

@@ -3,8 +3,14 @@ import {
effect,
inject
} from '@angular/core';
import { Store } from '@ngrx/store';
import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
import { ScreenShareFacade } from '../../../domains/screen-share';
import { User } from '../../../shared-kernel';
import {
selectAllUsers,
selectCurrentUser
} from '../../../store/users/users.selectors';
import { VoiceConnectionFacade } from './voice-connection.facade';
export interface PlaybackOptions {
@@ -34,8 +40,11 @@ interface PeerAudioPipeline {
@Injectable({ providedIn: 'root' })
export class VoicePlaybackService {
private readonly store = inject(Store);
private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private peerPipelines = new Map<string, PeerAudioPipeline>();
private pendingRemoteStreams = new Map<string, MediaStream>();
@@ -64,6 +73,11 @@ export class VoicePlaybackService {
void this.applyEffectiveOutputDeviceToAllPipelines();
});
effect(() => {
this.syncOutgoingVoiceRouting();
this.recalcAllGains();
});
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
@@ -305,7 +319,7 @@ export class VoicePlaybackService {
if (!pipeline)
return;
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId) || !this.isPeerInCurrentVoiceRoom(peerId)) {
pipeline.gainNode.gain.value = 0;
return;
}
@@ -320,6 +334,61 @@ export class VoicePlaybackService {
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
}
private isPeerInCurrentVoiceRoom(peerId: string): boolean {
const localVoiceState = this.currentUser()?.voiceState;
if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) {
return false;
}
const remoteVoiceState = this.findUserForPeer(peerId)?.voiceState;
return !!remoteVoiceState?.isConnected
&& remoteVoiceState.roomId === localVoiceState.roomId
&& remoteVoiceState.serverId === localVoiceState.serverId;
}
private findUserForPeer(peerId: string): User | undefined {
return this.allUsers().find((user) => user.id === peerId || user.oderId === peerId || user.peerId === peerId);
}
private syncOutgoingVoiceRouting(): void {
const localVoiceState = this.currentUser()?.voiceState;
if (!localVoiceState?.isConnected || !localVoiceState.roomId || !localVoiceState.serverId) {
this.voiceConnection.syncOutgoingVoiceRouting([]);
return;
}
const allowedPeerIds = new Set<string>();
for (const user of this.allUsers()) {
const voiceState = user.voiceState;
if (
!voiceState?.isConnected
|| voiceState.roomId !== localVoiceState.roomId
|| voiceState.serverId !== localVoiceState.serverId
) {
continue;
}
if (user.id) {
allowedPeerIds.add(user.id);
}
if (user.oderId) {
allowedPeerIds.add(user.oderId);
}
if (user.peerId) {
allowedPeerIds.add(user.peerId);
}
}
this.voiceConnection.syncOutgoingVoiceRouting(Array.from(allowedPeerIds));
}
private persistVolumes(): void {
try {
const data: Record<string, { volume: number; muted: boolean }> = {};

View File

@@ -73,6 +73,10 @@ stateDiagram-v2
When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back.
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.
## Workspace modes
`VoiceWorkspaceService` controls the voice workspace panel state. The workspace is only visible when the user is viewing the voice-connected server.