Minor refactor

This commit is contained in:
2026-03-03 22:55:58 +01:00
parent 94f9a9f2ed
commit d641229f9d
4 changed files with 334 additions and 283 deletions

View File

@@ -34,6 +34,7 @@ import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks'; import remarkBreaks from 'remark-breaks';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import { unified } from 'unified'; import { unified } from 'unified';
import { ChatMarkdownService } from './services/chat-markdown.service';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀']; const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@@ -79,6 +80,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private serverDirectory = inject(ServerDirectoryService); private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService); private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
private markdown = inject(ChatMarkdownService);
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */ /** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
remarkProcessor = unified() remarkProcessor = unified()
@@ -277,7 +279,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const raw = this.messageContent.trim(); const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return; if (!raw && this.pendingFiles.length === 0) return;
const content = this.appendImageMarkdown(raw); const content = this.markdown.appendImageMarkdown(raw);
this.store.dispatch( this.store.dispatch(
MessagesActions.sendMessage({ MessagesActions.sendMessage({
@@ -607,109 +609,58 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */ /** Wrap selected text in an inline markdown token (bold, italic, etc.). */
applyInline(token: string): void { applyInline(token: string): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const selected = this.messageContent.slice(start, end) || 'text'; this.setSelection(result.selectionStart, result.selectionEnd);
const after = this.messageContent.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
this.messageContent = newText;
const cursor = before.length + token.length + selected.length + token.length;
this.setSelection(cursor, cursor);
} }
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */ /** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
applyPrefix(prefix: string): void { applyPrefix(prefix: string): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const selected = this.messageContent.slice(start, end) || 'text'; this.setSelection(result.selectionStart, result.selectionEnd);
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
} }
/** Insert a markdown heading at the given level around the current selection. */ /** Insert a markdown heading at the given level around the current selection. */
applyHeading(level: number): void { applyHeading(level: number): void {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level))); const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
const { start, end } = this.getSelection(); this.messageContent = result.text;
const before = this.messageContent.slice(0, start); this.setSelection(result.selectionStart, result.selectionEnd);
const selected = this.messageContent.slice(start, end) || 'Heading';
const after = this.messageContent.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const newText = `${before}${block}${after}`;
this.messageContent = newText;
const cursor = before.length + block.length;
this.setSelection(cursor, cursor);
} }
/** Convert selected lines into a numbered markdown list. */ /** Convert selected lines into a numbered markdown list. */
applyOrderedList(): void { applyOrderedList(): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const selected = this.messageContent.slice(start, end) || 'item\nitem'; this.setSelection(result.selectionStart, result.selectionEnd);
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
} }
/** Wrap the selection in a fenced markdown code block. */ /** Wrap the selection in a fenced markdown code block. */
applyCodeBlock(): void { applyCodeBlock(): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const selected = this.messageContent.slice(start, end) || 'code'; this.setSelection(result.selectionStart, result.selectionEnd);
const after = this.messageContent.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const newText = `${before}${fenced}${after}`;
this.messageContent = newText;
const cursor = before.length + fenced.length;
this.setSelection(cursor, cursor);
} }
/** Insert a markdown link around the current selection. */ /** Insert a markdown link around the current selection. */
applyLink(): void { applyLink(): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyLink(this.messageContent, this.getSelection());
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const selected = this.messageContent.slice(start, end) || 'link'; this.setSelection(result.selectionStart, result.selectionEnd);
const after = this.messageContent.slice(end);
const link = `[${selected}](https://)`;
const newText = `${before}${link}${after}`;
this.messageContent = newText;
const cursorStart = before.length + link.length - 1; // position inside url
this.setSelection(cursorStart - 8, cursorStart - 1);
} }
/** Insert a markdown image embed around the current selection. */ /** Insert a markdown image embed around the current selection. */
applyImage(): void { applyImage(): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyImage(this.messageContent, this.getSelection());
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const selected = this.messageContent.slice(start, end) || 'alt'; this.setSelection(result.selectionStart, result.selectionEnd);
const after = this.messageContent.slice(end);
const img = `![${selected}](https://)`;
const newText = `${before}${img}${after}`;
this.messageContent = newText;
const cursorStart = before.length + img.length - 1;
this.setSelection(cursorStart - 8, cursorStart - 1);
} }
/** Insert a horizontal rule at the cursor position. */ /** Insert a horizontal rule at the cursor position. */
applyHorizontalRule(): void { applyHorizontalRule(): void {
const { start, end } = this.getSelection(); const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
const before = this.messageContent.slice(0, start); this.messageContent = result.text;
const after = this.messageContent.slice(end); this.setSelection(result.selectionStart, result.selectionEnd);
const hr = `\n\n---\n\n`;
const newText = `${before}${hr}${after}`;
this.messageContent = newText;
const cursor = before.length + hr.length;
this.setSelection(cursor, cursor);
} }
/** Handle drag-enter to activate the drop zone overlay. */ /** Handle drag-enter to activate the drop zone overlay. */
@@ -902,34 +853,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.pendingFiles = []; this.pendingFiles = [];
} }
// Detect image URLs and append Markdown embeds at the end
private appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
// Skip if already embedded as a Markdown image
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */ /** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void { autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement; const el = this.messageInputRef?.nativeElement;

View File

@@ -0,0 +1,133 @@
import { Injectable } from '@angular/core';
export interface SelectionRange {
start: number;
end: number;
}
export interface ComposeResult {
text: string;
selectionStart: number;
selectionEnd: number;
}
@Injectable({ providedIn: 'root' })
export class ChatMarkdownService {
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length;
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'Heading';
const after = content.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const text = `${before}${block}${after}`;
const cursor = before.length + block.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'item\nitem';
const after = content.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'code';
const after = content.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'link';
const after = content.slice(end);
const link = `[${selected}](https://)`;
const text = `${before}${link}${after}`;
const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'alt';
const after = content.slice(end);
const img = `![${selected}](https://)`;
const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1;
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const after = content.slice(end);
const hr = `\n\n---\n\n`;
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text, selectionStart: cursor, selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
}
}

View File

@@ -0,0 +1,152 @@
import { Injectable } from '@angular/core';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
export interface PlaybackOptions {
isConnected: boolean;
outputVolume: number;
isDeafened: boolean;
}
@Injectable({ providedIn: 'root' })
export class VoicePlaybackService {
private remoteAudioElements = new Map<string, HTMLAudioElement>();
private pendingRemoteStreams = new Map<string, MediaStream>();
private rawRemoteStreams = new Map<string, MediaStream>();
constructor(
private voiceLeveling: VoiceLevelingService,
private webrtc: WebRTCService,
) {}
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
if (!options.isConnected) {
this.pendingRemoteStreams.set(peerId, stream);
return;
}
if (!this.hasAudio(stream)) {
return;
}
this.removeAudioElement(peerId);
// Always stash the raw stream so we can re-wire on toggle
this.rawRemoteStreams.set(peerId, stream);
// Start playback immediately with the raw stream
const audio = new Audio();
audio.srcObject = stream;
audio.autoplay = true;
audio.volume = options.outputVolume;
audio.muted = options.isDeafened;
audio.play().catch(() => {});
this.remoteAudioElements.set(peerId, audio);
// Swap to leveled stream if enabled
if (this.voiceLeveling.enabled()) {
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
const currentAudio = this.remoteAudioElements.get(peerId);
if (currentAudio && leveledStream !== stream) {
currentAudio.srcObject = leveledStream;
}
}).catch(() => {});
}
}
removeRemoteAudio(peerId: string): void {
this.pendingRemoteStreams.delete(peerId);
this.rawRemoteStreams.delete(peerId);
this.voiceLeveling.disable(peerId);
this.removeAudioElement(peerId);
}
playPendingStreams(options: PlaybackOptions): void {
if (!options.isConnected) return;
this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options));
this.pendingRemoteStreams.clear();
}
ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void {
if (!options.isConnected) return;
const peers = this.webrtc.getConnectedPeers();
for (const peerId of peers) {
const stream = this.webrtc.getRemoteStream(peerId);
if (stream && this.hasAudio(stream)) {
const trackedRaw = this.rawRemoteStreams.get(peerId);
if (!trackedRaw || trackedRaw !== stream) {
this.handleRemoteStream(peerId, stream, options);
}
}
}
}
async rebuildAllRemoteAudio(enabled: boolean, options: PlaybackOptions): Promise<void> {
if (enabled) {
for (const [peerId, rawStream] of this.rawRemoteStreams) {
try {
const leveledStream = await this.voiceLeveling.enable(peerId, rawStream);
const audio = this.remoteAudioElements.get(peerId);
if (audio && leveledStream !== rawStream) {
audio.srcObject = leveledStream;
}
} catch {}
}
} else {
this.voiceLeveling.disableAll();
for (const [peerId, rawStream] of this.rawRemoteStreams) {
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = rawStream;
}
}
}
this.updateOutputVolume(options.outputVolume);
this.updateDeafened(options.isDeafened);
}
updateOutputVolume(volume: number): void {
this.remoteAudioElements.forEach((audio) => {
audio.volume = volume;
});
}
updateDeafened(isDeafened: boolean): void {
this.remoteAudioElements.forEach((audio) => {
audio.muted = isDeafened;
});
}
applyOutputDevice(deviceId: string): void {
if (!deviceId) return;
this.remoteAudioElements.forEach((audio) => {
const anyAudio = audio as any;
if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch(() => {});
}
});
}
teardownAll(): void {
this.remoteAudioElements.forEach((audio) => {
audio.srcObject = null;
audio.remove();
});
this.remoteAudioElements.clear();
this.rawRemoteStreams.clear();
this.pendingRemoteStreams.clear();
}
private hasAudio(stream: MediaStream): boolean {
return stream.getAudioTracks().length > 0;
}
private removeAudioElement(peerId: string): void {
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = null;
audio.remove();
this.remoteAudioElements.delete(peerId);
}
}
}

View File

@@ -34,6 +34,7 @@ import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { UserAvatarComponent } from '../../../shared'; import { UserAvatarComponent } from '../../../shared';
import { PlaybackOptions, VoicePlaybackService } from './services/voice-playback.service';
interface AudioDevice { interface AudioDevice {
deviceId: string; deviceId: string;
@@ -64,15 +65,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private voiceSessionService = inject(VoiceSessionService); private voiceSessionService = inject(VoiceSessionService);
private voiceActivity = inject(VoiceActivityService); private voiceActivity = inject(VoiceActivityService);
private voiceLeveling = inject(VoiceLevelingService); private voiceLeveling = inject(VoiceLevelingService);
private voicePlayback = inject(VoicePlaybackService);
private store = inject(Store); private store = inject(Store);
private settingsModal = inject(SettingsModalService); private settingsModal = inject(SettingsModalService);
private remoteStreamSubscription: Subscription | null = null; private remoteStreamSubscription: Subscription | null = null;
private remoteAudioElements = new Map<string, HTMLAudioElement>();
private pendingRemoteStreams = new Map<string, MediaStream>();
/** Raw (unprocessed) remote streams keyed by peer ID — used to swap
* between raw playback and leveled playback when the user toggles
* the voice leveling setting. */
private rawRemoteStreams = new Map<string, MediaStream>();
/** Unsubscribe function for live voice-leveling toggle notifications. */ /** Unsubscribe function for live voice-leveling toggle notifications. */
private voiceLevelingUnsubscribe: (() => void) | null = null; private voiceLevelingUnsubscribe: (() => void) | null = null;
@@ -98,6 +94,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
includeSystemAudio = signal(false); includeSystemAudio = signal(false);
noiseReduction = signal(false); noiseReduction = signal(false);
private playbackOptions(): PlaybackOptions {
return {
isConnected: this.isConnected(),
outputVolume: this.outputVolume() / 100,
isDeafened: this.isDeafened(),
};
}
private voiceConnectedSubscription: Subscription | null = null; private voiceConnectedSubscription: Subscription | null = null;
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -110,28 +114,29 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Subscribe to remote streams to play audio from peers // Subscribe to remote streams to play audio from peers
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe( this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
({ peerId, stream }) => { ({ peerId, stream }) => {
this.playRemoteAudio(peerId, stream); this.voicePlayback.handleRemoteStream(peerId, stream, this.playbackOptions());
}, },
); );
// Listen for live voice-leveling toggle changes so we can // Listen for live voice-leveling toggle changes so we can
// rebuild all remote Audio elements immediately (no reconnect). // rebuild all remote Audio elements immediately (no reconnect).
this.voiceLevelingUnsubscribe = this.voiceLeveling.onEnabledChange( this.voiceLevelingUnsubscribe = this.voiceLeveling.onEnabledChange(
(enabled) => this.rebuildAllRemoteAudio(enabled), (enabled) => this.voicePlayback.rebuildAllRemoteAudio(enabled, this.playbackOptions()),
); );
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up // Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => { this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
this.playPendingStreams(); const options = this.playbackOptions();
this.voicePlayback.playPendingStreams(options);
// Also ensure all remote streams from connected peers are playing // Also ensure all remote streams from connected peers are playing
// This handles the case where streams were received while voice was "connected" // This handles the case where streams were received while voice was "connected"
// from a previous session but audio elements weren't set up // from a previous session but audio elements weren't set up
this.ensureAllRemoteStreamsPlaying(); this.voicePlayback.ensureAllRemoteStreamsPlaying(options);
}); });
// Clean up audio when peer disconnects // Clean up audio when peer disconnects
this.webrtcService.onPeerDisconnected.subscribe((peerId) => { this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
this.removeRemoteAudio(peerId); this.voicePlayback.removeRemoteAudio(peerId);
}); });
} }
@@ -140,13 +145,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.disconnect(); this.disconnect();
} }
// Clean up audio elements this.voicePlayback.teardownAll();
this.remoteAudioElements.forEach((audio) => {
audio.srcObject = null;
audio.remove();
});
this.remoteAudioElements.clear();
this.rawRemoteStreams.clear();
this.voiceLeveling.disableAll(); this.voiceLeveling.disableAll();
this.remoteStreamSubscription?.unsubscribe(); this.remoteStreamSubscription?.unsubscribe();
@@ -154,139 +153,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.voiceLevelingUnsubscribe?.(); this.voiceLevelingUnsubscribe?.();
} }
/**
* Play any pending remote streams that were received before we joined voice.
* This is called when voice is connected to ensure audio works on first join.
*/
private playPendingStreams(): void {
this.pendingRemoteStreams.forEach((stream, peerId) => {
this.playRemoteAudio(peerId, stream);
});
this.pendingRemoteStreams.clear();
}
/**
* Ensure all remote streams from connected peers are playing.
* This handles cases where voice was reconnected and streams were received
* while the previous voice session was still "connected".
*/
private ensureAllRemoteStreamsPlaying(): void {
const connectedPeers = this.webrtcService.getConnectedPeers();
for (const peerId of connectedPeers) {
const stream = this.webrtcService.getRemoteStream(peerId);
if (stream && stream.getAudioTracks().length > 0) {
// Check if we already have an active audio element for this peer.
// Compare against the stashed raw stream (not srcObject which may
// be the leveled stream when voice leveling is enabled).
const existingAudio = this.remoteAudioElements.get(peerId);
const trackedRaw = this.rawRemoteStreams.get(peerId);
if (!existingAudio || trackedRaw !== stream) {
this.playRemoteAudio(peerId, stream);
}
}
}
}
private removeRemoteAudio(peerId: string): void {
// Remove from pending streams
this.pendingRemoteStreams.delete(peerId);
this.rawRemoteStreams.delete(peerId);
// Remove voice leveling pipeline for this speaker
this.voiceLeveling.disable(peerId);
// Remove audio element
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = null;
audio.remove();
this.remoteAudioElements.delete(peerId);
}
}
private playRemoteAudio(peerId: string, stream: MediaStream): void {
// Only play remote audio if we have joined voice
if (!this.isConnected()) {
// Store the stream to play later when we connect
this.pendingRemoteStreams.set(peerId, stream);
return;
}
// Check if stream has audio tracks
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
return;
}
// Remove existing audio element for this peer if any
const existingAudio = this.remoteAudioElements.get(peerId);
if (existingAudio) {
existingAudio.srcObject = null;
existingAudio.remove();
}
// Always stash the raw stream so we can re-wire on toggle
this.rawRemoteStreams.set(peerId, stream);
// ── Step 1: Immediately start playback with the raw stream ──
// This guarantees audio is never lost even if the pipeline
// build takes time or fails.
const audio = new Audio();
audio.srcObject = stream;
audio.autoplay = true;
audio.volume = this.outputVolume() / 100;
if (this.isDeafened()) {
audio.muted = true;
}
audio.play().then(() => {}).catch(() => {});
this.remoteAudioElements.set(peerId, audio);
// ── Step 2: Asynchronously swap in the leveled stream ──
// Only when voice leveling is enabled. If it fails or is
// disabled, playback continues on the raw stream.
if (this.voiceLeveling.enabled()) {
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
// Guard: audio element may have been replaced or removed
const currentAudio = this.remoteAudioElements.get(peerId);
if (currentAudio && leveledStream !== stream) {
currentAudio.srcObject = leveledStream;
}
});
}
}
/**
* Rebuild all remote Audio elements when the user toggles voice
* leveling on or off. This runs synchronously for each peer,
* swapping `srcObject` between the raw stream and the leveled one.
*
* Mirrors the noise-reduction live-toggle pattern.
*/
private async rebuildAllRemoteAudio(enabled: boolean): Promise<void> {
if (enabled) {
// Enable: build pipelines and swap to leveled streams
for (const [peerId, rawStream] of this.rawRemoteStreams) {
try {
const leveledStream = await this.voiceLeveling.enable(peerId, rawStream);
const audio = this.remoteAudioElements.get(peerId);
if (audio && leveledStream !== rawStream) {
audio.srcObject = leveledStream;
}
} catch { /* already playing raw — fine */ }
}
} else {
// Disable: tear down all pipelines, swap back to raw streams
this.voiceLeveling.disableAll();
for (const [peerId, rawStream] of this.rawRemoteStreams) {
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = rawStream;
}
}
}
}
async loadAudioDevices(): Promise<void> { async loadAudioDevices(): Promise<void> {
try { try {
if (!navigator.mediaDevices?.enumerateDevices) { if (!navigator.mediaDevices?.enumerateDevices) {
@@ -355,10 +221,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}); });
// Play any pending remote streams now that we're connected // Play any pending remote streams now that we're connected
this.pendingRemoteStreams.forEach((pendingStream, peerId) => { this.voicePlayback.playPendingStreams(this.playbackOptions());
this.playRemoteAudio(peerId, pendingStream);
});
this.pendingRemoteStreams.clear();
// Persist settings after successful connection // Persist settings after successful connection
this.saveSettings(); this.saveSettings();
@@ -405,15 +268,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Tear down all voice leveling pipelines // Tear down all voice leveling pipelines
this.voiceLeveling.disableAll(); this.voiceLeveling.disableAll();
this.voicePlayback.teardownAll();
// Clear all remote audio elements
this.remoteAudioElements.forEach((audio) => {
audio.srcObject = null;
audio.remove();
});
this.remoteAudioElements.clear();
this.rawRemoteStreams.clear();
this.pendingRemoteStreams.clear();
const user = this.currentUser(); const user = this.currentUser();
if (user?.id) { if (user?.id) {
@@ -460,10 +315,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.isDeafened.update((current) => !current); this.isDeafened.update((current) => !current);
this.webrtcService.toggleDeafen(this.isDeafened()); this.webrtcService.toggleDeafen(this.isDeafened());
// Mute/unmute all remote audio elements this.voicePlayback.updateDeafened(this.isDeafened());
this.remoteAudioElements.forEach((audio) => {
audio.muted = this.isDeafened();
});
// When deafening, also mute // When deafening, also mute
if (this.isDeafened() && !this.isMuted()) { if (this.isDeafened() && !this.isMuted()) {
@@ -532,11 +384,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10)); this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100); this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
// Update volume on all remote audio elements
this.remoteAudioElements.forEach((audio) => {
audio.volume = this.outputVolume() / 100;
});
this.saveSettings(); this.saveSettings();
} }
@@ -627,12 +475,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private async applyOutputDevice(): Promise<void> { private async applyOutputDevice(): Promise<void> {
const deviceId = this.selectedOutputDevice(); const deviceId = this.selectedOutputDevice();
if (!deviceId) return; if (!deviceId) return;
this.remoteAudioElements.forEach((audio) => { this.voicePlayback.applyOutputDevice(deviceId);
const anyAudio = audio as any;
if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch(() => {});
}
});
} }
getMuteButtonClass(): string { getMuteButtonClass(): string {