feat: instance-level streaming limits with admin settings panel
Add a server-side instance_settings table (single-row, CHECK(id=1)) that stores admin-configurable streaming bounds: bitrate min/max/step, allowed resolutions, and allowed framerates. Backend: - New instance_settings schema + migrations (is_admin on users, default settings row, first-registered-user promoted to admin) - GET/PATCH /api/settings/streaming endpoints with admin-only writes and full input validation including cross-field checks Frontend: - settingsStore fetches limits on WebSocket ready, exposes isAdmin flag - ScreenShareSettingsPopover reads bounds from store instead of hardcoded constants, auto-clamps stale localStorage values - buildScreenShareOptions() clamps bitrate to server limits at build time as enforcement backstop - ServerSettings modal gains a "Streaming" tab (admin-only) with bitrate range sliders, resolution/framerate toggles, and save/reset
This commit is contained in:
@@ -59,6 +59,7 @@ const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => (
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
...overrides,
|
||||
@@ -212,6 +213,7 @@ describe('FriendsPage', () => {
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -256,6 +258,7 @@ describe('FriendsPage', () => {
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -295,6 +298,7 @@ describe('FriendsPage', () => {
|
||||
avatar: null,
|
||||
status: 'online',
|
||||
customStatus: null,
|
||||
isAdmin: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,219 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { InstanceStreamingLimits } from '@opencord/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
|
||||
const VALID_FRAMERATES = [30, 45, 60] as const;
|
||||
|
||||
function formatKbps(kbps: number): string {
|
||||
return kbps >= 1000
|
||||
? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps`
|
||||
: `${kbps} kbps`;
|
||||
}
|
||||
|
||||
function StreamingLimitsPanel() {
|
||||
const limits = useSettingsStore((s) => s.streamingLimits);
|
||||
const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits);
|
||||
|
||||
const [draft, setDraft] = useState<InstanceStreamingLimits | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (limits) setDraft({ ...limits });
|
||||
}, [limits]);
|
||||
|
||||
if (!draft) return <div className="text-sm text-discord-text-muted">Loading settings...</div>;
|
||||
|
||||
const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
await updateStreamingLimits(draft);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (limits) setDraft({ ...limits });
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
const toggleResolution = (res: number) => {
|
||||
const current = new Set(draft.allowedResolutions);
|
||||
if (current.has(res)) {
|
||||
if (current.size <= 1) return; // Must have at least one
|
||||
current.delete(res);
|
||||
} else {
|
||||
current.add(res);
|
||||
}
|
||||
setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const toggleFramerate = (fps: number) => {
|
||||
const current = new Set(draft.allowedFramerates);
|
||||
if (current.has(fps)) {
|
||||
if (current.size <= 1) return;
|
||||
current.delete(fps);
|
||||
} else {
|
||||
current.add(fps);
|
||||
}
|
||||
setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) });
|
||||
};
|
||||
|
||||
const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none';
|
||||
const pillOn = 'bg-discord-blurple text-white';
|
||||
const pillOff = 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#35373c]';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-discord-text-muted">
|
||||
These limits apply to all users on this instance. Users can pick values within these bounds.
|
||||
</div>
|
||||
|
||||
{/* Bitrate Range */}
|
||||
<div>
|
||||
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-2">
|
||||
Bitrate Range
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-discord-text-muted mb-1 block">Min</label>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={draft.maxBitrateKbps - 500}
|
||||
step={100}
|
||||
value={draft.minBitrateKbps}
|
||||
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
|
||||
className="w-full h-1.5 accent-discord-blurple cursor-pointer appearance-none bg-[#4e5058] rounded-full
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-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"
|
||||
/>
|
||||
<div className="text-[11px] text-discord-text-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-[11px] text-discord-text-muted mb-1 block">Max</label>
|
||||
<input
|
||||
type="range"
|
||||
min={draft.minBitrateKbps + 500}
|
||||
max={50000}
|
||||
step={500}
|
||||
value={draft.maxBitrateKbps}
|
||||
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
|
||||
className="w-full h-1.5 accent-discord-blurple cursor-pointer appearance-none bg-[#4e5058] rounded-full
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-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"
|
||||
/>
|
||||
<div className="text-[11px] text-discord-text-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bitrate Step */}
|
||||
<div>
|
||||
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-1.5">
|
||||
Slider Step
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={5000}
|
||||
step={50}
|
||||
value={draft.bitrateStepKbps}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
|
||||
}}
|
||||
className="w-24 px-2 py-1 bg-discord-bg-tertiary rounded text-sm text-discord-text-primary outline-none focus:ring-1 focus:ring-discord-blurple"
|
||||
/>
|
||||
<span className="text-[12px] text-discord-text-muted">kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Resolutions */}
|
||||
<div>
|
||||
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-1.5">
|
||||
Allowed Resolutions
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{VALID_RESOLUTIONS.map((res) => (
|
||||
<button
|
||||
key={res}
|
||||
onClick={() => toggleResolution(res)}
|
||||
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
|
||||
>
|
||||
{res}p
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Frame Rates */}
|
||||
<div>
|
||||
<div className="text-[11px] text-discord-text-muted font-semibold uppercase tracking-wider mb-1.5">
|
||||
Allowed Frame Rates
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{VALID_FRAMERATES.map((fps) => (
|
||||
<button
|
||||
key={fps}
|
||||
onClick={() => toggleFramerate(fps)}
|
||||
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
|
||||
>
|
||||
{fps} fps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Reset */}
|
||||
{saveError && (
|
||||
<div className="p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-text-danger text-sm">{saveError}</div>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<div className="p-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm">Settings saved</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-1.5 text-sm text-discord-text-muted hover:text-discord-text-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerSettingsModal() {
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
@@ -19,9 +226,10 @@ export function ServerSettingsModal() {
|
||||
const deleteServer = useServerStore((s) => s.deleteServer);
|
||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isAdmin = useSettingsStore((s) => s.isAdmin);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [tab, setTab] = useState<'overview' | 'members'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'members' | 'streaming'>('overview');
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -146,6 +354,16 @@ export function ServerSettingsModal() {
|
||||
>
|
||||
Members
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setTab('streaming')}
|
||||
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
tab === 'streaming' ? 'bg-discord-bg-active text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
|
||||
}`}
|
||||
>
|
||||
Streaming
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -289,6 +507,10 @@ export function ServerSettingsModal() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'streaming' && isAdmin && (
|
||||
<StreamingLimitsPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { buildScreenShareOptions } from '../../utils/screenShare';
|
||||
|
||||
const BITRATE_MIN = 500; // kbps
|
||||
const BITRATE_MAX = 20000; // kbps
|
||||
const BITRATE_STEP = 500; // kbps
|
||||
|
||||
interface ScreenShareSettingsPopoverProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
|
||||
const ALL_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 }[] = [
|
||||
const ALL_FRAME_RATES: { value: ScreenShareConfig['fps']; label: string }[] = [
|
||||
{ value: 30, label: '30' },
|
||||
{ value: 45, label: '45' },
|
||||
{ value: 60, label: '60' },
|
||||
@@ -52,6 +49,18 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const config = useVoiceStore((s) => s.screenShareConfig);
|
||||
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
||||
const limits = useSettingsStore((s) => s.streamingLimits);
|
||||
|
||||
const BITRATE_MIN = limits?.minBitrateKbps ?? 500;
|
||||
const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000;
|
||||
const BITRATE_STEP = limits?.bitrateStepKbps ?? 500;
|
||||
|
||||
const RESOLUTIONS = ALL_RESOLUTIONS.filter((r) =>
|
||||
(limits?.allowedResolutions ?? [540, 720, 1080]).includes(r.value)
|
||||
);
|
||||
const FRAME_RATES = ALL_FRAME_RATES.filter((f) =>
|
||||
(limits?.allowedFramerates ?? [30, 45, 60]).includes(f.value)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -64,6 +73,29 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
||||
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 closest = limits.allowedResolutions.reduce((a, b) =>
|
||||
Math.abs(b - config.height) < Math.abs(a - config.height) ? b : a
|
||||
) as ScreenShareConfig['height'];
|
||||
patch.height = closest;
|
||||
}
|
||||
if (!limits.allowedFramerates.includes(config.fps)) {
|
||||
const closest = limits.allowedFramerates.reduce((a, b) =>
|
||||
Math.abs(b - config.fps) < Math.abs(a - config.fps) ? b : a
|
||||
) as ScreenShareConfig['fps'];
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user