From f808a204e7380907093fe1f7aca428e4f353f4ef Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:16:15 +0100 Subject: [PATCH] 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. --- .../voice/ScreenShareSettingsPopover.tsx | 138 +++++++++++++ .../web/src/components/voice/StreamTile.tsx | 6 +- .../components/voice/VideoQualityPopover.tsx | 74 ------- .../src/components/voice/VoiceControlBar.tsx | 27 ++- .../src/components/voice/VoiceControls.tsx | 20 +- packages/web/src/hooks/useLiveKit.ts | 27 ++- packages/web/src/stores/voiceStore.ts | 34 +++- packages/web/src/utils/screenShare.ts | 184 +++++++++++------- 8 files changed, 313 insertions(+), 197 deletions(-) create mode 100644 packages/web/src/components/voice/ScreenShareSettingsPopover.tsx delete mode 100644 packages/web/src/components/voice/VideoQualityPopover.tsx diff --git a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx new file mode 100644 index 00000000..9267e835 --- /dev/null +++ b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx @@ -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(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 ( +
+
+ Stream Settings +
+ +
+ {/* Resolution */} +
+
+ Resolution +
+
+ {RESOLUTIONS.map((r) => ( + + ))} +
+
+ + {/* Frame Rate */} +
+
+ Frame Rate +
+
+ {FRAME_RATES.map((f) => ( + + ))} +
+
+ + {/* Content Mode */} +
+
+ Content Mode +
+
+ {MODES.map((m) => ( + + ))} +
+
+
+ + {/* Footer — computed stats */} +
+ + {formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)} + +
+
+ ); +} diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index 0982149c..c679a500 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -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" > - {useVoiceStore.getState().videoQuality} + {`${useVoiceStore.getState().screenShareConfig.height}p ${useVoiceStore.getState().screenShareConfig.fps}fps`} {qualityPopoverOpen && ( - setQualityPopoverOpen(false)} /> diff --git a/packages/web/src/components/voice/VideoQualityPopover.tsx b/packages/web/src/components/voice/VideoQualityPopover.tsx deleted file mode 100644 index 0c373b6e..00000000 --- a/packages/web/src/components/voice/VideoQualityPopover.tsx +++ /dev/null @@ -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(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 ( -
-
- Video Quality -
-
- {PRESETS.map((preset) => ( - - ))} -
-
- ); -} diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 657ef93d..7d00751f 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -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 }, - { - videoEncoding: preset.encoding, - simulcast: videoQuality === '1080p' || videoQuality === '720p' - } - ); - } else { - await room.localParticipant.setCameraEnabled(true); - } + await room.localParticipant.setCameraEnabled(true, + { resolution: CAMERA_PRESET.resolution }, + { + videoCodec: CAMERA_PRESET.codec, + videoEncoding: CAMERA_PRESET.encoding, + simulcast: false, + } + ); } else { await room.localParticipant.setCameraEnabled(false); } @@ -222,7 +217,7 @@ export function VoiceControlBar() { - setQualityOpen(false)} /> + setQualityOpen(false)} /> {/* Separator */} diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 73d8d2df..cd5b0d32 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -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() { - {/* Video Quality Popover */} - setShowVideoQuality(false)} + {/* Screen Share Settings Popover */} + setShowScreenShareSettings(false)} /> diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 7ed76b5e..24bff4be 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -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; } }; diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 30942d59..969e76f8 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -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; // 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; 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) => void; noiseSuppression: boolean; echoCancellation: boolean; autoGainControl: boolean; @@ -103,7 +109,7 @@ export const useVoiceStore = create()( 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()( 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()( }), { 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()( 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()( outputVolume: state.outputVolume, inputDeviceId: state.inputDeviceId, outputDeviceId: state.outputDeviceId, - videoQuality: state.videoQuality, + screenShareConfig: state.screenShareConfig, echoCancellation: state.echoCancellation, autoGainControl: state.autoGainControl, rnnoiseEnabled: state.rnnoiseEnabled, diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts index a907b88e..b11b4ce4 100644 --- a/packages/web/src/utils/screenShare.ts +++ b/packages/web/src/utils/screenShare.ts @@ -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 = { - '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> = { + 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 = { 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 { 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 { - 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 { } } -/** - * Stop screen sharing, restore AEC, reset store. - */ +// --------------------------------------------------------------------------- +// Stop screen sharing +// --------------------------------------------------------------------------- + export async function stopScreenShare(room: Room): Promise { try { await room.localParticipant.setScreenShareEnabled(false); @@ -155,25 +196,24 @@ export async function stopScreenShare(room: Room): Promise { 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 { 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 }); }