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