requestFullscreen() on the voice container puts only its descendants in the browser's top layer; overlays portaled to document.body were rendered outside that layer and stayed invisible — most visibly the right-click context menu on stream tiles and voice user panels. Add usePortalContainer() hook returning document.fullscreenElement ?? document.body and re-rendering on fullscreenchange. Migrate every overlay reachable during a call: ContextMenuRenderer (desktop, submenu, mobile sheet), Tooltip, ConfirmDialog, ConnectionInfoPopover, ScreenShareSettingsPopover, and ScreenSharePicker (which previously rendered inline at App root).
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import React, { useRef, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useVoiceStore } from '../../stores/voiceStore';
|
|
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
|
import { useSettingsStore } from '../../stores/settingsStore';
|
|
import { buildScreenShareOptions } from '../../utils/screenShare';
|
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
|
import { usePortalContainer } from '../../hooks/usePortalContainer';
|
|
import { Toggle } from '../ui/Toggle';
|
|
import { isElectron } from '../../platform/platform';
|
|
import { RESOLUTION_LABELS } from '@backspace/shared/src/constants';
|
|
|
|
interface ScreenShareSettingsPopoverProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
}
|
|
|
|
const MODES: { value: ScreenShareConfig['mode']; label: string }[] = [
|
|
{ value: 'gaming', label: 'Gaming' },
|
|
{ value: 'text', label: 'Text' },
|
|
];
|
|
|
|
const CODEC_OPTIONS = [
|
|
{ value: 'vp9' as const, label: 'Standard', sub: 'VP9' },
|
|
{ value: 'hw' as const, label: 'NVIDIA / Apple', sub: 'H.264' },
|
|
];
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function formatKbps(kbps: number): string {
|
|
return kbps >= 1000
|
|
? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps`
|
|
: `${kbps} kbps`;
|
|
}
|
|
|
|
export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const portalContainer = usePortalContainer();
|
|
const config = useVoiceStore((s) => s.screenShareConfig);
|
|
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
|
const hwOverdrive = useVoiceStore((s) => s.hwOverdrive);
|
|
const setHwOverdrive = useVoiceStore((s) => s.setHwOverdrive);
|
|
const limits = useSettingsStore((s) => s.streamingLimits);
|
|
|
|
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
|
placement: 'top',
|
|
offset: 12,
|
|
enabled: open,
|
|
});
|
|
|
|
const BITRATE_MIN = limits?.minBitrateKbps ?? 500;
|
|
const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000;
|
|
const BITRATE_STEP = limits?.bitrateStepKbps ?? 500;
|
|
|
|
const RESOLUTIONS = (limits?.allowedResolutions ?? [540, 720, 1080]).map((r) => ({
|
|
value: r as ScreenShareConfig['height'],
|
|
label: RESOLUTION_LABELS[r as keyof typeof RESOLUTION_LABELS] ?? `${r}p`,
|
|
}));
|
|
const FRAME_RATES = (limits?.allowedFramerates ?? [30, 45, 60]).map((f) => ({
|
|
value: f as ScreenShareConfig['fps'],
|
|
label: `${f}`,
|
|
}));
|
|
|
|
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]);
|
|
|
|
// Auto-clamp persisted config if outside allowed bounds
|
|
useEffect(() => {
|
|
if (!limits) return;
|
|
const patch: Partial<ScreenShareConfig> = {};
|
|
if (!limits.allowedResolutions.includes(config.height)) {
|
|
const numericRes = limits.allowedResolutions.filter((r): r is number => r !== 'native');
|
|
if (typeof config.height === 'number' && numericRes.length > 0) {
|
|
const h = config.height;
|
|
patch.height = numericRes.reduce((a, b) =>
|
|
Math.abs(b - h) < Math.abs(a - h) ? b : a
|
|
);
|
|
} else {
|
|
// 'native' was disabled or no numeric options — fall back to highest numeric
|
|
patch.height = numericRes.length > 0 ? Math.max(...numericRes) : 1080;
|
|
}
|
|
}
|
|
if (!limits.allowedFramerates.includes(config.fps)) {
|
|
const f = config.fps;
|
|
const closest = limits.allowedFramerates.reduce((a, b) =>
|
|
Math.abs(b - f) < Math.abs(a - f) ? b : a
|
|
);
|
|
patch.fps = closest;
|
|
}
|
|
if (config.customBitrateKbps != null) {
|
|
const clamped = Math.min(Math.max(config.customBitrateKbps, limits.minBitrateKbps), limits.maxBitrateKbps);
|
|
if (clamped !== config.customBitrateKbps) patch.customBitrateKbps = clamped;
|
|
}
|
|
if (Object.keys(patch).length > 0) setConfig(patch);
|
|
}, [limits, config, setConfig]);
|
|
|
|
if (!open) return null;
|
|
|
|
const result = buildScreenShareOptions(config);
|
|
|
|
const pillBase = 'px-2.5 py-1.5 rounded-full text-[13px] font-medium transition-colors cursor-pointer select-none text-center';
|
|
const pillSelected = 'bg-accent-primary text-white';
|
|
const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
style={style}
|
|
className="w-[260px] glass rounded-lg overflow-hidden"
|
|
>
|
|
<div className="px-3 py-2 border-b border-border-hard">
|
|
<span className="text-[14px] font-bold text-txt-primary">Stream Settings</span>
|
|
</div>
|
|
|
|
<div className="px-3 py-3 flex flex-col gap-3">
|
|
{/* Resolution */}
|
|
<div>
|
|
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
|
Resolution
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-1.5">
|
|
{RESOLUTIONS.map((r) => (
|
|
<button
|
|
key={String(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-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
|
Frame Rate
|
|
</div>
|
|
<div className="grid grid-cols-3 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-txt-tertiary 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>
|
|
|
|
{/* Codec */}
|
|
<div>
|
|
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
|
Codec
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{CODEC_OPTIONS.map((c) => {
|
|
const isHw = c.value === 'hw';
|
|
const isSelected = isHw ? hwOverdrive : !hwOverdrive;
|
|
return (
|
|
<button
|
|
key={c.value}
|
|
onClick={() => setHwOverdrive(isHw)}
|
|
className={`${pillBase} flex flex-col items-center gap-0 leading-tight ${
|
|
isSelected
|
|
? (isHw ? 'bg-accent-amber/60 text-white' : pillSelected)
|
|
: (isHw ? 'bg-surface-elevated/50 text-txt-tertiary hover:bg-interactive-hover' : pillUnselected)
|
|
}`}
|
|
>
|
|
<span>{c.label}</span>
|
|
<span className="text-[10px] opacity-60">{c.sub}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{hwOverdrive && (
|
|
<div className="text-[10px] text-accent-amber/80 mt-1">
|
|
GPU hardware encoder · resets when stream ends
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bitrate */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider">
|
|
Bitrate
|
|
</div>
|
|
{limits?.allowCustomBitrate !== false && config.customBitrateKbps != null && (
|
|
<button
|
|
onClick={() => setConfig({ customBitrateKbps: null })}
|
|
className="text-[11px] text-accent-primary hover:text-accent-lavender font-medium transition-colors"
|
|
>
|
|
Reset to Auto
|
|
</button>
|
|
)}
|
|
</div>
|
|
{limits?.allowCustomBitrate !== false ? (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={BITRATE_MIN}
|
|
max={BITRATE_MAX}
|
|
step={BITRATE_STEP}
|
|
value={config.customBitrateKbps ?? Math.round(result.publish.videoEncoding.maxBitrate / 1000)}
|
|
onChange={(e) => setConfig({ customBitrateKbps: Number(e.target.value) })}
|
|
className="flex-1 h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5
|
|
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
|
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0
|
|
[&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full
|
|
[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
|
|
/>
|
|
<span className={`text-[12px] font-medium min-w-[64px] text-right ${
|
|
config.customBitrateKbps != null ? 'text-txt-primary' : 'text-txt-tertiary'
|
|
}`}>
|
|
{config.customBitrateKbps != null
|
|
? formatKbps(config.customBitrateKbps)
|
|
: `Auto`}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="text-[12px] text-txt-secondary font-medium">
|
|
{formatKbps(Math.round(result.publish.videoEncoding.maxBitrate / 1000))}
|
|
</div>
|
|
<div className="text-[10px] text-txt-tertiary mt-0.5">
|
|
Custom bitrate disabled by administrator
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* System Audio */}
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider">
|
|
System Audio
|
|
</div>
|
|
{isElectron() && config.shareAudio && (
|
|
<div className="text-[10px] text-accent-amber/80 mt-0.5">
|
|
Use Chrome for echo-free audio
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Toggle
|
|
enabled={config.shareAudio}
|
|
onChange={(enabled) => setConfig({ shareAudio: enabled })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer — computed stats */}
|
|
<div className="px-3 py-2 border-t border-border-hard">
|
|
<span className="text-[12px] text-txt-tertiary">
|
|
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
|
|
</span>
|
|
</div>
|
|
</div>,
|
|
portalContainer,
|
|
);
|
|
}
|