fix: centralize screen share pipeline to fix 270p resolution ramp-up failure
Screen sharing was publishing at h360 (640x360) and never ramping to target resolution due to stale closure in setTimeout, wrong initial quality anchor, and 5 competing code paths with inconsistent bitrates. - Create utils/screenShare.ts as single source of truth for all screen share ops - Publish at target resolution from the start (not h360 → ramp) - Read store at call time in timers (eliminates stale closure bug) - Use maintain-resolution for screen content, maintain-framerate for camera - Fix OS-level "Stop sharing" not resetting store or restoring AEC - Enable dynacast for SFU quality signaling - Reconcile QUALITY_MAP to canonical bitrates across all 7 files
This commit is contained in:
@@ -4,17 +4,7 @@ import { useServerStore } from '../../stores/serverStore';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
|
||||||
|
|
||||||
const QUALITY_MAP: Record<string, any> = {
|
|
||||||
'1080p60': new VideoPreset(1920, 1080, 15_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),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DmCallView() {
|
export function DmCallView() {
|
||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
@@ -26,7 +16,6 @@ export function DmCallView() {
|
|||||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
|
||||||
const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall);
|
const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall);
|
||||||
const leaveVoice = useVoiceStore((s) => s.leaveVoice);
|
const leaveVoice = useVoiceStore((s) => s.leaveVoice);
|
||||||
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
|
||||||
@@ -69,7 +58,7 @@ export function DmCallView() {
|
|||||||
const willEnable = !isCameraOn;
|
const willEnable = !isCameraOn;
|
||||||
if (willEnable) {
|
if (willEnable) {
|
||||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||||
const preset = QUALITY_MAP[videoQuality];
|
const preset = SCREEN_QUALITY_MAP[videoQuality];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
await room.localParticipant.setCameraEnabled(true,
|
await room.localParticipant.setCameraEnabled(true,
|
||||||
{ resolution: preset.resolution },
|
{ resolution: preset.resolution },
|
||||||
@@ -93,13 +82,10 @@ export function DmCallView() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
AudioManager.getInstance().setScreenShareActive(true);
|
await startScreenShare(room);
|
||||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await stopScreenShare(room);
|
||||||
AudioManager.getInstance().setScreenShareActive(false);
|
|
||||||
}
|
}
|
||||||
toggleScreenShare();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[DmCallView] Failed to toggle screen share:', err);
|
console.error('[DmCallView] Failed to toggle screen share:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ 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 { AudioManager } from '../../audio/AudioManager';
|
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
|
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
||||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface StreamTileProps {
|
interface StreamTileProps {
|
||||||
@@ -107,22 +107,14 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
const handleStopStreaming = useCallback(async () => {
|
const handleStopStreaming = useCallback(async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
if (room) {
|
if (room) {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await stopScreenShare(room);
|
||||||
AudioManager.getInstance().setScreenShareActive(false);
|
|
||||||
useVoiceStore.getState().toggleScreenShare();
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChangeStream = useCallback(async () => {
|
const handleChangeStream = useCallback(async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
if (room) {
|
if (room) {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await changeScreenShare(room);
|
||||||
// Small delay then re-start to re-trigger the source picker
|
|
||||||
setTimeout(async () => {
|
|
||||||
await room.localParticipant.setScreenShareEnabled(true, {
|
|
||||||
audio: true,
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
|
||||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
|
||||||
|
|
||||||
interface VideoQualityPopoverProps {
|
interface VideoQualityPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -10,23 +8,14 @@ interface VideoQualityPopoverProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
{ value: '1080p60' as const, label: '1080p 60fps', desc: '1920x1080, 10000 kbps' },
|
{ value: '1080p60' as const, label: '1080p 60fps', desc: '1920x1080, 12000 kbps' },
|
||||||
{ value: '1080p' as const, label: '1080p 30fps', desc: '1920x1080, 5000 kbps' },
|
{ value: '1080p' as const, label: '1080p 30fps', desc: '1920x1080, 8000 kbps' },
|
||||||
{ value: '720p60' as const, label: '720p 60fps', desc: '1280x720, 5000 kbps' },
|
{ value: '720p60' as const, label: '720p 60fps', desc: '1280x720, 8000 kbps' },
|
||||||
{ value: '720p' as const, label: '720p 30fps', desc: '1280x720, 3000 kbps' },
|
{ value: '720p' as const, label: '720p 30fps', desc: '1280x720, 5000 kbps' },
|
||||||
{ value: '540p' as const, label: '540p 30fps', desc: '960x540, 1500 kbps' },
|
{ value: '540p' as const, label: '540p 30fps', desc: '960x540, 2000 kbps' },
|
||||||
{ value: '360p' as const, label: '360p 30fps', desc: '640x360, 800 kbps' },
|
{ value: '360p' as const, label: '360p 30fps', desc: '640x360, 1000 kbps' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const QUALITY_MAP: Record<string, VideoPreset> = {
|
|
||||||
'1080p60': new VideoPreset(1920, 1080, 15_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),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VideoQualityPopover({ open, onClose, anchorRect }: VideoQualityPopoverProps) {
|
export function VideoQualityPopover({ open, onClose, anchorRect }: VideoQualityPopoverProps) {
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||||
|
|||||||
@@ -3,18 +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 { AudioManager } from '../../audio/AudioManager';
|
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
import { VideoPresets, VideoPreset } from 'livekit-client';
|
import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
|
||||||
const QUALITY_MAP: Record<string, any> = {
|
|
||||||
'1080p60': new VideoPreset(1920, 1080, 15_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),
|
|
||||||
};
|
|
||||||
|
|
||||||
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`;
|
||||||
@@ -29,7 +19,6 @@ export function VoiceControlBar() {
|
|||||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
|
||||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||||
@@ -72,7 +61,7 @@ export function VoiceControlBar() {
|
|||||||
const willEnable = !isCameraOn;
|
const willEnable = !isCameraOn;
|
||||||
if (willEnable) {
|
if (willEnable) {
|
||||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||||
const preset = QUALITY_MAP[videoQuality];
|
const preset = SCREEN_QUALITY_MAP[videoQuality];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
await room.localParticipant.setCameraEnabled(true,
|
await room.localParticipant.setCameraEnabled(true,
|
||||||
{ resolution: preset.resolution },
|
{ resolution: preset.resolution },
|
||||||
@@ -98,13 +87,10 @@ export function VoiceControlBar() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
AudioManager.getInstance().setScreenShareActive(true);
|
await startScreenShare(room);
|
||||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await stopScreenShare(room);
|
||||||
AudioManager.getInstance().setScreenShareActive(false);
|
|
||||||
}
|
}
|
||||||
toggleScreenShare();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ 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 { AudioManager } from '../../audio/AudioManager';
|
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||||
|
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VoiceControls renders the voice status + button rows.
|
* VoiceControls renders the voice status + button rows.
|
||||||
@@ -16,7 +16,6 @@ export function VoiceControls() {
|
|||||||
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 toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
|
||||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||||
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
|
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
|
||||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||||
@@ -47,13 +46,10 @@ export function VoiceControls() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
if (!isScreenSharing) {
|
if (!isScreenSharing) {
|
||||||
AudioManager.getInstance().setScreenShareActive(true);
|
await startScreenShare(room);
|
||||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
|
||||||
} else {
|
} else {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await stopScreenShare(room);
|
||||||
AudioManager.getInstance().setScreenShareActive(false);
|
|
||||||
}
|
}
|
||||||
toggleScreenShare();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,21 +18,14 @@ import { api } from '../api/client';
|
|||||||
import { useVoiceStore } from '../stores/voiceStore';
|
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 {
|
||||||
/**
|
SCREEN_QUALITY_MAP,
|
||||||
* OPENCORD NATIVE OVERDRIVE PIPELINE v33
|
AUTO_PRESET,
|
||||||
*/
|
applyOverdrive,
|
||||||
|
startScreenShare,
|
||||||
const QUALITY_MAP: Record<string, VideoPreset> = {
|
stopScreenShare,
|
||||||
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
|
handleScreenShareUnpublished,
|
||||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
} from '../utils/screenShare';
|
||||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
|
||||||
'720p': new VideoPreset(1280, 720, 4_000_000, 30),
|
|
||||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
|
||||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
|
||||||
};
|
|
||||||
|
|
||||||
const AUTO_PRESET = QUALITY_MAP['720p60']!;
|
|
||||||
|
|
||||||
let _activeRoom: Room | null = null;
|
let _activeRoom: Room | null = null;
|
||||||
|
|
||||||
@@ -114,31 +107,6 @@ function parseIdentity(identity: string): { userId: string; username: string } {
|
|||||||
|
|
||||||
let _connectGeneration = 0;
|
let _connectGeneration = 0;
|
||||||
|
|
||||||
async function applyOverdriveHammer(room: Room, source: Track.Source, preset: VideoPreset) {
|
|
||||||
try {
|
|
||||||
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
|
||||||
if (!pub?.track) return;
|
|
||||||
|
|
||||||
const engine = (room as any).engine;
|
|
||||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
|
|
||||||
if (pc) {
|
|
||||||
const senders = (pc as RTCPeerConnection).getSenders();
|
|
||||||
const sender = senders.find(s => s.track?.id === (pub.track as any).mediaStreamTrack?.id);
|
|
||||||
if (sender) {
|
|
||||||
const params = sender.getParameters();
|
|
||||||
if (params.encodings && params.encodings[0]) {
|
|
||||||
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
|
|
||||||
(params.encodings[0] as any).minBitrate = 2_000_000;
|
|
||||||
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
|
|
||||||
params.encodings[0].networkPriority = 'high';
|
|
||||||
// @ts-ignore
|
|
||||||
params.degradationPreference = 'maintain-framerate';
|
|
||||||
await sender.setParameters(params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLiveKit() {
|
export function useLiveKit() {
|
||||||
const [room, setRoom] = useState<Room | null>(null);
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
@@ -357,11 +325,10 @@ export function useLiveKit() {
|
|||||||
try {
|
try {
|
||||||
const { token, url } = await api.livekit.token(channelId);
|
const { token, url } = await api.livekit.token(channelId);
|
||||||
if (gen !== _connectGeneration) return;
|
if (gen !== _connectGeneration) return;
|
||||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||||
roomRef.current = newRoom;
|
roomRef.current = newRoom;
|
||||||
|
|
||||||
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
||||||
// ... existing event listeners ...
|
|
||||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
if (useVoiceStore.getState().isDeafened) {
|
if (useVoiceStore.getState().isDeafened) {
|
||||||
@@ -399,6 +366,8 @@ export function useLiveKit() {
|
|||||||
if (publication.source === Track.Source.ScreenShare) {
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||||
useVoiceStore.getState().unwatchStream(userId);
|
useVoiceStore.getState().unwatchStream(userId);
|
||||||
|
// OS-level "Stop sharing" fires this without going through stopScreenShare
|
||||||
|
handleScreenShareUnpublished();
|
||||||
}
|
}
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
});
|
});
|
||||||
@@ -530,7 +499,7 @@ export function useLiveKit() {
|
|||||||
try {
|
try {
|
||||||
const { token, url } = await api.livekit.dmToken(dmChannelId);
|
const { token, url } = await api.livekit.dmToken(dmChannelId);
|
||||||
if (gen !== _connectGeneration) return;
|
if (gen !== _connectGeneration) return;
|
||||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
const newRoom = new Room({ adaptiveStream: false, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||||
roomRef.current = newRoom;
|
roomRef.current = newRoom;
|
||||||
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
|
||||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||||
@@ -558,6 +527,8 @@ export function useLiveKit() {
|
|||||||
if (publication.source === Track.Source.ScreenShare) {
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||||
useVoiceStore.getState().unwatchStream(userId);
|
useVoiceStore.getState().unwatchStream(userId);
|
||||||
|
// OS-level "Stop sharing" fires this without going through stopScreenShare
|
||||||
|
handleScreenShareUnpublished();
|
||||||
}
|
}
|
||||||
guardedUpdate();
|
guardedUpdate();
|
||||||
});
|
});
|
||||||
@@ -664,55 +635,23 @@ export function useLiveKit() {
|
|||||||
const toggleCamera = useCallback(async () => {
|
const toggleCamera = useCallback(async () => {
|
||||||
if (roomRef.current) {
|
if (roomRef.current) {
|
||||||
if (!isCameraOn) {
|
if (!isCameraOn) {
|
||||||
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
|
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 });
|
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
|
||||||
setTimeout(() => { if (roomRef.current) applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 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, videoQuality, updateParticipants]);
|
||||||
|
|
||||||
const toggleScreenShare = useCallback(async () => {
|
const toggleScreenShare = useCallback(async () => {
|
||||||
if (roomRef.current) {
|
if (!roomRef.current) return;
|
||||||
if (!isScreenSharing) {
|
if (!useVoiceStore.getState().isScreenSharing) {
|
||||||
// Notify AudioManager BEFORE enabling screen share so the mic track
|
await startScreenShare(roomRef.current);
|
||||||
// gets republished with AEC off, preventing Chrome's ducking.
|
} else {
|
||||||
AudioManager.getInstance().setScreenShareActive(true);
|
await stopScreenShare(roomRef.current);
|
||||||
|
|
||||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
|
||||||
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
|
|
||||||
audio: true,
|
|
||||||
resolution: VideoPresets.h360.resolution,
|
|
||||||
// @ts-ignore
|
|
||||||
frameRate: 30,
|
|
||||||
}, {
|
|
||||||
videoCodec: 'h264', videoEncoding: VideoPresets.h360.encoding, simulcast: false, priority: 'very-high'
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
if (track) {
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (roomRef.current && isScreenSharing) {
|
|
||||||
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
|
||||||
if (screenPub?.track?.mediaStreamTrack) {
|
|
||||||
await screenPub.track.mediaStreamTrack.applyConstraints({
|
|
||||||
width: { ideal: preset.resolution.width },
|
|
||||||
height: { ideal: preset.resolution.height },
|
|
||||||
frameRate: { ideal: preset.encoding.maxFramerate, min: 30 }
|
|
||||||
});
|
|
||||||
await applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
setTimeout(() => applyOverdriveHammer(roomRef.current!, Track.Source.ScreenShare, preset), 5000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await roomRef.current.localParticipant.setScreenShareEnabled(false);
|
|
||||||
// Restore user's AEC preference after screen share ends
|
|
||||||
AudioManager.getInstance().setScreenShareActive(false);
|
|
||||||
}
|
|
||||||
updateParticipants();
|
|
||||||
}
|
}
|
||||||
}, [isScreenSharing, videoQuality, updateParticipants]);
|
updateParticipants();
|
||||||
|
}, [updateParticipants]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateParticipants();
|
updateParticipants();
|
||||||
@@ -720,7 +659,7 @@ export function useLiveKit() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||||
const updateActiveTracks = async () => {
|
const updateActiveTracks = async () => {
|
||||||
if (isScreenSharing) {
|
if (isScreenSharing) {
|
||||||
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||||
@@ -729,10 +668,10 @@ export function useLiveKit() {
|
|||||||
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: preset.resolution.width }, height: { ideal: preset.resolution.height }, frameRate: { ideal: preset.encoding.maxFramerate } });
|
||||||
}
|
}
|
||||||
await applyOverdriveHammer(room, Track.Source.ScreenShare, preset);
|
await applyOverdrive(room, Track.Source.ScreenShare, preset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isCameraOn) { await applyOverdriveHammer(room, Track.Source.Camera, preset); }
|
if (isCameraOn) { await applyOverdrive(room, Track.Source.Camera, preset); }
|
||||||
};
|
};
|
||||||
updateActiveTracks().catch(() => {});
|
updateActiveTracks().catch(() => {});
|
||||||
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { Room, Track, VideoPreset } from 'livekit-client';
|
||||||
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTO_PRESET = SCREEN_QUALITY_MAP['720p60']!;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply overdrive hammer to a published track — forces bitrate/resolution/framerate
|
||||||
|
* directly on the RTCRtpSender, bypassing LiveKit's conservative defaults.
|
||||||
|
*
|
||||||
|
* Screen share: uses 'maintain-resolution' (text/code readability matters more than fps)
|
||||||
|
* Camera: uses 'maintain-framerate' (smooth motion matters more than sharpness)
|
||||||
|
*/
|
||||||
|
export async function applyOverdrive(
|
||||||
|
room: Room,
|
||||||
|
source: Track.Source,
|
||||||
|
preset: VideoPreset,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
||||||
|
if (!pub?.track) return;
|
||||||
|
|
||||||
|
const engine = (room as any).engine;
|
||||||
|
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
|
||||||
|
if (!pc) return;
|
||||||
|
|
||||||
|
const senders = (pc as RTCPeerConnection).getSenders();
|
||||||
|
const sender = senders.find(s => s.track?.id === (pub.track as any).mediaStreamTrack?.id);
|
||||||
|
if (!sender) return;
|
||||||
|
|
||||||
|
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].networkPriority = 'high';
|
||||||
|
|
||||||
|
const isScreenShare = source === Track.Source.ScreenShare;
|
||||||
|
if (isScreenShare) {
|
||||||
|
(params as any).degradationPreference = 'maintain-resolution';
|
||||||
|
(params.encodings[0] as any).minBitrate = 2_000_000;
|
||||||
|
} else {
|
||||||
|
(params as any).degradationPreference = 'maintain-framerate';
|
||||||
|
}
|
||||||
|
|
||||||
|
await sender.setParameters(params);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ScreenShare] Failed to apply overdrive:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start screen sharing at target quality from the beginning.
|
||||||
|
* Reads videoQuality from store at call time — no stale closures.
|
||||||
|
*/
|
||||||
|
export async function startScreenShare(room: Room): Promise<boolean> {
|
||||||
|
const { videoQuality } = useVoiceStore.getState();
|
||||||
|
const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||||
|
|
||||||
|
// Notify AudioManager BEFORE enabling screen share so the mic track
|
||||||
|
// gets republished with AEC off, preventing Chrome's ducking.
|
||||||
|
AudioManager.getInstance().setScreenShareActive(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const track = await room.localParticipant.setScreenShareEnabled(true, {
|
||||||
|
audio: true,
|
||||||
|
resolution: preset.resolution,
|
||||||
|
// @ts-ignore — LiveKit accepts frameRate at capture level
|
||||||
|
frameRate: preset.encoding.maxFramerate,
|
||||||
|
}, {
|
||||||
|
videoCodec: 'h264',
|
||||||
|
videoEncoding: preset.encoding,
|
||||||
|
simulcast: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
// User cancelled the screen picker
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await applyOverdrive(room, Track.Source.ScreenShare, currentPreset);
|
||||||
|
}, 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);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ScreenShare] Failed to start screen share:', err);
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop screen sharing, restore AEC, reset store.
|
||||||
|
*/
|
||||||
|
export async function stopScreenShare(room: Room): Promise<void> {
|
||||||
|
try {
|
||||||
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ScreenShare] Failed to stop screen share:', err);
|
||||||
|
}
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
|
useVoiceStore.setState({ isScreenSharing: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the 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).
|
||||||
|
*/
|
||||||
|
export function handleScreenShareUnpublished(): void {
|
||||||
|
AudioManager.getInstance().setScreenShareActive(false);
|
||||||
|
useVoiceStore.setState({ isScreenSharing: false });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user