diff --git a/packages/web/src/components/voice/DmCallView.tsx b/packages/web/src/components/voice/DmCallView.tsx index 3ef28101..7c29990a 100644 --- a/packages/web/src/components/voice/DmCallView.tsx +++ b/packages/web/src/components/voice/DmCallView.tsx @@ -4,17 +4,7 @@ import { useServerStore } from '../../stores/serverStore'; import { useAuthStore } from '../../stores/authStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; -import { AudioManager } from '../../audio/AudioManager'; -import { VideoPresets, VideoPreset } from 'livekit-client'; - -const QUALITY_MAP: Record = { - '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), -}; +import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare'; export function DmCallView() { const activeDmCall = useVoiceStore((s) => s.activeDmCall); @@ -26,7 +16,6 @@ export function DmCallView() { const toggleMic = useVoiceStore((s) => s.toggleMic); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleCamera = useVoiceStore((s) => s.toggleCamera); - const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall); const leaveVoice = useVoiceStore((s) => s.leaveVoice); const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds); @@ -69,11 +58,11 @@ export function DmCallView() { const willEnable = !isCameraOn; if (willEnable) { const videoQuality = useVoiceStore.getState().videoQuality; - const preset = QUALITY_MAP[videoQuality]; + const preset = SCREEN_QUALITY_MAP[videoQuality]; if (preset) { - await room.localParticipant.setCameraEnabled(true, + await room.localParticipant.setCameraEnabled(true, { resolution: preset.resolution }, - { + { videoEncoding: preset.encoding, simulcast: videoQuality === '1080p' || videoQuality === '720p' } @@ -93,13 +82,10 @@ export function DmCallView() { if (!room) return; try { if (!isScreenSharing) { - AudioManager.getInstance().setScreenShareActive(true); - await room.localParticipant.setScreenShareEnabled(true, { audio: true }); + await startScreenShare(room); } else { - await room.localParticipant.setScreenShareEnabled(false); - AudioManager.getInstance().setScreenShareActive(false); + await stopScreenShare(room); } - toggleScreenShare(); } catch (err) { console.error('[DmCallView] Failed to toggle screen share:', err); } diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index 1edfcc30..0982149c 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -2,8 +2,8 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; -import { AudioManager } from '../../audio/AudioManager'; import { VideoQualityPopover } from './VideoQualityPopover'; +import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit'; interface StreamTileProps { @@ -107,22 +107,14 @@ export function StreamTile({ tile, large }: StreamTileProps) { const handleStopStreaming = useCallback(async () => { const room = getActiveRoom(); if (room) { - await room.localParticipant.setScreenShareEnabled(false); - AudioManager.getInstance().setScreenShareActive(false); - useVoiceStore.getState().toggleScreenShare(); + await stopScreenShare(room); } }, []); const handleChangeStream = useCallback(async () => { const room = getActiveRoom(); if (room) { - await room.localParticipant.setScreenShareEnabled(false); - // Small delay then re-start to re-trigger the source picker - setTimeout(async () => { - await room.localParticipant.setScreenShareEnabled(true, { - audio: true, - }); - }, 200); + await changeScreenShare(room); } }, []); diff --git a/packages/web/src/components/voice/VideoQualityPopover.tsx b/packages/web/src/components/voice/VideoQualityPopover.tsx index 5313fa2b..0c373b6e 100644 --- a/packages/web/src/components/voice/VideoQualityPopover.tsx +++ b/packages/web/src/components/voice/VideoQualityPopover.tsx @@ -1,7 +1,5 @@ import React, { useRef, useEffect } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; -import { getActiveRoom } from '../../hooks/useLiveKit'; -import { VideoPresets, VideoPreset } from 'livekit-client'; interface VideoQualityPopoverProps { open: boolean; @@ -10,23 +8,14 @@ interface VideoQualityPopoverProps { } const PRESETS = [ - { value: '1080p60' as const, label: '1080p 60fps', desc: '1920x1080, 10000 kbps' }, - { value: '1080p' as const, label: '1080p 30fps', desc: '1920x1080, 5000 kbps' }, - { value: '720p60' as const, label: '720p 60fps', desc: '1280x720, 5000 kbps' }, - { value: '720p' as const, label: '720p 30fps', desc: '1280x720, 3000 kbps' }, - { value: '540p' as const, label: '540p 30fps', desc: '960x540, 1500 kbps' }, - { value: '360p' as const, label: '360p 30fps', desc: '640x360, 800 kbps' }, + { 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; -const QUALITY_MAP: Record = { - '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) { const popoverRef = useRef(null); const videoQuality = useVoiceStore((s) => s.videoQuality); diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 148cabb5..fbaa9f0c 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -3,18 +3,8 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { useUIStore } from '../../stores/uiStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; -import { AudioManager } from '../../audio/AudioManager'; import { VideoQualityPopover } from './VideoQualityPopover'; -import { VideoPresets, VideoPreset } from 'livekit-client'; - -const QUALITY_MAP: Record = { - '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), -}; +import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare'; const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors'; const btnDefault = `${btnBase} bg-[#1e1f22] text-discord-text-secondary hover:bg-[#2b2d31] hover:text-discord-text-primary`; @@ -29,7 +19,6 @@ export function VoiceControlBar() { const toggleMic = useVoiceStore((s) => s.toggleMic); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleCamera = useVoiceStore((s) => s.toggleCamera); - const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); @@ -72,7 +61,7 @@ export function VoiceControlBar() { const willEnable = !isCameraOn; if (willEnable) { const videoQuality = useVoiceStore.getState().videoQuality; - const preset = QUALITY_MAP[videoQuality]; + const preset = SCREEN_QUALITY_MAP[videoQuality]; if (preset) { await room.localParticipant.setCameraEnabled(true, { resolution: preset.resolution }, @@ -98,13 +87,10 @@ export function VoiceControlBar() { if (!room) return; try { if (!isScreenSharing) { - AudioManager.getInstance().setScreenShareActive(true); - await room.localParticipant.setScreenShareEnabled(true, { audio: true }); + await startScreenShare(room); } else { - await room.localParticipant.setScreenShareEnabled(false); - AudioManager.getInstance().setScreenShareActive(false); + await stopScreenShare(room); } - toggleScreenShare(); } catch (err) { console.error('[VoiceControlBar] Failed to toggle screen share:', err); } diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index a165e727..29d2f872 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -3,9 +3,9 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { useServerStore } from '../../stores/serverStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; -import { AudioManager } from '../../audio/AudioManager'; import { VideoQualityPopover } from './VideoQualityPopover'; import { ConnectionInfoPopover } from './ConnectionInfoPopover'; +import { startScreenShare, stopScreenShare } from '../../utils/screenShare'; /** * VoiceControls renders the voice status + button rows. @@ -16,7 +16,6 @@ export function VoiceControls() { const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const toggleCamera = useVoiceStore((s) => s.toggleCamera); - const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled); const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled); const connectionError = useVoiceStore((s) => s.connectionError); @@ -47,13 +46,10 @@ export function VoiceControls() { if (!room) return; try { if (!isScreenSharing) { - AudioManager.getInstance().setScreenShareActive(true); - await room.localParticipant.setScreenShareEnabled(true, { audio: true }); + await startScreenShare(room); } else { - await room.localParticipant.setScreenShareEnabled(false); - AudioManager.getInstance().setScreenShareActive(false); + await stopScreenShare(room); } - toggleScreenShare(); } catch (err) { console.error('[VoiceControls] Failed to toggle screen share:', err); } diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 905e81c9..a00e097c 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -18,21 +18,14 @@ import { api } from '../api/client'; import { useVoiceStore } from '../stores/voiceStore'; import { AudioManager } from '../audio/AudioManager'; import { SpeakingDetector } from '../audio/SpeakingDetector'; - -/** - * OPENCORD NATIVE OVERDRIVE PIPELINE v33 - */ - -const QUALITY_MAP: Record = { - '1080p60': new VideoPreset(1920, 1080, 12_000_000, 60), - '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), - '720p60': new VideoPreset(1280, 720, 8_000_000, 60), - '720p': new VideoPreset(1280, 720, 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']!; +import { + SCREEN_QUALITY_MAP, + AUTO_PRESET, + applyOverdrive, + startScreenShare, + stopScreenShare, + handleScreenShareUnpublished, +} from '../utils/screenShare'; let _activeRoom: Room | null = null; @@ -114,31 +107,6 @@ function parseIdentity(identity: string): { userId: string; username: string } { 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() { const [room, setRoom] = useState(null); @@ -357,11 +325,10 @@ export function useLiveKit() { try { const { token, url } = await api.livekit.token(channelId); 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; - + const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; - // ... existing event listeners ... newRoom.on(RoomEvent.ParticipantConnected, (participant) => { guardedUpdate(); if (useVoiceStore.getState().isDeafened) { @@ -399,6 +366,8 @@ export function useLiveKit() { if (publication.source === Track.Source.ScreenShare) { const { userId } = parseIdentity(newRoom.localParticipant.identity); useVoiceStore.getState().unwatchStream(userId); + // OS-level "Stop sharing" fires this without going through stopScreenShare + handleScreenShareUnpublished(); } guardedUpdate(); }); @@ -530,7 +499,7 @@ export function useLiveKit() { try { const { token, url } = await api.livekit.dmToken(dmChannelId); 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; const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate); @@ -558,6 +527,8 @@ export function useLiveKit() { if (publication.source === Track.Source.ScreenShare) { const { userId } = parseIdentity(newRoom.localParticipant.identity); useVoiceStore.getState().unwatchStream(userId); + // OS-level "Stop sharing" fires this without going through stopScreenShare + handleScreenShareUnpublished(); } guardedUpdate(); }); @@ -664,55 +635,23 @@ export function useLiveKit() { const toggleCamera = useCallback(async () => { if (roomRef.current) { 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 }); - 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); } updateParticipants(); } }, [isCameraOn, videoQuality, updateParticipants]); const toggleScreenShare = useCallback(async () => { - if (roomRef.current) { - if (!isScreenSharing) { - // Notify AudioManager BEFORE enabling screen share so the mic track - // gets republished with AEC off, preventing Chrome's ducking. - AudioManager.getInstance().setScreenShareActive(true); - - 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(); + if (!roomRef.current) return; + if (!useVoiceStore.getState().isScreenSharing) { + await startScreenShare(roomRef.current); + } else { + await stopScreenShare(roomRef.current); } - }, [isScreenSharing, videoQuality, updateParticipants]); + updateParticipants(); + }, [updateParticipants]); useEffect(() => { updateParticipants(); @@ -720,7 +659,7 @@ export function useLiveKit() { useEffect(() => { if (!room) return; - const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET; + const preset = SCREEN_QUALITY_MAP[videoQuality] || AUTO_PRESET; const updateActiveTracks = async () => { if (isScreenSharing) { const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare); @@ -729,10 +668,10 @@ export function useLiveKit() { if (mediaTrack) { 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(() => {}); }, [room, videoQuality, isScreenSharing, isCameraOn]); diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts new file mode 100644 index 00000000..16ac398e --- /dev/null +++ b/packages/web/src/utils/screenShare.ts @@ -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 = { + '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 { + 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 { + 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 { + 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 { + 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 }); +}