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:
@@ -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 { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
||||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
|
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"
|
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
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
@@ -270,7 +270,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{qualityPopoverOpen && (
|
{qualityPopoverOpen && (
|
||||||
<VideoQualityPopover
|
<ScreenShareSettingsPopover
|
||||||
open={qualityPopoverOpen}
|
open={qualityPopoverOpen}
|
||||||
onClose={() => setQualityPopoverOpen(false)}
|
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 { useUIStore } from '../../stores/uiStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
|
||||||
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
|
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`;
|
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 {
|
try {
|
||||||
const willEnable = !isCameraOn;
|
const willEnable = !isCameraOn;
|
||||||
if (willEnable) {
|
if (willEnable) {
|
||||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
|
||||||
const preset = SCREEN_QUALITY_MAP[videoQuality];
|
|
||||||
if (preset) {
|
|
||||||
await room.localParticipant.setCameraEnabled(true,
|
await room.localParticipant.setCameraEnabled(true,
|
||||||
{ resolution: preset.resolution },
|
{ resolution: CAMERA_PRESET.resolution },
|
||||||
{
|
{
|
||||||
videoEncoding: preset.encoding,
|
videoCodec: CAMERA_PRESET.codec,
|
||||||
simulcast: videoQuality === '1080p' || videoQuality === '720p'
|
videoEncoding: CAMERA_PRESET.encoding,
|
||||||
|
simulcast: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
await room.localParticipant.setCameraEnabled(true);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setCameraEnabled(false);
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<VideoQualityPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||||
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function VoiceControls() {
|
|||||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||||
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
|
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
|
||||||
const channels = useServerStore((s) => s.channels);
|
const channels = useServerStore((s) => s.channels);
|
||||||
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
|
||||||
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
||||||
|
|
||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
@@ -109,7 +109,7 @@ export function VoiceControls() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowConnectionInfo(!showConnectionInfo);
|
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`}
|
className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0 hover:brightness-125 transition-all`}
|
||||||
title="Connection Info"
|
title="Connection Info"
|
||||||
@@ -188,11 +188,11 @@ export function VoiceControls() {
|
|||||||
{/* Video Quality */}
|
{/* Video Quality */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowVideoQuality(!showVideoQuality);
|
setShowScreenShareSettings(!showScreenShareSettings);
|
||||||
if (!showVideoQuality) setShowConnectionInfo(false);
|
if (!showScreenShareSettings) setShowConnectionInfo(false);
|
||||||
}}
|
}}
|
||||||
className={`${btnBase} ${
|
className={`${btnBase} ${
|
||||||
showVideoQuality
|
showScreenShareSettings
|
||||||
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
||||||
: btnDefaultStyle
|
: btnDefaultStyle
|
||||||
}`}
|
}`}
|
||||||
@@ -232,10 +232,10 @@ export function VoiceControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Video Quality Popover */}
|
{/* Screen Share Settings Popover */}
|
||||||
<VideoQualityPopover
|
<ScreenShareSettingsPopover
|
||||||
open={showVideoQuality}
|
open={showScreenShareSettings}
|
||||||
onClose={() => setShowVideoQuality(false)}
|
onClose={() => setShowScreenShareSettings(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import {
|
|||||||
RemoteAudioTrack,
|
RemoteAudioTrack,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
ConnectionQuality,
|
ConnectionQuality,
|
||||||
VideoPresets,
|
|
||||||
VideoPreset,
|
|
||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
@@ -19,8 +17,9 @@ import { useVoiceStore } from '../stores/voiceStore';
|
|||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
||||||
import {
|
import {
|
||||||
SCREEN_QUALITY_MAP,
|
CAMERA_PRESET,
|
||||||
AUTO_PRESET,
|
CAMERA_OVERDRIVE,
|
||||||
|
buildScreenShareOptions,
|
||||||
applyOverdrive,
|
applyOverdrive,
|
||||||
startScreenShare,
|
startScreenShare,
|
||||||
stopScreenShare,
|
stopScreenShare,
|
||||||
@@ -127,7 +126,7 @@ export function useLiveKit() {
|
|||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
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 voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||||
@@ -495,13 +494,12 @@ export function useLiveKit() {
|
|||||||
const toggleCamera = useCallback(async () => {
|
const toggleCamera = useCallback(async () => {
|
||||||
if (roomRef.current) {
|
if (roomRef.current) {
|
||||||
if (!isCameraOn) {
|
if (!isCameraOn) {
|
||||||
const preset = SCREEN_QUALITY_MAP[videoQuality] || VideoPresets.h720;
|
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 });
|
||||||
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, CAMERA_OVERDRIVE); }, 2000);
|
||||||
setTimeout(() => { if (roomRef.current) applyOverdrive(roomRef.current, Track.Source.Camera, preset); }, 2000);
|
|
||||||
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
|
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
}
|
}
|
||||||
}, [isCameraOn, videoQuality, updateParticipants]);
|
}, [isCameraOn, updateParticipants]);
|
||||||
|
|
||||||
const toggleScreenShare = useCallback(async () => {
|
const toggleScreenShare = useCallback(async () => {
|
||||||
if (!roomRef.current) return;
|
if (!roomRef.current) return;
|
||||||
@@ -519,22 +517,23 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
|
||||||
const updateActiveTracks = async () => {
|
const updateActiveTracks = async () => {
|
||||||
if (isScreenSharing) {
|
if (isScreenSharing) {
|
||||||
|
const opts = buildScreenShareOptions(screenShareConfig);
|
||||||
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||||
if (screenPub?.videoTrack) {
|
if (screenPub?.videoTrack) {
|
||||||
const mediaTrack = (screenPub.videoTrack as any).mediaStreamTrack as MediaStreamTrack;
|
const mediaTrack = (screenPub.videoTrack as any).mediaStreamTrack as MediaStreamTrack;
|
||||||
if (mediaTrack) {
|
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(() => {});
|
updateActiveTracks().catch(() => {});
|
||||||
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
}, [room, screenShareConfig, isScreenSharing, isCameraOn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } };
|
return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } };
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
|
|
||||||
|
export interface ScreenShareConfig {
|
||||||
|
height: 1080 | 720 | 540;
|
||||||
|
fps: 60 | 45 | 30;
|
||||||
|
mode: 'gaming' | 'text';
|
||||||
|
}
|
||||||
|
|
||||||
interface VoiceState {
|
interface VoiceState {
|
||||||
voiceUsers: Map<string, string[]>; // channelId → userIds
|
voiceUsers: Map<string, string[]>; // channelId → userIds
|
||||||
currentVoiceChannelId: string | null;
|
currentVoiceChannelId: string | null;
|
||||||
@@ -21,7 +27,7 @@ interface VoiceState {
|
|||||||
inputDeviceId: string;
|
inputDeviceId: string;
|
||||||
outputDeviceId: string;
|
outputDeviceId: string;
|
||||||
focusedParticipantId: string | null;
|
focusedParticipantId: string | null;
|
||||||
videoQuality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p';
|
screenShareConfig: ScreenShareConfig;
|
||||||
// Per-participant volume (userId → 0-200, 100 = default)
|
// Per-participant volume (userId → 0-200, 100 = default)
|
||||||
participantVolumes: Map<string, number>;
|
participantVolumes: Map<string, number>;
|
||||||
setParticipantVolume: (userId: string, volume: number) => void;
|
setParticipantVolume: (userId: string, volume: number) => void;
|
||||||
@@ -64,7 +70,7 @@ interface VoiceState {
|
|||||||
toggleScreenShare: () => void;
|
toggleScreenShare: () => void;
|
||||||
toggleDeafen: () => void;
|
toggleDeafen: () => void;
|
||||||
setFocusedParticipant: (id: string | null) => void;
|
setFocusedParticipant: (id: string | null) => void;
|
||||||
setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
|
setScreenShareConfig: (config: Partial<ScreenShareConfig>) => void;
|
||||||
noiseSuppression: boolean;
|
noiseSuppression: boolean;
|
||||||
echoCancellation: boolean;
|
echoCancellation: boolean;
|
||||||
autoGainControl: boolean;
|
autoGainControl: boolean;
|
||||||
@@ -103,7 +109,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
inputDeviceId: 'default',
|
inputDeviceId: 'default',
|
||||||
outputDeviceId: 'default',
|
outputDeviceId: 'default',
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
videoQuality: '720p60',
|
screenShareConfig: { height: 720, fps: 60, mode: 'gaming' },
|
||||||
participantVolumes: new Map(),
|
participantVolumes: new Map(),
|
||||||
setParticipantVolume: (userId, volume) => {
|
setParticipantVolume: (userId, volume) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -229,7 +235,9 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
||||||
|
|
||||||
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
|
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
|
||||||
setVideoQuality: (quality) => set({ videoQuality: quality }),
|
setScreenShareConfig: (config) => set((state) => ({
|
||||||
|
screenShareConfig: { ...state.screenShareConfig, ...config },
|
||||||
|
})),
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
autoGainControl: false,
|
autoGainControl: false,
|
||||||
@@ -315,7 +323,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'opencord-voice-settings',
|
name: 'opencord-voice-settings',
|
||||||
version: 4,
|
version: 5,
|
||||||
migrate: (persistedState: any, version: number) => {
|
migrate: (persistedState: any, version: number) => {
|
||||||
if (version === 0) {
|
if (version === 0) {
|
||||||
persistedState.streamAttenuationEnabled = false;
|
persistedState.streamAttenuationEnabled = false;
|
||||||
@@ -325,11 +333,21 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
persistedState.autoGainControl = false;
|
persistedState.autoGainControl = false;
|
||||||
}
|
}
|
||||||
if (version < 4) {
|
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.rnnoiseEnabled = true;
|
||||||
persistedState.noiseSuppression = 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;
|
return persistedState;
|
||||||
},
|
},
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
@@ -344,7 +362,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
outputVolume: state.outputVolume,
|
outputVolume: state.outputVolume,
|
||||||
inputDeviceId: state.inputDeviceId,
|
inputDeviceId: state.inputDeviceId,
|
||||||
outputDeviceId: state.outputDeviceId,
|
outputDeviceId: state.outputDeviceId,
|
||||||
videoQuality: state.videoQuality,
|
screenShareConfig: state.screenShareConfig,
|
||||||
echoCancellation: state.echoCancellation,
|
echoCancellation: state.echoCancellation,
|
||||||
autoGainControl: state.autoGainControl,
|
autoGainControl: state.autoGainControl,
|
||||||
rnnoiseEnabled: state.rnnoiseEnabled,
|
rnnoiseEnabled: state.rnnoiseEnabled,
|
||||||
|
|||||||
@@ -1,34 +1,87 @@
|
|||||||
import { Room, Track, VideoPreset } from 'livekit-client';
|
import { Room, Track } from 'livekit-client';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
import type { ScreenShareConfig } from '../stores/voiceStore';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { wsSend } from '../hooks/useWebSocket';
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Canonical quality presets — single source of truth.
|
// Types
|
||||||
* Used by all screen share entry points, camera controls, and VideoQualityPopover.
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
export const SCREEN_QUALITY_MAP: Record<string, VideoPreset> = {
|
export interface OverdriveOptions {
|
||||||
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
|
maxBitrate: number;
|
||||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
maxFramerate: number;
|
||||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
minBitrate: number;
|
||||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
degradationPreference: RTCDegradationPreference;
|
||||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
}
|
||||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
|
||||||
|
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(
|
export async function applyOverdrive(
|
||||||
room: Room,
|
room: Room,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
preset: VideoPreset,
|
options: OverdriveOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
||||||
@@ -45,16 +98,12 @@ export async function applyOverdrive(
|
|||||||
const params = sender.getParameters();
|
const params = sender.getParameters();
|
||||||
if (!params.encodings?.[0]) return;
|
if (!params.encodings?.[0]) return;
|
||||||
|
|
||||||
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
|
params.encodings[0].maxBitrate = options.maxBitrate;
|
||||||
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
|
params.encodings[0].maxFramerate = options.maxFramerate;
|
||||||
params.encodings[0].networkPriority = 'high';
|
params.encodings[0].networkPriority = 'high';
|
||||||
|
(params as any).degradationPreference = options.degradationPreference;
|
||||||
const isScreenShare = source === Track.Source.ScreenShare;
|
if (options.minBitrate > 0) {
|
||||||
if (isScreenShare) {
|
(params.encodings[0] as any).minBitrate = options.minBitrate;
|
||||||
(params as any).degradationPreference = 'maintain-framerate';
|
|
||||||
(params.encodings[0] as any).minBitrate = 2_000_000;
|
|
||||||
} else {
|
|
||||||
(params as any).degradationPreference = 'maintain-framerate';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sender.setParameters(params);
|
await sender.setParameters(params);
|
||||||
@@ -63,75 +112,66 @@ export async function applyOverdrive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Start screen sharing at target quality from the beginning.
|
// Start screen sharing
|
||||||
* Reads videoQuality from store at call time — no stale closures.
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
export async function startScreenShare(room: Room): Promise<boolean> {
|
export async function startScreenShare(room: Room): Promise<boolean> {
|
||||||
const { videoQuality } = useVoiceStore.getState();
|
const opts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
|
||||||
const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
|
||||||
|
|
||||||
try {
|
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, {
|
const track = await room.localParticipant.setScreenShareEnabled(true, {
|
||||||
audio: true,
|
audio: true,
|
||||||
resolution: preset.resolution,
|
resolution: { width: opts.capture.width, height: opts.capture.height },
|
||||||
// @ts-ignore — LiveKit accepts frameRate at capture level
|
// @ts-ignore — LiveKit accepts frameRate at capture level
|
||||||
frameRate: preset.encoding.maxFramerate,
|
frameRate: opts.capture.frameRate,
|
||||||
}, {
|
}, {
|
||||||
videoCodec: 'h264',
|
videoCodec: 'h264',
|
||||||
videoEncoding: preset.encoding,
|
videoEncoding: opts.publish.videoEncoding,
|
||||||
simulcast: false,
|
simulcast: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
// User cancelled the screen picker
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOW that the track is acquired and published, rebuild the mic without AEC.
|
// Rebuild mic without AEC now that screen share is acquired
|
||||||
// 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.
|
|
||||||
AudioManager.getInstance().setScreenShareActive(true);
|
AudioManager.getInstance().setScreenShareActive(true);
|
||||||
|
|
||||||
// Tell the encoder to optimize for motion (more P-frames, fewer I-frames)
|
// Set content hint from builder (motion for gaming, detail for text)
|
||||||
// Must be set BEFORE the overdrive timer so the encoder knows from frame 1
|
|
||||||
const screenPub = room.localParticipant.getTrackPublications()
|
const screenPub = room.localParticipant.getTrackPublications()
|
||||||
.find(p => p.source === Track.Source.ScreenShare);
|
.find(p => p.source === Track.Source.ScreenShare);
|
||||||
if (screenPub?.track?.mediaStreamTrack) {
|
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 });
|
useVoiceStore.setState({ isScreenSharing: true });
|
||||||
|
|
||||||
// Overdrive at 2s — after WebRTC finishes negotiation
|
// Overdrive at 2s — after WebRTC finishes negotiation
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// Read state fresh at timer fire — no stale closure
|
|
||||||
if (!useVoiceStore.getState().isScreenSharing) return;
|
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()
|
const screenPub = room.localParticipant.getTrackPublications()
|
||||||
.find(p => p.source === Track.Source.ScreenShare);
|
.find(p => p.source === Track.Source.ScreenShare);
|
||||||
if (screenPub?.track?.mediaStreamTrack) {
|
if (screenPub?.track?.mediaStreamTrack) {
|
||||||
await screenPub.track.mediaStreamTrack.applyConstraints({
|
await screenPub.track.mediaStreamTrack.applyConstraints({
|
||||||
width: { ideal: currentPreset.resolution.width },
|
width: { ideal: freshOpts.capture.width },
|
||||||
height: { ideal: currentPreset.resolution.height },
|
height: { ideal: freshOpts.capture.height },
|
||||||
frameRate: { ideal: currentPreset.encoding.maxFramerate, min: 15 },
|
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);
|
}, 2000);
|
||||||
|
|
||||||
// Second overdrive at 5s — safety net for slow BWE convergence
|
// Second overdrive at 5s — safety net for slow BWE convergence
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (!useVoiceStore.getState().isScreenSharing) return;
|
if (!useVoiceStore.getState().isScreenSharing) return;
|
||||||
const currentPreset = SCREEN_QUALITY_MAP[useVoiceStore.getState().videoQuality] || AUTO_PRESET;
|
const freshOpts = buildScreenShareOptions(useVoiceStore.getState().screenShareConfig);
|
||||||
await applyOverdrive(room, Track.Source.ScreenShare, currentPreset);
|
await applyOverdrive(room, Track.Source.ScreenShare, freshOpts.overdrive);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return true;
|
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> {
|
export async function stopScreenShare(room: Room): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
@@ -155,25 +196,24 @@ export async function stopScreenShare(room: Room): Promise<void> {
|
|||||||
useVoiceStore.setState({ isScreenSharing: false });
|
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> {
|
export async function changeScreenShare(room: Room): Promise<void> {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
// Small delay then re-start to re-trigger the source picker
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await startScreenShare(room);
|
await startScreenShare(room);
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Called from LocalTrackUnpublished handler to handle OS-level "Stop sharing".
|
// OS-level "Stop sharing" handler
|
||||||
* Resets store state and restores AEC without trying to unpublish (already done).
|
// ---------------------------------------------------------------------------
|
||||||
*/
|
|
||||||
export function handleScreenShareUnpublished(): void {
|
export function handleScreenShareUnpublished(): void {
|
||||||
AudioManager.getInstance().setScreenShareActive(false);
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
useVoiceStore.setState({ isScreenSharing: false });
|
useVoiceStore.setState({ isScreenSharing: false });
|
||||||
// Broadcast updated state via WebSocket — OS "Stop Sharing" bypasses our UI
|
|
||||||
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
|
const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState();
|
||||||
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });
|
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user