feat: stream quality presets, nuclear mute, sidebar width fix

- Add stream quality selector (Low/Medium/High/Source) in Voice & Audio
  settings with per-preset bitrate and resolution for camera + screenshare
- Use createLocalVideoTrack/createLocalScreenTracks + publishTrack for
  explicit encoding control (bypasses LiveKit conservative defaults)
- Source quality: 8Mbps camera, 10Mbps screenshare, no adaptive/dynacast
- Nuclear mute: fully unpublish mic track when muting, re-publish on
  unmute — guarantees SFU has no audio to forward
- Listen for LocalTrackPublished to re-enforce mute on renegotiation
- Store manually published tracks for explicit unpublish on disable
- VAD updatePipelineGain respects mute/deafen state
- Widen channel sidebar from 240px to 260px, add overflow handling
  so voice user icons don't clip and cause horizontal scrollbar
- Remove invalid LiveKit server-side room config fields
This commit is contained in:
jevb
2026-03-26 22:07:52 +01:00
parent 15b779f86b
commit c628a62565
4 changed files with 465 additions and 33 deletions
@@ -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<string>("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", {
+192 -28
View File
@@ -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<StreamQuality, VideoCaptureOptions> = {
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<StreamQuality, number> = {
low: 600_000,
medium: 1_700_000,
high: 4_000_000,
source: 8_000_000,
};
const SCREENSHARE_PRESETS: Record<StreamQuality, ScreenShareCaptureOptions> = {
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<StreamQuality, number> = {
low: 1_500_000,
medium: 3_000_000,
high: 6_000_000,
source: 10_000_000,
};
function getStreamQuality(): StreamQuality {
const saved = loadPref<string>("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<number, boolean>();
/** 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<void> {
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<void> {
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<string>("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<void> {
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<void> {
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<void> {
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<void> {
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);
+9 -3
View File
@@ -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) */
@@ -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<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<void>();
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);
});
});
});