import { Injectable, effect, inject } from '@angular/core'; import { WebRTCService } from '../../../../core/services/webrtc.service'; import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; export interface PlaybackOptions { isConnected: boolean; outputVolume: number; isDeafened: boolean; } /** * Per-peer Web Audio pipeline that routes the remote MediaStream * through a GainNode so volume can be amplified beyond 100% (up to 200%). * * Chrome/Electron workaround: a muted HTMLAudioElement is attached to * the stream first so that `createMediaStreamSource` actually outputs * audio. The priming element itself is silent; audible output is routed * through a separate output element fed by * `GainNode -> MediaStreamDestination` so output-device switching stays * reliable during Linux screen sharing. */ interface PeerAudioPipeline { audioElement: HTMLAudioElement; outputElement: HTMLAudioElement; context: AudioContext; sourceNodes: MediaStreamAudioSourceNode[]; gainNode: GainNode; } @Injectable({ providedIn: 'root' }) export class VoicePlaybackService { private webrtc = inject(WebRTCService); private peerPipelines = new Map(); private pendingRemoteStreams = new Map(); private rawRemoteStreams = new Map(); private userVolumes = new Map(); private userMuted = new Map(); private preferredOutputDeviceId = 'default'; private temporaryOutputDeviceId: string | null = null; private masterVolume = 1; private deafened = false; private captureEchoSuppressed = false; constructor() { this.loadPersistedVolumes(); effect(() => { this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed(); this.recalcAllGains(); }); effect(() => { this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput() ? 'default' : null; void this.applyEffectiveOutputDeviceToAllPipelines(); }); } handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void { if (!options.isConnected) { this.pendingRemoteStreams.set(peerId, stream); return; } if (!this.hasAudio(stream)) { this.rawRemoteStreams.delete(peerId); this.removePipeline(peerId); return; } this.removePipeline(peerId); this.rawRemoteStreams.set(peerId, stream); this.masterVolume = options.outputVolume; this.deafened = options.isDeafened; this.createPipeline(peerId, stream); } removeRemoteAudio(peerId: string): void { this.pendingRemoteStreams.delete(peerId); this.rawRemoteStreams.delete(peerId); this.removePipeline(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.getRemoteVoiceStream(peerId); if (stream && this.hasAudio(stream)) { const trackedRaw = this.rawRemoteStreams.get(peerId); if (!trackedRaw || trackedRaw !== stream) { this.handleRemoteStream(peerId, stream, options); } } } } updateOutputVolume(volume: number): void { this.masterVolume = volume; this.recalcAllGains(); } updateDeafened(isDeafened: boolean): void { this.deafened = isDeafened; this.recalcAllGains(); } getUserVolume(peerId: string): number { return this.userVolumes.get(peerId) ?? 100; } setUserVolume(peerId: string, volume: number): void { const clamped = Math.max(0, Math.min(200, volume)); this.userVolumes.set(peerId, clamped); this.applyGain(peerId); this.persistVolumes(); } isUserMuted(peerId: string): boolean { return this.userMuted.get(peerId) ?? false; } setUserMuted(peerId: string, muted: boolean): void { this.userMuted.set(peerId, muted); this.applyGain(peerId); this.persistVolumes(); } applyOutputDevice(deviceId: string): void { this.preferredOutputDeviceId = deviceId || 'default'; void this.applyEffectiveOutputDeviceToAllPipelines(); } teardownAll(): void { this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId)); this.peerPipelines.clear(); this.rawRemoteStreams.clear(); this.pendingRemoteStreams.clear(); } /** * Build the Web Audio graph for a remote peer: * * remoteStream * ↓ * muted