diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index c98e0ce2..032caae4 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -186,6 +186,35 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, appendChildren(outputVolumeRow, outputVolumeSlider, outputVolumeLabel); section.appendChild(outputVolumeRow); + // Stream quality selector + const qualityHeader = createElement("h3", {}, "Stream Quality"); + const qualityDesc = createElement("p", { + style: "color:var(--text-muted);font-size:12px;margin:0 0 8px", + }, "Applies to camera and screenshare. Higher quality uses more bandwidth. Changes take effect on next voice join."); + const qualitySelect = createElement("select", { + class: "form-input", + style: "width:100%;margin-bottom:16px", + }) as HTMLSelectElement; + const qualityOptions: Array<[string, string]> = [ + ["low", "Low (360p cam / 720p screen)"], + ["medium", "Medium (720p)"], + ["high", "High (1080p)"], + ["source", "Source (1080p max bitrate)"], + ]; + const savedQuality = loadPref("streamQuality", "high"); + for (const [value, label] of qualityOptions) { + const opt = createElement("option", { value }, label); + if (value === savedQuality) opt.setAttribute("selected", ""); + qualitySelect.appendChild(opt); + } + qualitySelect.value = savedQuality; + qualitySelect.addEventListener("change", () => { + savePref("streamQuality", qualitySelect.value); + }, { signal }); + section.appendChild(qualityHeader); + section.appendChild(qualityDesc); + section.appendChild(qualitySelect); + // Video device selector const videoHeader = createElement("h3", {}, "Video Device"); const videoSelect = createElement("select", { diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 7125b316..0ab31456 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -3,11 +3,17 @@ import { Room, RoomEvent, Track, + VideoPresets, + ScreenSharePresets, + createLocalScreenTracks, + createLocalVideoTrack, type RemoteTrack, type RemoteTrackPublication, type RemoteParticipant, type Participant, type LocalAudioTrack, + type VideoCaptureOptions, + type ScreenShareCaptureOptions, DisconnectReason, } from "livekit-client"; import type { WsClient } from "@lib/ws"; @@ -26,6 +32,44 @@ import { createRNNoiseProcessor } from "@lib/noise-suppression"; const log = createLogger("livekitSession"); +// --- Stream quality presets --- + +export type StreamQuality = "low" | "medium" | "high" | "source"; + +const CAMERA_PRESETS: Record = { + low: { resolution: VideoPresets.h360.resolution }, + medium: { resolution: VideoPresets.h720.resolution }, + high: { resolution: VideoPresets.h1080.resolution }, + source: { resolution: VideoPresets.h1080.resolution }, +}; + +const CAMERA_PUBLISH_BITRATES: Record = { + low: 600_000, + medium: 1_700_000, + high: 4_000_000, + source: 8_000_000, +}; + +const SCREENSHARE_PRESETS: Record = { + low: { audio: true, resolution: ScreenSharePresets.h720fps5.resolution }, + medium: { audio: true, resolution: ScreenSharePresets.h1080fps15.resolution, contentHint: "detail" }, + high: { audio: true, resolution: ScreenSharePresets.h1080fps30.resolution, contentHint: "detail" }, + source: { audio: true, contentHint: "detail" }, // no resolution cap — use native source resolution +}; + +const SCREENSHARE_PUBLISH_BITRATES: Record = { + low: 1_500_000, + medium: 3_000_000, + high: 6_000_000, + source: 10_000_000, +}; + +function getStreamQuality(): StreamQuality { + const saved = loadPref("streamQuality", "high"); + if (saved === "low" || saved === "medium" || saved === "high" || saved === "source") return saved; + return "high"; +} + // --- Pure helpers (no instance state) --- /** Parse userId from LiveKit participant identity "user-{id}". Returns 0 if unparseable. */ @@ -81,6 +125,10 @@ export class LiveKitSession { /** Persisted mute state for screenshare audio so replacement tracks inherit UI state. */ private screenshareAudioMutedByUser = new Map(); + /** Manually published local tracks (camera/screenshare) for explicit cleanup. */ + private manualCameraTrack: { mediaStreamTrack: MediaStreamTrack; stop(): void } | null = null; + private manualScreenTracks: Array<{ mediaStreamTrack: MediaStreamTrack; stop(): void }> = []; + // --- Unified audio pipeline: input volume + VAD gating --- // Pipeline: rawMicTrack → source → analyser (VAD reads here) // → gainNode (volume × vadGate) → dest → WebRTC sender @@ -121,25 +169,53 @@ export class LiveKitSession { // --- Room factory --- private createRoom(): Room { + const quality = getStreamQuality(); + const isSource = quality === "source"; const newRoom = new Room({ - adaptiveStream: true, - dynacast: true, + // Adaptive features reduce quality based on subscriber viewport — + // disable for "source" quality to maintain full resolution. + adaptiveStream: !isSource, + dynacast: !isSource, audioCaptureDefaults: { echoCancellation: loadPref("echoCancellation", true), noiseSuppression: loadPref("noiseSuppression", true), autoGainControl: loadPref("autoGainControl", true), }, + videoCaptureDefaults: CAMERA_PRESETS[quality], + publishDefaults: { + videoEncoding: { + maxBitrate: CAMERA_PUBLISH_BITRATES[quality], + maxFramerate: quality === "low" ? 15 : 30, + }, + screenShareEncoding: { + maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality], + maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30, + }, + }, }); newRoom.on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed); newRoom.on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed); newRoom.on(RoomEvent.Disconnected, this.handleDisconnected); newRoom.on(RoomEvent.ActiveSpeakersChanged, this.handleActiveSpeakersChanged); newRoom.on(RoomEvent.AudioPlaybackStatusChanged, this.handleAudioPlaybackChanged); + newRoom.on(RoomEvent.LocalTrackPublished, this.handleLocalTrackPublished); return newRoom; } // --- Room event handlers (arrow fns to preserve `this`) --- + /** Defense in depth: when LiveKit (re)publishes a mic track during + * renegotiation, re-enforce the current mute state on the new track. */ + private handleLocalTrackPublished = (publication: { source?: string }): void => { + if (publication.source === Track.Source.Microphone) { + const { localMuted, localDeafened } = voiceStore.getState(); + if (localMuted || localDeafened) { + void this.applyMicMuteState(true); + log.debug("LocalTrackPublished: re-applied mute to mic track"); + } + } + }; + private handleTrackSubscribed = ( track: RemoteTrack, publication: RemoteTrackPublication, @@ -318,6 +394,7 @@ export class LiveKitSession { this.room.startAudio().catch(() => {}); await this.restoreLocalVoiceState("reconnect"); this.setupAudioPipeline(); + this.reapplyMuteGain(); this.startTokenRefreshTimer(); // Request a fresh token since the stored one may be close to expiry. this.requestTokenRefresh(); @@ -460,6 +537,13 @@ export class LiveKitSession { } } + // Always enforce mute at the track level even if no pipeline exists yet. + // setMicrophoneEnabled(false) doesn't guarantee mediaStreamTrack.enabled=false, + // and renegotiation when a new participant joins can bring a track back alive. + if (muted) { + this.applyMicMuteState(true); + } + this.applyRemoteAudioSubscriptionState(deafened); } @@ -563,6 +647,7 @@ export class LiveKitSession { // Set up unified audio pipeline (input volume + VAD gating via GainNode). // VAD polling only starts if saved sensitivity < 100. this.setupAudioPipeline(); + this.reapplyMuteGain(); this.startTokenRefreshTimer(); log.info("Voice session active", { channelId }); } @@ -594,6 +679,10 @@ export class LiveKitSession { this.teardownAudioPipeline(); this.removeAutoplayUnlock(); this.pendingJoin = null; + // Clean up manually published tracks. + if (this.manualCameraTrack !== null) { this.manualCameraTrack.stop(); this.manualCameraTrack = null; } + for (const t of this.manualScreenTracks) t.stop(); + this.manualScreenTracks = []; if (sendWs && this.ws !== null) { this.ws.send({ type: "voice_leave", payload: {} }); } @@ -628,34 +717,37 @@ export class LiveKitSession { setMuted(muted: boolean): void { setLocalMuted(muted); - if (this.room !== null) { - void this.room.localParticipant.setMicrophoneEnabled(!muted); - // When the audio pipeline is active, LiveKit's track disable may not - // silence the replaced sender track. Zero the gain to guarantee silence. - if (this.audioPipelineGain !== null && this.audioPipelineCtx !== null) { - const gain = muted ? 0 : this.currentInputGain; - this.audioPipelineGain.gain.setTargetAtTime(gain, this.audioPipelineCtx.currentTime, 0.015); - } - } + void this.applyMicMuteState(muted); } setDeafened(deafened: boolean): void { setLocalDeafened(deafened); this.applyRemoteAudioSubscriptionState(deafened); - // Deafen implies mute — stop publishing audio so other participants - // don't hear us while we can't hear them (matches Discord behaviour). - if (this.room !== null) { - const shouldPublish = !deafened && !voiceStore.getState().localMuted; - void this.room.localParticipant.setMicrophoneEnabled(shouldPublish); - // Also zero the pipeline gain to guarantee silence on the replaced track. - if (this.audioPipelineGain !== null && this.audioPipelineCtx !== null) { - const gain = shouldPublish ? this.currentInputGain : 0; - this.audioPipelineGain.gain.setTargetAtTime(gain, this.audioPipelineCtx.currentTime, 0.015); - } - } + const shouldMute = deafened || voiceStore.getState().localMuted; + void this.applyMicMuteState(shouldMute); log.debug("Deafen state changed", { deafened }); } + /** Nuclear mute: fully unpublish the mic track when muting and tear down + * the audio pipeline. Re-publish and rebuild when unmuting. This guarantees + * the SFU has no audio track to forward to other participants. */ + private async applyMicMuteState(muted: boolean): Promise { + if (this.room === null) return; + if (muted) { + // Tear down pipeline first so it doesn't hold refs to the track + this.teardownAudioPipeline(); + // Fully disable the mic — this unpublishes the track from the SFU + await this.room.localParticipant.setMicrophoneEnabled(false); + log.debug("Mic fully unpublished (muted)"); + } else { + // Re-enable mic — this re-publishes the track to the SFU + await this.room.localParticipant.setMicrophoneEnabled(true); + // Rebuild the audio pipeline on the fresh track + this.setupAudioPipeline(); + log.debug("Mic re-published (unmuted)"); + } + } + async enableCamera(): Promise { if (this.room === null || this.ws === null) { log.warn("Cannot enable camera: no active voice session"); @@ -663,12 +755,30 @@ export class LiveKitSession { return; } setLocalCamera(true); + const quality = getStreamQuality(); try { - await this.room.localParticipant.setCameraEnabled(true); const savedVideoDevice = loadPref("videoInputDevice", ""); - if (savedVideoDevice) await this.room.switchActiveDevice("videoinput", savedVideoDevice); + // Stop any existing manual camera track before creating a new one. + this.stopManualCameraTrack(); + const videoTrack = await createLocalVideoTrack({ + ...CAMERA_PRESETS[quality], + ...(savedVideoDevice ? { deviceId: savedVideoDevice } : {}), + }); + this.manualCameraTrack = videoTrack as any; + await this.room.localParticipant.publishTrack(videoTrack, { + source: Track.Source.Camera, + simulcast: quality !== "source", + videoEncoding: { + maxBitrate: CAMERA_PUBLISH_BITRATES[quality], + maxFramerate: quality === "low" ? 15 : 30, + }, + }); this.ws.send({ type: "voice_camera", payload: { enabled: true } }); - log.info("Camera enabled"); + // Re-apply audio pipeline — publishing a new track can trigger WebRTC + // renegotiation which resets the mic sender, bypassing our GainNode mute. + this.setupAudioPipeline(); + this.reapplyMuteGain(); + log.info("Camera enabled", { quality, maxBitrate: CAMERA_PUBLISH_BITRATES[quality] }); } catch (err) { setLocalCamera(false); log.error("Failed to enable camera", err); @@ -684,6 +794,9 @@ export class LiveKitSession { async disableCamera(): Promise { try { + this.stopManualCameraTrack(); + // Also call setCameraEnabled(false) as a fallback to clean up any + // LiveKit-managed camera track that might exist. if (this.room !== null) await this.room.localParticipant.setCameraEnabled(false); } catch (err) { log.warn("Failed to disable camera track (non-fatal)", err); @@ -694,6 +807,16 @@ export class LiveKitSession { } } + private stopManualCameraTrack(): void { + if (this.manualCameraTrack === null || this.room === null) return; + const track = this.manualCameraTrack; + this.manualCameraTrack = null; + try { + this.room.localParticipant.unpublishTrack(track.mediaStreamTrack); + } catch { /* already unpublished */ } + track.stop(); + } + async enableScreenshare(): Promise { if (this.room === null || this.ws === null) { log.warn("Cannot enable screenshare: no active voice session"); @@ -701,10 +824,29 @@ export class LiveKitSession { return; } setLocalScreenshare(true); + const quality = getStreamQuality(); try { - await this.room.localParticipant.setScreenShareEnabled(true, { audio: true }); + this.stopManualScreenTracks(); + const screenTracks = await createLocalScreenTracks(SCREENSHARE_PRESETS[quality]); + this.manualScreenTracks = screenTracks as any[]; + for (const track of screenTracks) { + const isVideo = track.kind === Track.Kind.Video; + await this.room.localParticipant.publishTrack(track, { + source: isVideo ? Track.Source.ScreenShare : Track.Source.ScreenShareAudio, + simulcast: false, // No simulcast for screenshare — send full quality + ...(isVideo ? { + videoEncoding: { + maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality], + maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30, + }, + } : {}), + }); + } this.ws.send({ type: "voice_screenshare", payload: { enabled: true } }); - log.info("Screenshare enabled"); + // Re-apply audio pipeline — same renegotiation risk as camera. + this.setupAudioPipeline(); + this.reapplyMuteGain(); + log.info("Screenshare enabled", { quality, maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality] }); } catch (err) { setLocalScreenshare(false); log.error("Failed to enable screenshare", err); @@ -718,6 +860,7 @@ export class LiveKitSession { async disableScreenshare(): Promise { try { + this.stopManualScreenTracks(); if (this.room !== null) await this.room.localParticipant.setScreenShareEnabled(false); } catch (err) { log.warn("Failed to disable screenshare track (non-fatal)", err); @@ -728,6 +871,18 @@ export class LiveKitSession { } } + private stopManualScreenTracks(): void { + if (this.manualScreenTracks.length === 0 || this.room === null) return; + const tracks = this.manualScreenTracks; + this.manualScreenTracks = []; + for (const track of tracks) { + try { + this.room.localParticipant.unpublishTrack(track.mediaStreamTrack); + } catch { /* already unpublished */ } + track.stop(); + } + } + async switchInputDevice(deviceId: string): Promise { if (this.room === null) { log.debug("Skipping input device switch — no active voice session"); @@ -882,13 +1037,22 @@ export class LiveKitSession { this.vadGated = false; } - /** Update the effective gain on the pipeline (inputVolume × vadGate). */ + /** Update the effective gain on the pipeline (inputVolume × vadGate). + * The pipeline only exists when unmuted — muting tears it down entirely. */ private updatePipelineGain(): void { if (this.audioPipelineGain === null || this.audioPipelineCtx === null) return; const effectiveGain = this.vadGated ? 0 : this.currentInputGain; this.audioPipelineGain.gain.setTargetAtTime(effectiveGain, this.audioPipelineCtx.currentTime, 0.015); } + /** Re-apply mute/deafen state after events that may reset the audio pipeline. */ + private reapplyMuteGain(): void { + const { localMuted, localDeafened } = voiceStore.getState(); + if (localMuted || localDeafened) { + void this.applyMicMuteState(true); + } + } + setInputVolume(volume: number): void { const clamped = Math.max(0, Math.min(200, volume)); savePref("inputVolume", clamped); diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 18e2e23f..bb59dffd 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -48,8 +48,9 @@ /* ── Channel Sidebar ── */ .channel-sidebar { - width: 240px; background: var(--bg-secondary); + width: 260px; background: var(--bg-secondary); display: flex; flex-direction: column; flex-shrink: 0; + overflow: hidden; } .channel-sidebar-header { height: 48px; padding: 0 16px; @@ -129,9 +130,13 @@ /* Voice users nested in sidebar */ .voice-users-list { padding: 2px 0 4px 36px; } .voice-user-item { - display: flex; align-items: center; gap: 8px; + display: flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; color: var(--text-muted); + overflow: hidden; +} +.voice-user-item .vu-name { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .voice-user-item:hover { background: var(--bg-hover); color: var(--text-normal); } .voice-user-item .vu-avatar { @@ -141,7 +146,7 @@ } .voice-user-item.speaking .vu-avatar { box-shadow: 0 0 0 2px var(--green); } .voice-user-item .vu-status, -.voice-user-item .vu-muted { margin-left: 2px; display: flex; align-items: center; } +.voice-user-item .vu-muted { margin-left: 2px; display: flex; align-items: center; flex-shrink: 0; } .voice-user-item .vu-status { color: var(--text-muted); } .voice-user-item .vu-muted { color: var(--red); } .voice-user-item .vu-name + .vu-status, @@ -156,6 +161,7 @@ padding: 1px 4px; border-radius: 3px; margin-left: 2px; + flex-shrink: 0; } /* Voice widget (above user bar, when connected) */ diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 37dd7f9c..34a98ae6 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // --- Mocks must be declared before imports --- +const mockVoiceState = vi.hoisted(() => ({ + localMuted: false, + localDeafened: false, +})); + const mockRoom = vi.hoisted(() => ({ connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), @@ -29,21 +34,46 @@ vi.mock("livekit-client", () => ({ TrackUnsubscribed: "trackUnsubscribed", Disconnected: "disconnected", ActiveSpeakersChanged: "activeSpeakersChanged", + AudioPlaybackStatusChanged: "audioPlaybackStatusChanged", + LocalTrackPublished: "localTrackPublished", }, Track: { - Source: { Microphone: "microphone", Camera: "camera" }, + Source: { + Microphone: "microphone", + Camera: "camera", + ScreenShare: "screenShare", + ScreenShareAudio: "screenShareAudio", + }, Kind: { Audio: "audio", Video: "video" }, }, + VideoPresets: { + h360: { resolution: { width: 640, height: 360 } }, + h720: { resolution: { width: 1280, height: 720 } }, + h1080: { resolution: { width: 1920, height: 1080 } }, + }, + ScreenSharePresets: { + h720fps5: { resolution: { width: 1280, height: 720 } }, + h1080fps15: { resolution: { width: 1920, height: 1080 } }, + h1080fps30: { resolution: { width: 1920, height: 1080 } }, + }, DisconnectReason: { CLIENT_INITIATED: 0 }, + createLocalVideoTrack: vi.fn(async () => ({ kind: "video", mediaStreamTrack: new MediaStreamTrack() })), + createLocalScreenTracks: vi.fn(async () => [{ kind: "video", mediaStreamTrack: new MediaStreamTrack() }]), })); vi.mock("@stores/voice.store", () => ({ - voiceStore: { get: vi.fn(() => ({})), set: vi.fn(), subscribe: vi.fn() }, + voiceStore: { + getState: vi.fn(() => mockVoiceState), + get: vi.fn(() => ({})), + set: vi.fn(), + subscribe: vi.fn(), + }, setLocalMuted: vi.fn(), setLocalDeafened: vi.fn(), setLocalCamera: vi.fn(), setLocalScreenshare: vi.fn(), setSpeakers: vi.fn(), + leaveVoiceChannel: vi.fn(), })); const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({ @@ -73,6 +103,20 @@ vi.mock("@lib/noise-suppression", () => ({ import { parseUserId, LiveKitSession } from "../../src/lib/livekitSession"; import { setLocalMuted, setLocalDeafened, setLocalCamera, setLocalScreenshare } from "@stores/voice.store"; +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("parseUserId", () => { it("parses a valid user identity", () => { expect(parseUserId("user-42")).toBe(42); @@ -129,6 +173,8 @@ describe("LiveKitSession", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); + mockVoiceState.localMuted = false; + mockVoiceState.localDeafened = false; session = new LiveKitSession(); // Reset mockRoom state mockRoom.state = "connected"; @@ -327,6 +373,24 @@ describe("LiveKitSession", () => { session.setOutputVolume(-10); expect(mockSavePref).toHaveBeenCalledWith("outputVolume", 0); }); + + it("updates existing screenshare audio elements when master output changes", () => { + const screenshareAudio = document.createElement("audio"); + (session as any).screenshareAudioElements = new Map([[42, new Set([screenshareAudio])]]); + + session.setOutputVolume(80); + + expect(screenshareAudio.volume).toBe(0.8); + }); + + it("clamps existing screenshare audio elements to the browser volume range", () => { + const screenshareAudio = document.createElement("audio"); + (session as any).screenshareAudioElements = new Map([[42, new Set([screenshareAudio])]]); + + session.setOutputVolume(150); + + expect(screenshareAudio.volume).toBe(1); + }); }); describe("setVoiceSensitivity", () => { @@ -432,6 +496,31 @@ describe("LiveKitSession", () => { expect(errorCb).toHaveBeenCalledWith("Failed to join voice — connection error"); }); + + it("queues the latest join request that arrives while connecting", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + const firstConnect = createDeferred(); + mockRoom.connect + .mockImplementationOnce(() => firstConnect.promise) + .mockResolvedValueOnce(undefined); + + const firstJoin = session.handleVoiceToken("first-token", "/livekit-one", 1, "ws://localhost:7881"); + await Promise.resolve(); + + await session.handleVoiceToken("second-token", "/livekit-two", 2, "ws://localhost:7882"); + expect(mockRoom.connect).toHaveBeenCalledTimes(1); + + firstConnect.resolve(undefined); + await firstJoin; + + expect(mockRoom.connect).toHaveBeenCalledTimes(2); + expect(mockRoom.connect).toHaveBeenNthCalledWith(1, "ws://localhost:7881", "first-token"); + expect(mockRoom.connect).toHaveBeenNthCalledWith(2, "ws://localhost:7882", "second-token"); + expect(mockRoom.startAudio).toHaveBeenCalledTimes(1); + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledTimes(1); + }); }); describe("handleVoiceTokenRefresh", () => { @@ -444,4 +533,148 @@ describe("LiveKitSession", () => { expect(() => session.handleVoiceTokenRefresh(undefined)).not.toThrow(); }); }); + + describe("auto reconnect", () => { + it("preserves local mute state on reconnect", async () => { + mockVoiceState.localMuted = true; + mockVoiceState.localDeafened = false; + (session as any).currentChannelId = 7; + + const reconnectPromise = (session as any).attemptAutoReconnect( + "reconnect-token", + "/livekit", + 7, + "ws://localhost:7880", + ); + + await vi.advanceTimersByTimeAsync(3100); + await reconnectPromise; + + expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); + }); + + it("re-applies deafened remote subscriptions on reconnect", async () => { + mockVoiceState.localMuted = true; + mockVoiceState.localDeafened = true; + (session as any).currentChannelId = 9; + + const setSubscribed = vi.fn(); + mockRoom.remoteParticipants = new Map([ + [ + "remote-user", + { + audioTrackPublications: new Map([["audio", { setSubscribed }]]), + }, + ], + ]); + + const reconnectPromise = (session as any).attemptAutoReconnect( + "reconnect-token", + "/livekit", + 9, + "ws://localhost:7880", + ); + + await vi.advanceTimersByTimeAsync(3100); + await reconnectPromise; + + expect(setSubscribed).toHaveBeenCalledWith(false); + }); + }); + + // ----------------------------------------------------------------------- + // Screenshare audio controls (Spec 1) + // ----------------------------------------------------------------------- + + describe("setScreenshareAudioVolume", () => { + it("does not throw when no audio element exists for userId", () => { + expect(() => session.setScreenshareAudioVolume(999, 0.5)).not.toThrow(); + }); + }); + + describe("screenshare audio subscription", () => { + it("clamps screenshare audio element volume when output is boosted", () => { + session.setOutputVolume(150); + + const audioEl = document.createElement("audio"); + const track = { + kind: "audio", + sid: "track-1", + detach: vi.fn(() => []), + attach: vi.fn(() => audioEl), + }; + const publication = { source: "screenShareAudio" }; + const participant = { identity: "user-42" }; + + expect(() => (session as any).handleTrackSubscribed(track, publication, participant)).not.toThrow(); + expect(audioEl.volume).toBe(1); + }); + + it("keeps a replacement screenshare audio element tracked when an older track unsubscribes", () => { + const firstAudioEl = document.createElement("audio"); + const secondAudioEl = document.createElement("audio"); + const firstTrack = { + kind: "audio", + sid: "track-1", + detach: vi.fn(() => [firstAudioEl]), + attach: vi.fn(() => firstAudioEl), + }; + const secondTrack = { + kind: "audio", + sid: "track-2", + detach: vi.fn(() => [secondAudioEl]), + attach: vi.fn(() => secondAudioEl), + }; + const publication = { source: "screenShareAudio" }; + const participant = { identity: "user-42" }; + + (session as any).handleTrackSubscribed(firstTrack, publication, participant); + (session as any).handleTrackSubscribed(secondTrack, publication, participant); + (session as any).handleTrackUnsubscribed(firstTrack, publication, participant); + + session.muteScreenshareAudio(42, true); + + expect(secondAudioEl.muted).toBe(true); + expect((session as any).screenshareAudioElements.get(42)).toEqual(new Set([secondAudioEl])); + }); + + it("applies the stored mute state to replacement screenshare audio tracks", () => { + const firstAudioEl = document.createElement("audio"); + const secondAudioEl = document.createElement("audio"); + const firstTrack = { + kind: "audio", + sid: "track-1", + detach: vi.fn(() => [firstAudioEl]), + attach: vi.fn(() => firstAudioEl), + }; + const secondTrack = { + kind: "audio", + sid: "track-2", + detach: vi.fn(() => [secondAudioEl]), + attach: vi.fn(() => secondAudioEl), + }; + const publication = { source: "screenShareAudio" }; + const participant = { identity: "user-42" }; + + (session as any).handleTrackSubscribed(firstTrack, publication, participant); + session.muteScreenshareAudio(42, true); + + (session as any).handleTrackSubscribed(secondTrack, publication, participant); + + expect(secondAudioEl.muted).toBe(true); + expect(session.getScreenshareAudioMuted(42)).toBe(true); + }); + }); + + describe("muteScreenshareAudio", () => { + it("does not throw when no audio element exists for userId", () => { + expect(() => session.muteScreenshareAudio(999, true)).not.toThrow(); + }); + }); + + describe("getScreenshareAudioMuted", () => { + it("returns false when no audio element exists for userId", () => { + expect(session.getScreenshareAudioMuted(999)).toBe(false); + }); + }); });