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

@@ -8,6 +8,10 @@ import {
JoinRequestPayload
} from './types';
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function parseStringArray(raw: string | null | undefined): string[] {
try {
const parsed = JSON.parse(raw || '[]');
@@ -38,7 +42,7 @@ function parseServerChannels(raw: string | null | undefined): ServerChannelPaylo
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = name.toLocaleLowerCase();
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
interface LegacyServerRow {
id: string;
channels: string | null;
}
interface LegacyServerChannel {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies LegacyServerChannel;
})
.filter((channel): channel is LegacyServerChannel => !!channel);
} catch {
return [];
}
}
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
const hasTextGeneral = channels.some(
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
);
const hasVoiceAfk = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
);
const hasVoiceGeneral = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
);
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
}
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
return channels;
}
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
const repairedVoiceChannels = [
{
id: 'vc-general',
name: 'General',
type: 'voice' as const,
position: 0
},
...voiceChannels
].map((channel, index) => ({
...channel,
position: index
}));
return [
...textChannels,
...repairedVoiceChannels
];
}
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
name = 'RepairLegacyVoiceChannels1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
const channels = normalizeLegacyChannels(row.channels);
const repaired = repairLegacyVoiceChannels(channels);
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
continue;
}
await queryRunner.query(
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
[JSON.stringify(repaired), row.id]
);
}
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Forward-only data repair migration.
}
}

View File

@@ -1,9 +1,11 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
export const serverMigrations = [
InitialSchema1000000000000,
ServerAccessControl1000000000001,
ServerChannels1000000000002
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003
];

View File

@@ -37,6 +37,10 @@ function normalizeRole(role: unknown): string | null {
return typeof role === 'string' ? role.trim().toLowerCase() : null;
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role);
}
@@ -59,7 +63,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = name.toLocaleLowerCase();
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
continue;

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.

View File

@@ -116,7 +116,15 @@
}
<div class="space-y-1">
@for (ch of voiceChannels(); track ch.id) {
<div>
<div
class="rounded-md transition-colors"
[class.bg-primary/10]="dragTargetVoiceChannelId() === ch.id"
[class.ring-1]="dragTargetVoiceChannelId() === ch.id"
[class.ring-primary/40]="dragTargetVoiceChannelId() === ch.id"
(dragover)="onVoiceChannelDragOver($event, ch.id)"
(dragleave)="onVoiceChannelDragLeave(ch.id)"
(drop)="onVoiceChannelDrop($event, ch.id)"
>
<button
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
(click)="joinVoice(ch.id)"
@@ -163,6 +171,11 @@
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40"
[class.cursor-pointer]="canDragVoiceUser(u)"
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
[draggable]="canDragVoiceUser(u)"
(dragstart)="onVoiceUserDragStart($event, u)"
(dragend)="onVoiceUserDragEnd()"
(contextmenu)="openVoiceUserVolumeMenu($event, u)"
>
<app-user-avatar

View File

@@ -172,6 +172,8 @@ export class RoomsSidePanelComponent {
volumeMenuY = signal(0);
volumeMenuPeerId = signal('');
volumeMenuDisplayName = signal('');
draggedVoiceUserId = signal<string | null>(null);
dragTargetVoiceChannelId = signal<string | null>(null);
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
@@ -371,9 +373,16 @@ export class RoomsSidePanelComponent {
}
const channels = this.currentRoom()?.channels ?? [];
const channelType = excludeChannelId
? channels.find((channel) => channel.id === excludeChannelId)?.type
: this.createChannelType();
if (isChannelNameTaken(channels, name, excludeChannelId)) {
return 'Channel names must be unique in a server.';
if (!channelType) {
return null;
}
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
return 'Channel names must be unique within text or voice channels.';
}
return null;
@@ -618,6 +627,113 @@ export class RoomsSidePanelComponent {
this.voiceWorkspace.focusStream(userId, { connectRemoteShares: true });
}
canMoveVoiceUsers(): boolean {
return this.canManageChannels();
}
canDragVoiceUser(user: User): boolean {
return this.canMoveVoiceUsers() && !this.isCurrentUserIdentity(user) && !!user.voiceState?.isConnected;
}
onVoiceUserDragStart(event: DragEvent, user: User): void {
if (!this.canDragVoiceUser(user)) {
event.preventDefault();
return;
}
const dragId = user.id || user.oderId;
if (!dragId) {
event.preventDefault();
return;
}
this.draggedVoiceUserId.set(dragId);
event.dataTransfer?.setData('text/plain', dragId);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
onVoiceUserDragEnd(): void {
this.draggedVoiceUserId.set(null);
this.dragTargetVoiceChannelId.set(null);
}
onVoiceChannelDragOver(event: DragEvent, channelId: string): void {
if (!this.draggedVoiceUserId()) {
return;
}
event.preventDefault();
this.dragTargetVoiceChannelId.set(channelId);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
onVoiceChannelDragLeave(channelId: string): void {
if (this.dragTargetVoiceChannelId() === channelId) {
this.dragTargetVoiceChannelId.set(null);
}
}
onVoiceChannelDrop(event: DragEvent, channelId: string): void {
event.preventDefault();
const draggedUserId = this.draggedVoiceUserId() || event.dataTransfer?.getData('text/plain') || null;
this.draggedVoiceUserId.set(null);
this.dragTargetVoiceChannelId.set(null);
if (!draggedUserId) {
return;
}
this.moveVoiceUserToChannel(draggedUserId, channelId);
}
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
const room = this.currentRoom();
const actor = this.currentUser();
if (!room || !actor || !this.canMoveVoiceUsers()) {
return;
}
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
return;
}
const movedVoiceState: Partial<User['voiceState']> = {
isConnected: true,
isMuted: targetUser.voiceState.isMuted,
isDeafened: targetUser.voiceState.isDeafened,
isSpeaking: targetUser.voiceState.isSpeaking,
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
volume: targetUser.voiceState.volume,
roomId: channelId,
serverId: room.id
};
this.store.dispatch(UsersActions.updateVoiceState({
userId: targetUser.id,
voiceState: movedVoiceState
}));
this.realtime.broadcastMessage({
type: 'voice-channel-move',
roomId: room.id,
targetUserId: targetUser.oderId || targetUser.id,
voiceState: movedVoiceState,
displayName: targetUser.displayName
});
}
isUserLocallyMuted(user: User): boolean {
const peerId = user.oderId || user.id;

View File

@@ -46,7 +46,7 @@ Both backends store the same entity types:
The IndexedDB schema is at version 2.
The persisted `rooms` store is a local cache of room metadata. Channel topology is still server-owned metadata: after room create, join, view, or channel-management changes, the renderer should hydrate the authoritative channel list from server-directory responses so every member converges on the same room structure.
The persisted `rooms` store is a local cache of room metadata. Channel topology is still server-owned metadata: after room create, join, view, or channel-management changes, the renderer should hydrate the authoritative mixed text-and-voice channel list from server-directory responses so every member converges on the same room structure.
## How the two backends differ

View File

@@ -200,7 +200,7 @@ When a peer connection enters `disconnected`, a 10-second grace period starts. I
## Data channel
A single ordered data channel carries all peer-to-peer messages: chat events, voice/screen state broadcasts, state requests, pings, and screen share control.
A single ordered data channel carries all peer-to-peer messages: chat events, voice/screen state broadcasts, voice-channel move control events, state requests, pings, and screen share control.
Back-pressure is handled with a high-water mark (4 MB) and low-water mark (1 MB). `sendToPeerBuffered()` waits for the buffer to drain before sending, which matters during file transfers.
@@ -229,10 +229,12 @@ graph LR
click Peers "media/media.manager.ts" "MediaManager.bindLocalTracksToAllPeers()" _blank
```
`MediaManager` grabs the mic with `getUserMedia`, optionally pipes it through the RNNoise AudioWorklet for noise reduction (48 kHz, loaded from `rnnoise-worklet.js`), optionally runs it through a `GainNode` for input volume control, and then pushes the resulting stream to every connected peer via `replaceTrack`.
`MediaManager` grabs the mic with `getUserMedia`, optionally pipes it through the RNNoise AudioWorklet for noise reduction (48 kHz, loaded from `rnnoise-worklet.js`), optionally runs it through a `GainNode` for input volume control, and then routes the resulting audio track only to peers that currently belong to the same active voice channel.
Mute just disables the audio track (`track.enabled = false`), the connection stays up. Deafen suppresses incoming audio playback on the local side.
Because peers stay connected across the server for shared state and chat, voice-channel isolation is enforced in both transport and playback: outgoing mic audio is only attached to peers whose voice membership matches the local user's current channel, and remote voice audio plus join/leave cues are only active when the remote peer's announced `voiceState.roomId` and `voiceState.serverId` match the local user's current voice channel.
### Screen share
Screen capture uses a platform-specific strategy:

View File

@@ -91,6 +91,7 @@ export class MediaManager {
private currentVoiceRoomId: string | undefined;
/** Current voice channel server ID (set when joining voice). */
private currentVoiceServerId: string | undefined;
private allowedVoicePeerIds = new Set<string>();
constructor(
private readonly logger: WebRTCLogger,
@@ -146,6 +147,21 @@ export class MediaManager {
return this._noiseReductionDesired;
}
setAllowedVoicePeerIds(peerIds: Iterable<string>): void {
const nextAllowed = new Set(peerIds);
if (this.areSetsEqual(this.allowedVoicePeerIds, nextAllowed)) {
return;
}
this.allowedVoicePeerIds = nextAllowed;
this.syncVoiceRouting();
}
refreshVoiceRouting(): void {
this.syncVoiceRouting();
}
/**
* Request microphone access via `getUserMedia` and bind the resulting
* audio track to every active peer connection.
@@ -239,6 +255,7 @@ export class MediaManager {
this.isVoiceActive = false;
this.currentVoiceRoomId = undefined;
this.currentVoiceServerId = undefined;
this.allowedVoicePeerIds.clear();
}
/**
@@ -491,31 +508,11 @@ export class MediaManager {
peers.forEach((peerData, peerId) => {
if (localAudioTrack) {
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
preferredSender: peerData.audioSender,
excludedSenders: [peerData.screenAudioSender]
});
const audioSender = audioTransceiver.sender;
peerData.audioSender = audioSender;
// Restore direction after removeTrack (which sets it to recvonly)
if (
audioTransceiver &&
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
audioTransceiver.direction === TRANSCEIVER_INACTIVE)
) {
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
if (this.allowedVoicePeerIds.has(peerId)) {
this.attachVoiceTrackToPeer(peerId, peerData, localStream, localAudioTrack);
} else {
this.detachVoiceTrackFromPeer(peerData);
}
if (typeof audioSender.setStreams === 'function') {
audioSender.setStreams(localStream);
}
audioSender
.replaceTrack(localAudioTrack)
.then(() => this.logger.info('audio replaceTrack ok', { peerId }))
.catch((error) => this.logger.error('audio replaceTrack failed', error));
}
if (localVideoTrack) {
@@ -549,6 +546,87 @@ export class MediaManager {
});
}
private syncVoiceRouting(): void {
const peers = this.callbacks.getActivePeers();
const localStream = this.localMediaStream;
const localAudioTrack = localStream?.getAudioTracks()[0] || null;
peers.forEach((peerData, peerId) => {
const didChange = localStream && localAudioTrack && this.allowedVoicePeerIds.has(peerId)
? this.attachVoiceTrackToPeer(peerId, peerData, localStream, localAudioTrack)
: this.detachVoiceTrackFromPeer(peerData);
if (didChange) {
void this.callbacks.renegotiate(peerId);
}
});
}
private attachVoiceTrackToPeer(
peerId: string,
peerData: PeerData,
localStream: MediaStream,
localAudioTrack: MediaStreamTrack
): boolean {
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
preferredSender: peerData.audioSender,
excludedSenders: [peerData.screenAudioSender]
});
const audioSender = audioTransceiver.sender;
const needsDirectionRestore = audioTransceiver.direction === TRANSCEIVER_RECV_ONLY
|| audioTransceiver.direction === TRANSCEIVER_INACTIVE;
const needsTrackReplace = audioSender.track !== localAudioTrack;
peerData.audioSender = audioSender;
if (!needsDirectionRestore && !needsTrackReplace) {
return false;
}
if (needsDirectionRestore) {
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
}
if (typeof audioSender.setStreams === 'function') {
audioSender.setStreams(localStream);
}
if (needsTrackReplace) {
audioSender
.replaceTrack(localAudioTrack)
.then(() => this.logger.info('audio replaceTrack ok', { peerId }))
.catch((error) => this.logger.error('audio replaceTrack failed', error));
}
return true;
}
private detachVoiceTrackFromPeer(peerData: PeerData): boolean {
const audioSender = peerData.audioSender
?? peerData.connection.getSenders().find((sender) => sender !== peerData.screenAudioSender && sender.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender?.track) {
return false;
}
peerData.connection.removeTrack(audioSender);
return true;
}
private areSetsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
private getOrCreateReusableTransceiver(
peerData: PeerData,
kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO,

View File

@@ -219,6 +219,8 @@ export class WebRTCService implements OnDestroy {
this.peerMediaFacade.syncScreenShareToPeer(peerId);
}
this.mediaManager.refreshVoiceRouting();
this.remoteScreenShareRequestController.handlePeerConnected(peerId);
});
@@ -575,6 +577,10 @@ export class WebRTCService implements OnDestroy {
this.voiceSessionController.stopVoiceHeartbeat();
}
syncOutgoingVoiceRouting(allowedPeerIds: string[]): void {
this.mediaManager.setAllowedVoicePeerIds(allowedPeerIds);
}
/**
* Start sharing the screen (or a window) with all connected peers.
*

View File

@@ -204,6 +204,13 @@ export interface VoiceStateEvent extends ChatEventBase {
voiceState: Partial<VoiceState>;
}
export interface VoiceChannelMoveEvent extends ChatEventBase {
type: 'voice-channel-move';
roomId: string;
targetUserId: string;
voiceState: Partial<VoiceState>;
}
export interface ScreenStateEvent extends ChatEventBase {
type: 'screen-state';
isScreenSharing: boolean;
@@ -323,6 +330,7 @@ export type ChatEvent =
| RoomPermissionsUpdateEvent
| HostChangeEvent
| VoiceStateEvent
| VoiceChannelMoveEvent
| ScreenStateEvent
| VoiceStateRequestEvent
| StateRequestEvent

View File

@@ -1,4 +1,7 @@
import { Channel } from '../../shared-kernel';
import {
Channel,
ChannelType
} from '../../shared-kernel';
export function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
@@ -8,9 +11,14 @@ function channelNameKey(name: string): string {
return normalizeChannelName(name).toLocaleLowerCase();
}
function typedChannelNameKey(type: ChannelType, name: string): string {
return `${type}:${channelNameKey(name)}`;
}
export function isChannelNameTaken(
channels: Channel[],
name: string,
channelType: ChannelType,
excludeChannelId?: string
): boolean {
const targetKey = channelNameKey(name);
@@ -19,7 +27,11 @@ export function isChannelNameTaken(
return false;
}
return channels.some((channel) => channel.id !== excludeChannelId && channelNameKey(channel.name) === targetKey);
return channels.some(
(channel) => channel.id !== excludeChannelId
&& channel.type === channelType
&& channelNameKey(channel.name) === targetKey
);
}
export function normalizeRoomChannels(channels: Channel[] | undefined): Channel[] | undefined {
@@ -35,7 +47,7 @@ export function normalizeRoomChannels(channels: Channel[] | undefined): Channel[
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = normalizeChannelName(channel.name);
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const nameKey = channelNameKey(name);
const nameKey = type ? typedChannelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;

View File

@@ -56,6 +56,7 @@ import {
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants';
import { VoiceSessionFacade } from '../../domains/voice-session';
import {
findRoomMember,
removeRoomMember,
@@ -138,6 +139,7 @@ export class RoomsEffects {
private webrtc = inject(RealtimeSessionFacade);
private serverDirectory = inject(ServerDirectoryFacade);
private audioService = inject(NotificationAudioService);
private voiceSessionService = inject(VoiceSessionFacade);
/**
* Tracks user IDs we already know are in voice. Lives outside the
@@ -404,6 +406,42 @@ export class RoomsEffects {
{ dispatch: false }
);
refreshServerOwnedRoomMetadata$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
switchMap(({ room }) =>
this.serverDirectory.getServer(room.id, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).pipe(
map((serverData) => {
if (!serverData) {
return null;
}
return RoomsActions.updateRoom({
roomId: room.id,
changes: {
name: serverData.name,
description: serverData.description,
hostId: serverData.ownerId || room.hostId,
hasPassword: !!serverData.hasPassword,
isPrivate: serverData.isPrivate,
maxUsers: serverData.maxUsers,
channels: Array.isArray(serverData.channels) ? serverData.channels : room.channels,
sourceId: serverData.sourceId ?? room.sourceId,
sourceName: serverData.sourceName ?? room.sourceName,
sourceUrl: serverData.sourceUrl ?? room.sourceUrl
}
});
}),
filter((action): action is ReturnType<typeof RoomsActions.updateRoom> => !!action),
catchError(() => EMPTY)
)
)
)
);
/** Switches the UI view to an already-joined server without leaving others. */
viewServer$ = createEffect(() =>
this.actions$.pipe(
@@ -1024,9 +1062,11 @@ export class RoomsEffects {
]) => {
switch (event.type) {
case 'voice-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'voice') : EMPTY;
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice') : EMPTY;
case 'voice-channel-move':
return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
case 'screen-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'screen') : EMPTY;
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen') : EMPTY;
case 'server-state-request':
return this.handleServerStateRequest(event, currentRoom, savedRooms);
case 'server-state-full':
@@ -1051,13 +1091,14 @@ export class RoomsEffects {
)
);
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], kind: 'voice' | 'screen') {
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen') {
const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId)
return EMPTY;
const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId);
const existingUser = allUsers.find((u) => u.id === userId || u.oderId === userId);
const userExists = !!existingUser;
if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined;
@@ -1070,13 +1111,14 @@ export class RoomsEffects {
// clearUsers() from server-switching doesn't create false transitions.
const weAreInVoice = this.webrtc.isVoiceConnected();
const nowConnected = vs.isConnected ?? false;
const wasKnown = this.knownVoiceUsers.has(userId);
const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
const isInCurrentVoiceRoom = this.isSameVoiceRoom(vs, currentUser?.voiceState);
if (weAreInVoice) {
const wasKnown = this.knownVoiceUsers.has(userId);
if (!wasKnown && nowConnected) {
if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
this.audioService.play(AppSound.Joining);
} else if (wasKnown && !nowConnected) {
} else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
this.audioService.play(AppSound.Leave);
}
}
@@ -1141,6 +1183,79 @@ export class RoomsEffects {
);
}
private handleVoiceChannelMove(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
) {
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;
if (!currentUser || !targetUserId || !serverId || !nextVoiceState?.roomId) {
return EMPTY;
}
if (targetUserId !== currentUser.id && targetUserId !== currentUser.oderId) {
return EMPTY;
}
const room = this.resolveRoom(serverId, currentRoom, savedRooms);
const movedChannel = room?.channels?.find((channel) => channel.id === nextVoiceState.roomId && channel.type === 'voice');
if (!room || !movedChannel) {
return EMPTY;
}
const updatedVoiceState: Partial<VoiceState> = {
isConnected: true,
isMuted: currentUser.voiceState?.isMuted ?? false,
isDeafened: currentUser.voiceState?.isDeafened ?? false,
isSpeaking: currentUser.voiceState?.isSpeaking ?? false,
isMutedByAdmin: currentUser.voiceState?.isMutedByAdmin,
volume: currentUser.voiceState?.volume,
roomId: movedChannel.id,
serverId: room.id
};
const wasViewingVoiceServer = this.voiceSessionService.isViewingVoiceServer();
this.webrtc.startVoiceHeartbeat(movedChannel.id, room.id);
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId: movedChannel.id,
roomName: `🔊 ${movedChannel.name}`,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: currentUser.oderId || currentUser.id,
displayName: currentUser.displayName || 'User',
voiceState: updatedVoiceState
});
return of(UsersActions.updateVoiceState({
userId: currentUser.id,
voiceState: updatedVoiceState
}));
}
private isSameVoiceRoom(
voiceState: Partial<VoiceState> | undefined,
currentUserVoiceState: Partial<VoiceState> | undefined
): boolean {
return !!voiceState?.isConnected
&& !!currentUserVoiceState?.isConnected
&& !!voiceState.roomId
&& !!voiceState.serverId
&& voiceState.roomId === currentUserVoiceState.roomId
&& voiceState.serverId === currentUserVoiceState.serverId;
}
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId)
return currentRoom;

View File

@@ -340,7 +340,10 @@ export const roomsReducer = createReducer(
return {
...state,
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.currentRoom?.id === roomId
? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
: state.activeChannelId
};
}),
@@ -411,7 +414,11 @@ export const roomsReducer = createReducer(
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(channel.name);
if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName)) {
if (
!normalizedName
|| existing.some((entry) => entry.id === channel.id)
|| isChannelNameTaken(existing, normalizedName, channel.type)
) {
return state;
}
@@ -451,8 +458,9 @@ export const roomsReducer = createReducer(
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(name);
const existingChannel = existing.find((channel) => channel.id === channelId);
if (!normalizedName || isChannelNameTaken(existing, normalizedName, channelId)) {
if (!normalizedName || !existingChannel || isChannelNameTaken(existing, normalizedName, existingChannel.type, channelId)) {
return state;
}