mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user