From 6ca18762cb9a0558cb2389657cba714d5f3574ec Mon Sep 17 00:00:00 2001 From: jevb Date: Thu, 26 Mar 2026 18:07:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20full=20screenshare=20support=20?= =?UTF-8?q?=E2=80=94=20button=20state,=20video=20grid,=20auto-reconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/VoiceWidget.ts | 10 +- Client/tauri-client/src/lib/livekitSession.ts | 125 +++++++++++++++++- Client/tauri-client/src/pages/MainPage.ts | 17 ++- .../pages/main-page/VideoModeController.ts | 45 +++++-- .../src/pages/main-page/VoiceCallbacks.ts | 13 +- .../tests/unit/livekit-session.test.ts | 10 +- .../tests/unit/settings-overlay.test.ts | 1 + .../tests/unit/video-mode-controller.test.ts | 118 +++++++++++++++-- .../tests/unit/voice-callbacks.test.ts | 23 ++-- .../tests/unit/voice-widget.test.ts | 21 +++ 10 files changed, 336 insertions(+), 47 deletions(-) diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts index cb6d9e16..5631ea1a 100644 --- a/Client/tauri-client/src/components/VoiceWidget.ts +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -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 }; diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 0a3d2a79..557b7ab7 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -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("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 { + 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("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 { + 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 { + 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 { 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 { 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); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index d8766a76..c6c26f68 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -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) { diff --git a/Client/tauri-client/src/pages/main-page/VideoModeController.ts b/Client/tauri-client/src/pages/main-page/VideoModeController.ts index 45006c64..89e2e8be 100644 --- a/Client/tauri-client/src/pages/main-page/VideoModeController.ts +++ b/Client/tauri-client/src/pages/main-page/VideoModeController.ts @@ -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 { diff --git a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts index 20281490..e5646718 100644 --- a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts +++ b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts @@ -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); + } }, }; } diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 89638b33..37dd7f9c 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -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", () => { diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index 80586d86..ec6047e3 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -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({}), })); diff --git a/Client/tauri-client/tests/unit/video-mode-controller.test.ts b/Client/tauri-client/tests/unit/video-mode-controller.test.ts index 86156a6b..46c3d1fb 100644 --- a/Client/tauri-client/tests/unit/video-mode-controller.test.ts +++ b/Client/tauri-client/tests/unit/video-mode-controller.test.ts @@ -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>; + voiceUsers: Map>; } function makeVoiceState(overrides: Partial = {}): 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); + }); }); diff --git a/Client/tauri-client/tests/unit/voice-callbacks.test.ts b/Client/tauri-client/tests/unit/voice-callbacks.test.ts index f6f2cbe6..7577854c 100644 --- a/Client/tauri-client/tests/unit/voice-callbacks.test.ts +++ b/Client/tauri-client/tests/unit/voice-callbacks.test.ts @@ -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(); }); }); }); diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts index 236f1cad..175fdb4d 100644 --- a/Client/tauri-client/tests/unit/voice-widget.test.ts +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -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(),