refactor: dynamic screen share engine with independent resolution/fps/mode axes

Replace rigid SCREEN_QUALITY_MAP (6 hardcoded VideoPreset strings) with a
builder function that computes bitrate, degradation preference, and content
hint from three independent axes (height, fps, content mode). Camera is
decoupled onto a fixed 720p30 preset so screen share changes no longer
affect camera quality. New ScreenShareSettingsPopover replaces the old
VideoQualityPopover with pill-style selectors. Store migrated to v5 with
backwards-compatible migration from videoQuality string.
This commit is contained in:
Jannis Braun
2026-02-24 02:16:15 +01:00
parent 9b0d319ab2
commit f808a204e7
8 changed files with 313 additions and 197 deletions
@@ -0,0 +1,138 @@
import React, { useRef, useEffect } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import type { ScreenShareConfig } from '../../stores/voiceStore';
import { buildScreenShareOptions } from '../../utils/screenShare';
interface ScreenShareSettingsPopoverProps {
open: boolean;
onClose: () => void;
}
const RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
{ value: 540, label: '540p' },
{ value: 720, label: '720p' },
{ value: 1080, label: '1080p' },
];
const FRAME_RATES: { value: ScreenShareConfig['fps']; label: string }[] = [
{ value: 30, label: '30' },
{ value: 45, label: '45' },
{ value: 60, label: '60' },
];
const MODES: { value: ScreenShareConfig['mode']; label: string }[] = [
{ value: 'gaming', label: 'Gaming' },
{ value: 'text', label: 'Text' },
];
function formatBitrate(bps: number): string {
return `${(bps / 1_000_000).toFixed(bps % 1_000_000 === 0 ? 0 : 1)} Mbps`;
}
function formatDegradation(pref: RTCDegradationPreference): string {
switch (pref) {
case 'maintain-resolution': return 'hold resolution';
case 'maintain-framerate': return 'hold framerate';
case 'balanced': return 'balanced';
default: return pref;
}
}
export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSettingsPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const config = useVoiceStore((s) => s.screenShareConfig);
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose]);
if (!open) return null;
const result = buildScreenShareOptions(config);
const pillBase = 'px-3 py-1.5 rounded-full text-[13px] font-medium transition-colors cursor-pointer select-none';
const pillSelected = 'bg-discord-blurple text-white';
const pillUnselected = 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#35373c]';
return (
<div
ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[260px] bg-[#1e1f22] rounded-lg shadow-lg border border-[#111214] z-50 overflow-hidden"
>
<div className="px-3 py-2 border-b border-[#111214]">
<span className="text-[14px] font-bold text-discord-text-primary">Stream Settings</span>
</div>
<div className="px-3 py-3 flex flex-col gap-3">
{/* Resolution */}
<div>
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-1.5">
Resolution
</div>
<div className="flex gap-1.5">
{RESOLUTIONS.map((r) => (
<button
key={r.value}
onClick={() => setConfig({ height: r.value })}
className={`${pillBase} ${config.height === r.value ? pillSelected : pillUnselected}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* Frame Rate */}
<div>
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-1.5">
Frame Rate
</div>
<div className="flex gap-1.5">
{FRAME_RATES.map((f) => (
<button
key={f.value}
onClick={() => setConfig({ fps: f.value })}
className={`${pillBase} ${config.fps === f.value ? pillSelected : pillUnselected}`}
>
{f.label}
</button>
))}
</div>
</div>
{/* Content Mode */}
<div>
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-1.5">
Content Mode
</div>
<div className="flex gap-1.5">
{MODES.map((m) => (
<button
key={m.value}
onClick={() => setConfig({ mode: m.value })}
className={`${pillBase} ${config.mode === m.value ? pillSelected : pillUnselected}`}
>
{m.label}
</button>
))}
</div>
</div>
</div>
{/* Footer — computed stats */}
<div className="px-3 py-2 border-t border-[#111214]">
<span className="text-[12px] text-discord-text-muted">
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
</span>
</div>
</div>
);
}
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
import { VideoQualityPopover } from './VideoQualityPopover';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
@@ -259,7 +259,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-discord-text-secondary hover:bg-discord-modifier-hover rounded transition-colors"
>
<span>{useVoiceStore.getState().videoQuality}</span>
<span>{`${useVoiceStore.getState().screenShareConfig.height}p ${useVoiceStore.getState().screenShareConfig.fps}fps`}</span>
<svg
width="12"
height="12"
@@ -270,7 +270,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
</svg>
</button>
{qualityPopoverOpen && (
<VideoQualityPopover
<ScreenShareSettingsPopover
open={qualityPopoverOpen}
onClose={() => setQualityPopoverOpen(false)}
/>
@@ -1,74 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
interface VideoQualityPopoverProps {
open: boolean;
onClose: () => void;
anchorRect?: DOMRect | null;
}
const PRESETS = [
{ value: '1080p60' as const, label: '1080p 60fps', desc: '1920x1080, 12000 kbps' },
{ value: '1080p' as const, label: '1080p 30fps', desc: '1920x1080, 8000 kbps' },
{ value: '720p60' as const, label: '720p 60fps', desc: '1280x720, 8000 kbps' },
{ value: '720p' as const, label: '720p 30fps', desc: '1280x720, 5000 kbps' },
{ value: '540p' as const, label: '540p 30fps', desc: '960x540, 2000 kbps' },
{ value: '360p' as const, label: '360p 30fps', desc: '640x360, 1000 kbps' },
] as const;
export function VideoQualityPopover({ open, onClose, anchorRect }: VideoQualityPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const setVideoQuality = useVoiceStore((s) => s.setVideoQuality);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose]);
if (!open) return null;
const handleSelect = async (quality: typeof videoQuality) => {
setVideoQuality(quality);
onClose();
};
return (
<div
ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[240px] bg-[#1e1f22] rounded-lg shadow-lg border border-[#111214] z-50 overflow-hidden"
>
<div className="px-3 py-2 border-b border-[#111214]">
<span className="text-[14px] font-bold text-discord-text-primary">Video Quality</span>
</div>
<div className="py-1">
{PRESETS.map((preset) => (
<button
key={preset.value}
onClick={() => handleSelect(preset.value)}
className={`w-full px-3 py-2 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors ${
videoQuality === preset.value ? 'text-discord-text-primary' : 'text-discord-text-secondary'
}`}
>
<div className="text-left">
<div className="text-[14px] font-medium">{preset.label}</div>
<div className="text-[12px] text-discord-text-muted">{preset.desc}</div>
</div>
{videoQuality === preset.value && (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0 ml-2">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</button>
))}
</div>
</div>
);
}
@@ -3,8 +3,8 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { VideoQualityPopover } from './VideoQualityPopover';
import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
const btnDefault = `${btnBase} bg-[#1e1f22] text-discord-text-secondary hover:bg-[#2b2d31] hover:text-discord-text-primary`;
@@ -60,19 +60,14 @@ export function VoiceControlBar() {
try {
const willEnable = !isCameraOn;
if (willEnable) {
const videoQuality = useVoiceStore.getState().videoQuality;
const preset = SCREEN_QUALITY_MAP[videoQuality];
if (preset) {
await room.localParticipant.setCameraEnabled(true,
{ resolution: preset.resolution },
{ resolution: CAMERA_PRESET.resolution },
{
videoEncoding: preset.encoding,
simulcast: videoQuality === '1080p' || videoQuality === '720p'
videoCodec: CAMERA_PRESET.codec,
videoEncoding: CAMERA_PRESET.encoding,
simulcast: false,
}
);
} else {
await room.localParticipant.setCameraEnabled(true);
}
} else {
await room.localParticipant.setCameraEnabled(false);
}
@@ -222,7 +217,7 @@ export function VoiceControlBar() {
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
<VideoQualityPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
</div>
{/* Separator */}
@@ -3,7 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { VideoQualityPopover } from './VideoQualityPopover';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
@@ -22,7 +22,7 @@ export function VoiceControls() {
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
const channels = useServerStore((s) => s.channels);
const [showVideoQuality, setShowVideoQuality] = useState(false);
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
@@ -109,7 +109,7 @@ export function VoiceControls() {
<button
onClick={() => {
setShowConnectionInfo(!showConnectionInfo);
if (!showConnectionInfo) setShowVideoQuality(false);
if (!showConnectionInfo) setShowScreenShareSettings(false);
}}
className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0 hover:brightness-125 transition-all`}
title="Connection Info"
@@ -188,11 +188,11 @@ export function VoiceControls() {
{/* Video Quality */}
<button
onClick={() => {
setShowVideoQuality(!showVideoQuality);
if (!showVideoQuality) setShowConnectionInfo(false);
setShowScreenShareSettings(!showScreenShareSettings);
if (!showScreenShareSettings) setShowConnectionInfo(false);
}}
className={`${btnBase} ${
showVideoQuality
showScreenShareSettings
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
: btnDefaultStyle
}`}
@@ -232,10 +232,10 @@ export function VoiceControls() {
</svg>
</button>
{/* Video Quality Popover */}
<VideoQualityPopover
open={showVideoQuality}
onClose={() => setShowVideoQuality(false)}
{/* Screen Share Settings Popover */}
<ScreenShareSettingsPopover
open={showScreenShareSettings}
onClose={() => setShowScreenShareSettings(false)}
/>
</div>
</>
+13 -14
View File
@@ -9,8 +9,6 @@ import {
RemoteAudioTrack,
ConnectionState,
ConnectionQuality,
VideoPresets,
VideoPreset,
LocalAudioTrack,
LocalTrackPublication,
} from 'livekit-client';
@@ -19,8 +17,9 @@ import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector';
import {
SCREEN_QUALITY_MAP,
AUTO_PRESET,
CAMERA_PRESET,
CAMERA_OVERDRIVE,
buildScreenShareOptions,
applyOverdrive,
startScreenShare,
stopScreenShare,
@@ -127,7 +126,7 @@ export function useLiveKit() {
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const screenShareConfig = useVoiceStore((s) => s.screenShareConfig);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
@@ -495,13 +494,12 @@ export function useLiveKit() {
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
if (!isCameraOn) {
const preset = SCREEN_QUALITY_MAP[videoQuality] || VideoPresets.h720;
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
setTimeout(() => { if (roomRef.current) applyOverdrive(roomRef.current, Track.Source.Camera, preset); }, 2000);
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: CAMERA_PRESET.resolution, frameRate: CAMERA_PRESET.encoding.maxFramerate }, { videoCodec: CAMERA_PRESET.codec, videoEncoding: CAMERA_PRESET.encoding, simulcast: false });
setTimeout(() => { if (roomRef.current) applyOverdrive(roomRef.current, Track.Source.Camera, CAMERA_OVERDRIVE); }, 2000);
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
updateParticipants();
}
}, [isCameraOn, videoQuality, updateParticipants]);
}, [isCameraOn, updateParticipants]);
const toggleScreenShare = useCallback(async () => {
if (!roomRef.current) return;
@@ -519,22 +517,23 @@ export function useLiveKit() {
useEffect(() => {
if (!room) return;
const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET;
const updateActiveTracks = async () => {
if (isScreenSharing) {
const opts = buildScreenShareOptions(screenShareConfig);
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.videoTrack) {
const mediaTrack = (screenPub.videoTrack as any).mediaStreamTrack as MediaStreamTrack;
if (mediaTrack) {
await mediaTrack.applyConstraints({ width: { ideal: preset.resolution.width }, height: { ideal: preset.resolution.height }, frameRate: { ideal: preset.encoding.maxFramerate } });
await mediaTrack.applyConstraints({ width: { ideal: opts.capture.width }, height: { ideal: opts.capture.height }, frameRate: { ideal: opts.capture.frameRate } });
mediaTrack.contentHint = opts.contentHint;
}
await applyOverdrive(room, Track.Source.ScreenShare, preset);
await applyOverdrive(room, Track.Source.ScreenShare, opts.overdrive);
}
}
if (isCameraOn) { await applyOverdrive(room, Track.Source.Camera, preset); }
if (isCameraOn) { await applyOverdrive(room, Track.Source.Camera, CAMERA_OVERDRIVE); }
};
updateActiveTracks().catch(() => {});
}, [room, videoQuality, isScreenSharing, isCameraOn]);
}, [room, screenShareConfig, isScreenSharing, isCameraOn]);
useEffect(() => {
return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } };
+26 -8
View File
@@ -3,6 +3,12 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { ParticipantInfo } from '../hooks/useLiveKit';
import { AudioManager } from '../audio/AudioManager';
export interface ScreenShareConfig {
height: 1080 | 720 | 540;
fps: 60 | 45 | 30;
mode: 'gaming' | 'text';
}
interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds
currentVoiceChannelId: string | null;
@@ -21,7 +27,7 @@ interface VoiceState {
inputDeviceId: string;
outputDeviceId: string;
focusedParticipantId: string | null;
videoQuality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p';
screenShareConfig: ScreenShareConfig;
// Per-participant volume (userId → 0-200, 100 = default)
participantVolumes: Map<string, number>;
setParticipantVolume: (userId: string, volume: number) => void;
@@ -64,7 +70,7 @@ interface VoiceState {
toggleScreenShare: () => void;
toggleDeafen: () => void;
setFocusedParticipant: (id: string | null) => void;
setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
setScreenShareConfig: (config: Partial<ScreenShareConfig>) => void;
noiseSuppression: boolean;
echoCancellation: boolean;
autoGainControl: boolean;
@@ -103,7 +109,7 @@ export const useVoiceStore = create<VoiceState>()(
inputDeviceId: 'default',
outputDeviceId: 'default',
focusedParticipantId: null,
videoQuality: '720p60',
screenShareConfig: { height: 720, fps: 60, mode: 'gaming' },
participantVolumes: new Map(),
setParticipantVolume: (userId, volume) => {
set((state) => {
@@ -229,7 +235,9 @@ export const useVoiceStore = create<VoiceState>()(
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }),
setScreenShareConfig: (config) => set((state) => ({
screenShareConfig: { ...state.screenShareConfig, ...config },
})),
noiseSuppression: true,
echoCancellation: true,
autoGainControl: false,
@@ -315,7 +323,7 @@ export const useVoiceStore = create<VoiceState>()(
}),
{
name: 'opencord-voice-settings',
version: 4,
version: 5,
migrate: (persistedState: any, version: number) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
@@ -325,11 +333,21 @@ export const useVoiceStore = create<VoiceState>()(
persistedState.autoGainControl = false;
}
if (version < 4) {
// v4: RNNoise on by default, browser NS is no longer user-configurable
// (AudioManager uses it as automatic fallback when RNNoise is off)
persistedState.rnnoiseEnabled = true;
persistedState.noiseSuppression = true;
}
if (version < 5) {
const vq = persistedState.videoQuality as string | undefined;
let height: 1080 | 720 | 540 = 720;
let fps: 60 | 45 | 30 = 60;
if (vq) {
if (vq.startsWith('1080')) height = 1080;
else if (vq.startsWith('540') || vq.startsWith('360')) height = 540;
fps = vq.endsWith('60') ? 60 : 30;
}
persistedState.screenShareConfig = { height, fps, mode: 'gaming' };
delete persistedState.videoQuality;
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
@@ -344,7 +362,7 @@ export const useVoiceStore = create<VoiceState>()(
outputVolume: state.outputVolume,
inputDeviceId: state.inputDeviceId,
outputDeviceId: state.outputDeviceId,
videoQuality: state.videoQuality,
screenShareConfig: state.screenShareConfig,
echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl,
rnnoiseEnabled: state.rnnoiseEnabled,
+112 -72
View File
@@ -1,34 +1,87 @@
import { Room, Track, VideoPreset } from 'livekit-client';
import { Room, Track } from 'livekit-client';
import { useVoiceStore } from '../stores/voiceStore';
import type { ScreenShareConfig } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
import { wsSend } from '../hooks/useWebSocket';
/**
* Canonical quality presets — single source of truth.
* Used by all screen share entry points, camera controls, and VideoQualityPopover.
*/
export const SCREEN_QUALITY_MAP: Record<string, VideoPreset> = {
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
'540p': new VideoPreset(960, 540, 2_000_000, 30),
'360p': new VideoPreset(640, 360, 1_000_000, 30),
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface OverdriveOptions {
maxBitrate: number;
maxFramerate: number;
minBitrate: number;
degradationPreference: RTCDegradationPreference;
}
export interface ScreenShareBuildResult {
capture: { width: number; height: number; frameRate: number };
publish: { videoCodec: 'h264'; videoEncoding: { maxBitrate: number; maxFramerate: number }; simulcast: false };
overdrive: OverdriveOptions;
contentHint: 'motion' | 'detail';
}
// ---------------------------------------------------------------------------
// Camera preset (fixed 720p30 H264, decoupled from screen share)
// ---------------------------------------------------------------------------
export const CAMERA_PRESET = {
resolution: { width: 1280, height: 720 },
encoding: { maxBitrate: 2_000_000, maxFramerate: 30 },
codec: 'h264' as const,
} as const;
export const CAMERA_OVERDRIVE: OverdriveOptions = {
maxBitrate: 2_000_000,
maxFramerate: 30,
minBitrate: 0,
degradationPreference: 'maintain-framerate',
};
export const AUTO_PRESET = SCREEN_QUALITY_MAP['720p60']!;
// ---------------------------------------------------------------------------
// Screen share builder — three independent axes → computed result
// ---------------------------------------------------------------------------
const BITRATE_MATRIX: Record<number, Record<number, number>> = {
1080: { 60: 12_000_000, 45: 10_000_000, 30: 8_000_000 },
720: { 60: 6_000_000, 45: 5_000_000, 30: 4_000_000 },
540: { 60: 3_000_000, 45: 2_500_000, 30: 2_000_000 },
};
const WIDTH_MAP: Record<number, number> = { 1080: 1920, 720: 1280, 540: 960 };
export function buildScreenShareOptions(config: ScreenShareConfig): ScreenShareBuildResult {
const { height, fps, mode } = config;
const width = WIDTH_MAP[height]!;
const maxBitrate = BITRATE_MATRIX[height]![fps]!;
const minBitrate = Math.round(maxBitrate * 0.25);
return {
capture: { width, height, frameRate: fps },
publish: {
videoCodec: 'h264',
videoEncoding: { maxBitrate, maxFramerate: fps },
simulcast: false,
},
overdrive: {
maxBitrate,
maxFramerate: fps,
minBitrate,
degradationPreference: mode === 'text' ? 'maintain-resolution' : 'balanced',
},
contentHint: mode === 'text' ? 'detail' : 'motion',
};
}
// ---------------------------------------------------------------------------
// Overdrive — forces bitrate/resolution/framerate on RTCRtpSender
// ---------------------------------------------------------------------------
/**
* Apply overdrive hammer to a published track — forces bitrate/resolution/framerate
* directly on the RTCRtpSender, bypassing LiveKit's conservative defaults.
*
* Screen share: uses 'maintain-framerate' (gaming: hold 60fps, allow temporary quality drops)
* Camera: uses 'maintain-framerate' (smooth face motion matters more than sharpness)
*/
export async function applyOverdrive(
room: Room,
source: Track.Source,
preset: VideoPreset,
options: OverdriveOptions,
): Promise<void> {
try {
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
@@ -45,16 +98,12 @@ export async function applyOverdrive(
const params = sender.getParameters();
if (!params.encodings?.[0]) return;
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
params.encodings[0].maxBitrate = options.maxBitrate;
params.encodings[0].maxFramerate = options.maxFramerate;
params.encodings[0].networkPriority = 'high';
const isScreenShare = source === Track.Source.ScreenShare;
if (isScreenShare) {
(params as any).degradationPreference = 'maintain-framerate';
(params.encodings[0] as any).minBitrate = 2_000_000;
} else {
(params as any).degradationPreference = 'maintain-framerate';
(params as any).degradationPreference = options.degradationPreference;
if (options.minBitrate > 0) {
(params.encodings[0] as any).minBitrate = options.minBitrate;
}
await sender.setParameters(params);
@@ -63,75 +112,66 @@ export async function applyOverdrive(
}
}
/**
* Start screen sharing at target quality from the beginning.
* Reads videoQuality from store at call time — no stale closures.
*/
// ---------------------------------------------------------------------------
// Start screen sharing
// ---------------------------------------------------------------------------
export async function startScreenShare(room: Room): Promise<boolean> {
const { videoQuality } = useVoiceStore.getState();
const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET;
const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
try {
// NOTE: We do NOT call AudioManager.setScreenShareActive(true) before the
// browser picker. The picker suspends getUserMedia while its secure overlay
// is open — if we killed the mic stream here (to rebuild without AEC), the
// mic would stay dead until the user picks a screen (5-30s of silence).
// Instead we defer the AEC toggle to after the track is acquired.
const track = await room.localParticipant.setScreenShareEnabled(true, {
audio: true,
resolution: preset.resolution,
resolution: { width: opts.capture.width, height: opts.capture.height },
// @ts-ignore — LiveKit accepts frameRate at capture level
frameRate: preset.encoding.maxFramerate,
frameRate: opts.capture.frameRate,
}, {
videoCodec: 'h264',
videoEncoding: preset.encoding,
videoEncoding: opts.publish.videoEncoding,
simulcast: false,
} as any);
if (!track) {
// User cancelled the screen picker
return false;
}
// NOW that the track is acquired and published, rebuild the mic without AEC.
// Chrome's AEC uses screen share audio as a reference signal and ducks the mic;
// this severs that link. The mic is dead for ~50ms during the rebuild — imperceptible.
// Rebuild mic without AEC now that screen share is acquired
AudioManager.getInstance().setScreenShareActive(true);
// Tell the encoder to optimize for motion (more P-frames, fewer I-frames)
// Must be set BEFORE the overdrive timer so the encoder knows from frame 1
// Set content hint from builder (motion for gaming, detail for text)
const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
screenPub.track.mediaStreamTrack.contentHint = 'motion';
screenPub.track.mediaStreamTrack.contentHint = opts.contentHint;
}
// Update store — screen share is now active
useVoiceStore.setState({ isScreenSharing: true });
// Overdrive at 2s — after WebRTC finishes negotiation
setTimeout(async () => {
// Read state fresh at timer fire — no stale closure
if (!useVoiceStore.getState().isScreenSharing) return;
const currentPreset = SCREEN_QUALITY_MAP[useVoiceStore.getState().videoQuality] || AUTO_PRESET;
// Rebuild from fresh store state — no stale closures
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
const screenPub = room.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
await screenPub.track.mediaStreamTrack.applyConstraints({
width: { ideal: currentPreset.resolution.width },
height: { ideal: currentPreset.resolution.height },
frameRate: { ideal: currentPreset.encoding.maxFramerate, min: 15 },
width: { ideal: freshOpts.capture.width },
height: { ideal: freshOpts.capture.height },
frameRate: { ideal: freshOpts.capture.frameRate, min: 15 },
});
// Re-assert contentHint (LiveKit may strip it during renegotiation)
screenPub.track.mediaStreamTrack.contentHint = freshOpts.contentHint;
}
await applyOverdrive(room, Track.Source.ScreenShare, currentPreset);
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
}, 2000);
// Second overdrive at 5s — safety net for slow BWE convergence
setTimeout(async () => {
if (!useVoiceStore.getState().isScreenSharing) return;
const currentPreset = SCREEN_QUALITY_MAP[useVoiceStore.getState().videoQuality] || AUTO_PRESET;
await applyOverdrive(room, Track.Source.ScreenShare, currentPreset);
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
}, 5000);
return true;
@@ -142,9 +182,10 @@ export async function startScreenShare(room: Room): Promise<boolean> {
}
}
/**
* Stop screen sharing, restore AEC, reset store.
*/
// ---------------------------------------------------------------------------
// Stop screen sharing
// ---------------------------------------------------------------------------
export async function stopScreenShare(room: Room): Promise<void> {
try {
await room.localParticipant.setScreenShareEnabled(false);
@@ -155,25 +196,24 @@ export async function stopScreenShare(room: Room): Promise<void> {
useVoiceStore.setState({ isScreenSharing: false });
}
/**
* Change the screen share source — stops current stream, re-triggers picker.
*/
// ---------------------------------------------------------------------------
// Change screen share source — stops current stream, re-triggers picker
// ---------------------------------------------------------------------------
export async function changeScreenShare(room: Room): Promise<void> {
await room.localParticipant.setScreenShareEnabled(false);
// Small delay then re-start to re-trigger the source picker
setTimeout(async () => {
await startScreenShare(room);
}, 200);
}
/**
* Called from LocalTrackUnpublished handler to handle OS-level "Stop sharing".
* Resets store state and restores AEC without trying to unpublish (already done).
*/
// ---------------------------------------------------------------------------
// OS-level "Stop sharing" handler
// ---------------------------------------------------------------------------
export function handleScreenShareUnpublished(): void {
AudioManager.getInstance().setScreenShareActive(false);
useVoiceStore.setState({ isScreenSharing: false });
// Broadcast updated state via WebSocket — OS "Stop Sharing" bypasses our UI
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });
}