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 { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
||||
|
||||
@@ -259,7 +259,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
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"
|
||||
>
|
||||
<span>{useVoiceStore.getState().videoQuality}</span>
|
||||
<span>{`${useVoiceStore.getState().screenShareConfig.height}p ${useVoiceStore.getState().screenShareConfig.fps}fps`}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -270,7 +270,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
</svg>
|
||||
</button>
|
||||
{qualityPopoverOpen && (
|
||||
<VideoQualityPopover
|
||||
<ScreenShareSettingsPopover
|
||||
open={qualityPopoverOpen}
|
||||
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 { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { SCREEN_QUALITY_MAP, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||
import { CAMERA_PRESET, 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`;
|
||||
@@ -60,19 +60,14 @@ export function VoiceControlBar() {
|
||||
try {
|
||||
const willEnable = !isCameraOn;
|
||||
if (willEnable) {
|
||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||
const preset = SCREEN_QUALITY_MAP[videoQuality];
|
||||
if (preset) {
|
||||
await room.localParticipant.setCameraEnabled(true,
|
||||
{ resolution: preset.resolution },
|
||||
{
|
||||
videoEncoding: preset.encoding,
|
||||
simulcast: videoQuality === '1080p' || videoQuality === '720p'
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
}
|
||||
await room.localParticipant.setCameraEnabled(true,
|
||||
{ resolution: CAMERA_PRESET.resolution },
|
||||
{
|
||||
videoCodec: CAMERA_PRESET.codec,
|
||||
videoEncoding: CAMERA_PRESET.encoding,
|
||||
simulcast: false,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
<VideoQualityPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
||||
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function VoiceControls() {
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
||||
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
|
||||
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
||||
|
||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||
@@ -109,7 +109,7 @@ export function VoiceControls() {
|
||||
<button
|
||||
onClick={() => {
|
||||
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`}
|
||||
title="Connection Info"
|
||||
@@ -188,11 +188,11 @@ export function VoiceControls() {
|
||||
{/* Video Quality */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowVideoQuality(!showVideoQuality);
|
||||
if (!showVideoQuality) setShowConnectionInfo(false);
|
||||
setShowScreenShareSettings(!showScreenShareSettings);
|
||||
if (!showScreenShareSettings) setShowConnectionInfo(false);
|
||||
}}
|
||||
className={`${btnBase} ${
|
||||
showVideoQuality
|
||||
showScreenShareSettings
|
||||
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle
|
||||
}`}
|
||||
@@ -232,10 +232,10 @@ export function VoiceControls() {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Video Quality Popover */}
|
||||
<VideoQualityPopover
|
||||
open={showVideoQuality}
|
||||
onClose={() => setShowVideoQuality(false)}
|
||||
{/* Screen Share Settings Popover */}
|
||||
<ScreenShareSettingsPopover
|
||||
open={showScreenShareSettings}
|
||||
onClose={() => setShowScreenShareSettings(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user