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
+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 });
}