feat: full screenshare support — button state, video grid, auto-reconnect

- Add enableScreenshare/disableScreenshare to LiveKitSession with
  proper LiveKit track publishing, error handling, and WS notification
- VoiceWidget screenshare button now shows active state (red highlight,
  icon swap, aria-pressed) matching mute/deafen/camera pattern
- VideoModeController activates video grid for screenshare (not just
  camera), with local self-view tile using ID offset to avoid collision
- MainPage voice store subscription now watches screenshare state
  changes to trigger checkVideoMode automatically
- Reset localScreenshare on leaveVoice to prevent stale button state
- Add auto-reconnect on unexpected LiveKit disconnect (2 attempts with
  3s delay, fresh token request on success)
- Fix pre-existing missing reapplyAudioProcessing mock in settings test
- 8 new tests covering screenshare button, video grid activation,
  tile lifecycle, and state cleanup
This commit is contained in:
jevb
2026-03-26 18:07:20 +01:00
parent 43a11c7b90
commit 6ca18762cb
10 changed files with 336 additions and 47 deletions
@@ -27,6 +27,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
let muteBtn: HTMLButtonElement | null = null;
let deafenBtn: HTMLButtonElement | null = null;
let cameraBtn: HTMLButtonElement | null = null;
let shareBtn: HTMLButtonElement | null = null;
const unsubs: Array<() => void> = [];
@@ -61,6 +62,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
if (muteBtn) { swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic"); muteBtn.setAttribute("aria-pressed", String(voice.localMuted)); }
if (deafenBtn) { swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones"); deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened)); }
if (cameraBtn) { swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera"); cameraBtn.setAttribute("aria-pressed", String(voice.localCamera)); }
shareBtn?.classList.toggle("active-ctrl", voice.localScreenshare);
if (shareBtn) { swapIcon(shareBtn, voice.localScreenshare ? "monitor-off" : "monitor"); shareBtn.setAttribute("aria-pressed", String(voice.localScreenshare)); }
}
function createControlButton(
@@ -90,7 +93,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
muteBtn = createControlButton("Mute", "mic", options.onMuteToggle);
deafenBtn = createControlButton("Deafen", "headphones", options.onDeafenToggle);
cameraBtn = createControlButton("Camera", "camera", options.onCameraToggle);
const shareBtn = createControlButton("Screenshare", "monitor", options.onScreenshareToggle);
shareBtn = createControlButton("Screenshare", "monitor", options.onScreenshareToggle);
const disconnectBtn = createControlButton(
"Disconnect", "phone", options.onDisconnect, "disconnect",
);
@@ -106,13 +109,15 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
muted: s.localMuted,
deafened: s.localDeafened,
camera: s.localCamera,
screenshare: s.localScreenshare,
}),
() => render(),
(a, b) =>
a.channelId === b.channelId &&
a.muted === b.muted &&
a.deafened === b.deafened &&
a.camera === b.camera,
a.camera === b.camera &&
a.screenshare === b.screenshare,
));
unsubs.push(channelsStore.subscribeSelector(
(s) => s.channels,
@@ -134,6 +139,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
muteBtn = null;
deafenBtn = null;
cameraBtn = null;
shareBtn = null;
}
return { mount, destroy };
+124 -1
View File
@@ -16,6 +16,7 @@ import {
setLocalMuted,
setLocalDeafened,
setLocalCamera,
setLocalScreenshare,
setSpeakers,
leaveVoiceChannel,
} from "@stores/voice.store";
@@ -59,6 +60,12 @@ export class LiveKitSession {
private latestToken: string | null = null;
/** Guard: true while handleVoiceToken is connecting — prevents concurrent joins. */
private connecting = false;
/** Last known LiveKit URL and directUrl for auto-reconnect on unexpected disconnect. */
private lastUrl: string | null = null;
private lastDirectUrl: string | undefined = undefined;
/** Max auto-reconnect attempts before giving up and showing error. */
private static readonly MAX_RECONNECT_ATTEMPTS = 2;
private static readonly RECONNECT_DELAY_MS = 3000;
/** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */
private outputVolumeMultiplier = loadPref<number>("outputVolume", 100) / 100;
@@ -218,12 +225,77 @@ export class LiveKitSession {
private handleDisconnected = (reason?: DisconnectReason): void => {
log.info("LiveKit room disconnected", { reason });
const isUnexpected = reason !== DisconnectReason.CLIENT_INITIATED;
if (isUnexpected && this.latestToken !== null && this.currentChannelId !== null && this.lastUrl !== null) {
// Attempt auto-reconnect with stored token before giving up.
const token = this.latestToken;
const url = this.lastUrl;
const channelId = this.currentChannelId;
const directUrl = this.lastDirectUrl;
// Clean up current room without sending WS leave (we're reconnecting, not leaving).
this.teardownAudioPipeline();
this.removeAutoplayUnlock();
this.clearTokenRefreshTimer();
if (this.room !== null) {
const r = this.room;
this.room = null;
r.removeAllListeners();
r.disconnect().catch(() => {});
}
void this.attemptAutoReconnect(token, url, channelId, directUrl);
return;
}
this.leaveVoice(false);
// Clear the voice store so the UI reflects the disconnected state.
leaveVoiceChannel();
if (isUnexpected) this.onErrorCallback?.("Voice connection lost — disconnected");
};
/** Attempt to auto-reconnect after unexpected disconnect using stored token. */
private async attemptAutoReconnect(
token: string, url: string, channelId: number, directUrl?: string,
): Promise<void> {
for (let attempt = 1; attempt <= LiveKitSession.MAX_RECONNECT_ATTEMPTS; attempt++) {
log.info("Auto-reconnect attempt", { attempt, maxAttempts: LiveKitSession.MAX_RECONNECT_ATTEMPTS });
await new Promise((r) => setTimeout(r, LiveKitSession.RECONNECT_DELAY_MS));
// If user manually left or joined a different channel during the delay, abort.
if (this.currentChannelId !== channelId) {
log.info("Auto-reconnect aborted — channel changed");
return;
}
try {
this.room = this.createRoom();
const resolvedUrl = this.resolveLiveKitUrl(url, directUrl);
await this.room.connect(resolvedUrl, token);
log.info("Auto-reconnect succeeded", { attempt, channelId });
this.room.startAudio().catch(() => {});
try {
await this.room.localParticipant.setMicrophoneEnabled(true);
if (loadPref<boolean>("enhancedNoiseSuppression", false)) {
await this.applyNoiseSuppressor();
}
} catch (micErr) {
log.warn("Auto-reconnect: mic unavailable — listen-only mode", micErr);
}
this.setupAudioPipeline();
this.startTokenRefreshTimer();
// Request a fresh token since the stored one may be close to expiry.
this.requestTokenRefresh();
return;
} catch (err) {
log.warn("Auto-reconnect failed", { attempt, error: err });
if (this.room !== null) {
this.room.removeAllListeners();
this.room.disconnect().catch(() => {});
this.room = null;
}
}
}
// All attempts exhausted — give up and clean up.
log.error("Auto-reconnect exhausted all attempts, giving up");
this.leaveVoice(false);
leaveVoiceChannel();
this.onErrorCallback?.("Voice connection lost — failed to reconnect");
}
// --- URL resolution ---
private resolveLiveKitUrl(proxyPath: string, directUrl?: string): string {
@@ -318,6 +390,10 @@ export class LiveKitSession {
this.handleVoiceTokenRefresh(token);
return;
}
// Store URL/token for auto-reconnect on unexpected disconnect.
this.latestToken = token;
this.lastUrl = url;
this.lastDirectUrl = directUrl;
// Prevent concurrent connect attempts (rapid channel switching).
if (this.connecting) {
log.warn("handleVoiceToken: already connecting, ignoring duplicate call");
@@ -407,7 +483,10 @@ export class LiveKitSession {
}
this.currentChannelId = null;
this.latestToken = null;
this.lastUrl = null;
this.lastDirectUrl = undefined;
setLocalCamera(false);
setLocalScreenshare(false);
log.info("Left voice session");
}
@@ -472,6 +551,40 @@ export class LiveKitSession {
}
}
async enableScreenshare(): Promise<void> {
if (this.room === null || this.ws === null) {
log.warn("Cannot enable screenshare: no active voice session");
this.onErrorCallback?.("Join a voice channel first");
return;
}
setLocalScreenshare(true);
try {
await this.room.localParticipant.setScreenShareEnabled(true);
this.ws.send({ type: "voice_screenshare", payload: { enabled: true } });
log.info("Screenshare enabled");
} catch (err) {
setLocalScreenshare(false);
log.error("Failed to enable screenshare", err);
if (err instanceof DOMException && err.name === "NotAllowedError") {
this.onErrorCallback?.("Screen sharing permission denied");
} else {
this.onErrorCallback?.("Failed to start screen sharing");
}
}
}
async disableScreenshare(): Promise<void> {
try {
if (this.room !== null) await this.room.localParticipant.setScreenShareEnabled(false);
} catch (err) {
log.warn("Failed to disable screenshare track (non-fatal)", err);
} finally {
setLocalScreenshare(false);
if (this.ws !== null) this.ws.send({ type: "voice_screenshare", payload: { enabled: false } });
log.info("Screenshare disabled");
}
}
async switchInputDevice(deviceId: string): Promise<void> {
if (this.room === null) {
log.debug("Skipping input device switch — no active voice session");
@@ -767,6 +880,13 @@ export class LiveKitSession {
return null;
}
getLocalScreenshareStream(): MediaStream | null {
if (this.room === null) return null;
const screenPub = this.room.localParticipant.getTrackPublication(Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) return new MediaStream([screenPub.track.mediaStreamTrack]);
return null;
}
getSessionDebugInfo(): Record<string, unknown> {
if (this.room === null) {
return { hasRoom: false, hasRNNoiseProcessor: false, currentChannelId: this.currentChannelId };
@@ -825,6 +945,8 @@ export const setMuted = session.setMuted.bind(session);
export const setDeafened = session.setDeafened.bind(session);
export const enableCamera = session.enableCamera.bind(session);
export const disableCamera = session.disableCamera.bind(session);
export const enableScreenshare = session.enableScreenshare.bind(session);
export const disableScreenshare = session.disableScreenshare.bind(session);
export const switchInputDevice = session.switchInputDevice.bind(session);
export const switchOutputDevice = session.switchOutputDevice.bind(session);
export const setUserVolume = session.setUserVolume.bind(session);
@@ -834,4 +956,5 @@ export const setOutputVolume = session.setOutputVolume.bind(session);
export const setVoiceSensitivity = session.setVoiceSensitivity.bind(session);
export const reapplyAudioProcessing = session.reapplyAudioProcessing.bind(session);
export const getLocalCameraStream = session.getLocalCameraStream.bind(session);
export const getLocalScreenshareStream = session.getLocalScreenshareStream.bind(session);
export const getSessionDebugInfo = session.getSessionDebugInfo.bind(session);
+8 -9
View File
@@ -292,25 +292,24 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
});
unsubscribers.push(() => clearOnRemoteVideo());
// Subscribe to voice store for camera state changes only (not speaking ticks)
let prevLocalCamera = voiceStore.getState().localCamera;
let prevCameraSignature = "";
// Subscribe to voice store for camera/screenshare state changes only (not speaking ticks)
let prevVideoSignature = "";
unsubscribers.push(voiceStore.subscribe((state) => {
try {
// Build a lightweight signature of camera-relevant state
let sig = state.localCamera ? "1" : "0";
// Build a lightweight signature of video-relevant state (camera + screenshare)
let sig = (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : "");
const channelId = state.currentChannelId;
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:${uid}`;
if (u.camera) sig += `:c${uid}`;
if (u.screenshare) sig += `:s${uid}`;
}
}
}
if (sig !== prevCameraSignature || state.localCamera !== prevLocalCamera) {
prevCameraSignature = sig;
prevLocalCamera = state.localCamera;
if (sig !== prevVideoSignature) {
prevVideoSignature = sig;
videoModeCtrl?.checkVideoMode();
}
} catch (err) {
@@ -4,7 +4,7 @@
*/
import { voiceStore } from "@stores/voice.store";
import { getLocalCameraStream } from "@lib/livekitSession";
import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession";
import type { VideoGridComponent } from "@components/VideoGrid";
// ---------------------------------------------------------------------------
@@ -48,6 +48,8 @@ export function createVideoModeController(
let videoMode = false;
/** Track whether we've already added the local self-view tile. */
let localTileAdded = false;
let localScreenshareTileAdded = false;
const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
function showVideoGrid(): void {
if (videoMode) return;
@@ -61,6 +63,8 @@ export function createVideoModeController(
function showChat(): void {
if (!videoMode) return;
videoMode = false;
localTileAdded = false;
localScreenshareTileAdded = false;
slots.messagesSlot.style.display = "";
slots.typingSlot.style.display = "";
slots.inputSlot.style.display = "";
@@ -80,19 +84,19 @@ export function createVideoModeController(
return;
}
// Check if any camera is active
let anyCameraOn = voice.localCamera;
if (!anyCameraOn) {
// Check if any camera or screenshare is active
let anyVideoOn = voice.localCamera || voice.localScreenshare;
if (!anyVideoOn) {
for (const user of channelUsers.values()) {
if (user.camera) {
anyCameraOn = true;
if (user.camera || user.screenshare) {
anyVideoOn = true;
break;
}
}
}
if (anyCameraOn && !videoMode) {
if (anyVideoOn && !videoMode) {
showVideoGrid();
} else if (!anyCameraOn && videoMode) {
} else if (!anyVideoOn && videoMode) {
showChat();
}
@@ -116,10 +120,30 @@ export function createVideoModeController(
localTileAdded = false;
}
// Remove remote video tiles for users who turned off their camera
// Manage local screenshare self-view tile
const screenshareUserId = currentUserId + SCREENSHARE_TILE_ID_OFFSET;
if (voice.localScreenshare) {
if (!localScreenshareTileAdded) {
const localStream = getLocalScreenshareStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
screenshareUserId,
me?.username ? `${me.username} (Screen)` : "Your Screen",
localStream,
);
localScreenshareTileAdded = true;
}
}
} else {
videoGrid.removeStream(screenshareUserId);
localScreenshareTileAdded = false;
}
// Remove remote video tiles for users who turned off their camera or screenshare
if (channelUsers) {
for (const user of channelUsers.values()) {
if (!user.camera && user.userId !== currentUserId) {
if (!user.camera && !user.screenshare && user.userId !== currentUserId) {
videoGrid.removeStream(user.userId);
}
}
@@ -133,6 +157,7 @@ export function createVideoModeController(
function destroy(): void {
if (videoMode) showChat();
localTileAdded = false;
localScreenshareTileAdded = false;
}
return {
@@ -9,7 +9,6 @@ import {
voiceStore,
joinVoiceChannel,
leaveVoiceChannel,
setLocalScreenshare,
} from "@stores/voice.store";
import {
leaveVoice as voiceSessionLeave,
@@ -17,6 +16,8 @@ import {
setDeafened as voiceSessionSetDeafened,
enableCamera,
disableCamera,
enableScreenshare,
disableScreenshare,
} from "@lib/livekitSession";
const log = createLogger("voice-callbacks");
@@ -106,8 +107,14 @@ export function createVoiceWidgetCallbacks(
onScreenshareToggle: () => {
if (!limiters.voiceVideo.tryConsume()) return;
const next = !voiceStore.getState().localScreenshare;
setLocalScreenshare(next);
ws.send({ type: "voice_screenshare", payload: { enabled: next } });
const handleScreenshareError = (err: unknown) => {
log.error("Screenshare toggle failed", { error: String(err) });
};
if (next) {
enableScreenshare().catch(handleScreenshareError);
} else {
disableScreenshare().catch(handleScreenshareError);
}
},
};
}
@@ -16,6 +16,8 @@ const mockRoom = vi.hoisted(() => ({
},
remoteParticipants: new Map(),
switchActiveDevice: vi.fn().mockResolvedValue(undefined),
startAudio: vi.fn().mockResolvedValue(undefined),
canPlaybackAudio: true,
state: "connected" as string,
name: "test-room",
}));
@@ -40,6 +42,7 @@ vi.mock("@stores/voice.store", () => ({
setLocalMuted: vi.fn(),
setLocalDeafened: vi.fn(),
setLocalCamera: vi.fn(),
setLocalScreenshare: vi.fn(),
setSpeakers: vi.fn(),
}));
@@ -68,7 +71,7 @@ vi.mock("@lib/noise-suppression", () => ({
// Now import
import { parseUserId, LiveKitSession } from "../../src/lib/livekitSession";
import { setLocalMuted, setLocalDeafened, setLocalCamera } from "@stores/voice.store";
import { setLocalMuted, setLocalDeafened, setLocalCamera, setLocalScreenshare } from "@stores/voice.store";
describe("parseUserId", () => {
it("parses a valid user identity", () => {
@@ -189,6 +192,11 @@ describe("LiveKitSession", () => {
session.leaveVoice(false);
expect(setLocalCamera).toHaveBeenCalledWith(false);
});
it("calls setLocalScreenshare(false)", () => {
session.leaveVoice(false);
expect(setLocalScreenshare).toHaveBeenCalledWith(false);
});
});
describe("cleanupAll", () => {
@@ -32,6 +32,7 @@ vi.mock("@lib/livekitSession", () => ({
setVoiceSensitivity: vi.fn(),
setInputVolume: vi.fn(),
setOutputVolume: vi.fn(),
reapplyAudioProcessing: vi.fn().mockResolvedValue(undefined),
getSessionDebugInfo: vi.fn().mockReturnValue({}),
}));
@@ -4,9 +4,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mocks
// ---------------------------------------------------------------------------
const { mockVoiceStoreGetState, mockGetLocalCameraStream } = vi.hoisted(() => ({
const { mockVoiceStoreGetState, mockGetLocalCameraStream, mockGetLocalScreenshareStream } = vi.hoisted(() => ({
mockVoiceStoreGetState: vi.fn(),
mockGetLocalCameraStream: vi.fn((): MediaStream | null => null),
mockGetLocalScreenshareStream: vi.fn((): MediaStream | null => null),
}));
vi.mock("@stores/voice.store", () => ({
@@ -15,6 +16,7 @@ vi.mock("@stores/voice.store", () => ({
vi.mock("@lib/livekitSession", () => ({
getLocalCameraStream: mockGetLocalCameraStream,
getLocalScreenshareStream: mockGetLocalScreenshareStream,
}));
// ---------------------------------------------------------------------------
@@ -50,7 +52,7 @@ interface VoiceStateStub {
currentChannelId: number | null;
localCamera: boolean;
localScreenshare: boolean;
voiceUsers: Map<number, Map<number, { userId: number; camera: boolean; username: string }>>;
voiceUsers: Map<number, Map<number, { userId: number; camera: boolean; screenshare: boolean; username: string }>>;
}
function makeVoiceState(overrides: Partial<VoiceStateStub> = {}): VoiceStateStub {
@@ -72,6 +74,7 @@ describe("createVideoModeController", () => {
vi.clearAllMocks();
mockVoiceStoreGetState.mockReturnValue(makeVoiceState());
mockGetLocalCameraStream.mockReturnValue(null);
mockGetLocalScreenshareStream.mockReturnValue(null);
});
it("starts in chat mode", () => {
@@ -95,7 +98,7 @@ describe("createVideoModeController", () => {
});
it("switches to video when any camera is on", () => {
const users = new Map([[2, { userId: 2, camera: true, username: "bob" }]]);
const users = new Map([[2, { userId: 2, camera: true, screenshare: false, username: "bob" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
);
@@ -114,7 +117,7 @@ describe("createVideoModeController", () => {
});
it("switches back to chat when all cameras off", () => {
const users = new Map([[2, { userId: 2, camera: true, username: "bob" }]]);
const users = new Map([[2, { userId: 2, camera: true, screenshare: false, username: "bob" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
);
@@ -129,14 +132,14 @@ describe("createVideoModeController", () => {
expect(ctrl.isVideoMode()).toBe(true);
// All cameras off
users.set(2, { userId: 2, camera: false, username: "bob" });
users.set(2, { userId: 2, camera: false, screenshare: false, username: "bob" });
ctrl.checkVideoMode();
expect(ctrl.isVideoMode()).toBe(false);
expect(slots.messagesSlot.style.display).toBe("");
});
it("detects local camera as reason to show video", () => {
const users = new Map([[1, { userId: 1, camera: false, username: "me" }]]);
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
@@ -157,7 +160,7 @@ describe("createVideoModeController", () => {
it("adds local self-view tile when local camera is on", () => {
const fakeStream = {} as MediaStream;
mockGetLocalCameraStream.mockReturnValue(fakeStream);
const users = new Map([[1, { userId: 1, camera: false, username: "me" }]]);
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
@@ -178,7 +181,7 @@ describe("createVideoModeController", () => {
});
it("removes local tile when local camera is off", () => {
const users = new Map([[1, { userId: 1, camera: false, username: "me" }]]);
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
@@ -200,8 +203,8 @@ describe("createVideoModeController", () => {
it("removes remote tile when remote user turns off camera", () => {
const users = new Map([
[1, { userId: 1, camera: false, username: "me" }],
[2, { userId: 2, camera: false, username: "bob" }],
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
[2, { userId: 2, camera: false, screenshare: false, username: "bob" }],
]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
@@ -252,4 +255,99 @@ describe("createVideoModeController", () => {
expect(slots.messagesSlot.style.display).toBe("");
expect(slots.videoGridSlot.style.display).toBe("none");
});
it("switches to video when local screenshare is on (no camera)", () => {
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
localCamera: false,
localScreenshare: true,
voiceUsers: new Map([[10, users]]),
}),
);
const slots = makeSlots();
const ctrl = createVideoModeController({
slots,
videoGrid: makeVideoGrid(),
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
expect(ctrl.isVideoMode()).toBe(true);
expect(slots.messagesSlot.style.display).toBe("none");
expect(slots.videoGridSlot.style.display).toBe("block");
});
it("adds local screenshare self-view tile when local screenshare is on", () => {
const fakeStream = {} as MediaStream;
mockGetLocalScreenshareStream.mockReturnValue(fakeStream);
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({
currentChannelId: 10,
localCamera: false,
localScreenshare: true,
voiceUsers: new Map([[10, users]]),
}),
);
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
// screenshareUserId = currentUserId + 1_000_000 = 1 + 1_000_000 = 1_000_001
expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream);
});
it("removes local screenshare tile when screenshare is turned off", () => {
const users = new Map([[1, { userId: 1, camera: false, screenshare: false, username: "me" }]]);
// First call: screenshare on — tile added
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, localScreenshare: true, voiceUsers: new Map([[10, users]]) }),
);
const fakeStream = { getTracks: () => [] } as unknown as MediaStream;
mockGetLocalScreenshareStream.mockReturnValue(fakeStream);
const vg = makeVideoGrid();
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: vg,
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
expect(vg.addStream).toHaveBeenCalledWith(1_000_001, "me (Screen)", fakeStream);
// Second call: screenshare off — tile removed
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, localScreenshare: false, voiceUsers: new Map([[10, users]]) }),
);
ctrl.checkVideoMode();
expect(vg.removeStream).toHaveBeenCalledWith(1_000_001);
});
it("switches to video when a remote user has screenshare on", () => {
const users = new Map([
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
[2, { userId: 2, camera: false, screenshare: true, username: "bob" }],
]);
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ currentChannelId: 10, voiceUsers: new Map([[10, users]]) }),
);
const ctrl = createVideoModeController({
slots: makeSlots(),
videoGrid: makeVideoGrid(),
getCurrentUserId: () => 1,
});
ctrl.checkVideoMode();
expect(ctrl.isVideoMode()).toBe(true);
});
});
@@ -8,22 +8,24 @@ const {
mockVoiceStoreGetState,
mockJoinVoiceChannel,
mockLeaveVoiceChannel,
mockSetLocalScreenshare,
mockVoiceSessionLeave,
mockSetMuted,
mockSetDeafened,
mockEnableCamera,
mockDisableCamera,
mockEnableScreenshare,
mockDisableScreenshare,
} = vi.hoisted(() => ({
mockVoiceStoreGetState: vi.fn(),
mockJoinVoiceChannel: vi.fn(),
mockLeaveVoiceChannel: vi.fn(),
mockSetLocalScreenshare: vi.fn(),
mockVoiceSessionLeave: vi.fn(),
mockSetMuted: vi.fn(),
mockSetDeafened: vi.fn(),
mockEnableCamera: vi.fn(() => Promise.resolve()),
mockDisableCamera: vi.fn(() => Promise.resolve()),
mockEnableScreenshare: vi.fn(() => Promise.resolve()),
mockDisableScreenshare: vi.fn(() => Promise.resolve()),
}));
vi.mock("@lib/logger", () => ({
@@ -39,7 +41,6 @@ vi.mock("@stores/voice.store", () => ({
voiceStore: { getState: mockVoiceStoreGetState },
joinVoiceChannel: mockJoinVoiceChannel,
leaveVoiceChannel: mockLeaveVoiceChannel,
setLocalScreenshare: mockSetLocalScreenshare,
}));
vi.mock("@lib/livekitSession", () => ({
@@ -48,6 +49,8 @@ vi.mock("@lib/livekitSession", () => ({
setDeafened: mockSetDeafened,
enableCamera: mockEnableCamera,
disableCamera: mockDisableCamera,
enableScreenshare: mockEnableScreenshare,
disableScreenshare: mockDisableScreenshare,
}));
// ---------------------------------------------------------------------------
@@ -239,11 +242,8 @@ describe("createVoiceWidgetCallbacks", () => {
cbs.onScreenshareToggle();
expect(mockSetLocalScreenshare).toHaveBeenCalledWith(true);
expect(ws.send).toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: true },
});
expect(mockEnableScreenshare).toHaveBeenCalled();
expect(mockDisableScreenshare).not.toHaveBeenCalled();
});
it("disables screenshare when on", () => {
@@ -253,7 +253,8 @@ describe("createVoiceWidgetCallbacks", () => {
cbs.onScreenshareToggle();
expect(mockSetLocalScreenshare).toHaveBeenCalledWith(false);
expect(mockDisableScreenshare).toHaveBeenCalled();
expect(mockEnableScreenshare).not.toHaveBeenCalled();
});
it("respects video rate limiter", () => {
@@ -262,8 +263,8 @@ describe("createVoiceWidgetCallbacks", () => {
cbs.onScreenshareToggle();
expect(mockSetLocalScreenshare).not.toHaveBeenCalled();
expect(ws.send).not.toHaveBeenCalled();
expect(mockEnableScreenshare).not.toHaveBeenCalled();
expect(mockDisableScreenshare).not.toHaveBeenCalled();
});
});
});
@@ -237,6 +237,27 @@ describe("VoiceWidget", () => {
widget.destroy?.();
});
it("toggles screenshare active state based on store", () => {
setVoiceChannel(1, []);
voiceStore.setState((prev) => ({ ...prev, localScreenshare: true }));
const widget = createVoiceWidget({
onDisconnect: vi.fn(),
onMuteToggle: vi.fn(),
onDeafenToggle: vi.fn(),
onCameraToggle: vi.fn(),
onScreenshareToggle: vi.fn(),
});
widget.mount(container);
const screenshareBtn = container.querySelector('[aria-label="Screenshare"]') as HTMLButtonElement;
expect(screenshareBtn).not.toBeNull();
expect(screenshareBtn.classList.contains("active-ctrl")).toBe(true);
expect(screenshareBtn.getAttribute("aria-pressed")).toBe("true");
widget.destroy?.();
});
it("cleans up on destroy", () => {
const widget = createVoiceWidget({
onDisconnect: vi.fn(),