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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user