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:
Jannis Braun
2026-02-26 03:33:29 +01:00
parent 2184ded2c1
commit 773a03b1aa
22 changed files with 541 additions and 9 deletions
+7
View File
@@ -25,6 +25,7 @@ import type {
CreateDmMessageRequest,
Friend,
FriendRequest,
InstanceStreamingLimits,
} from '@opencord/shared';
const BASE_URL = '/api';
@@ -193,4 +194,10 @@ export const api = {
dmToken: (dmChannelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { dmChannelId }),
},
settings: {
getStreaming: () => request<InstanceStreamingLimits>('GET', '/settings/streaming'),
updateStreaming: (data: Partial<InstanceStreamingLimits>) =>
request<InstanceStreamingLimits>('PATCH', '/settings/streaming', data),
},
};
@@ -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);
+3
View File
@@ -4,6 +4,7 @@ import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore';
import { useSettingsStore } from '../stores/settingsStore';
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared';
let globalWs: WebSocket | null = null;
@@ -60,6 +61,8 @@ function handleEvent(event: ServerEvent): void {
switch (event.type) {
case 'ready':
setUser(event.user);
useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false);
useSettingsStore.getState().fetchStreamingLimits();
populateFromReady(event.servers, event.folders, event.dmChannels);
if (currentServerId) {
loadServerDetail(currentServerId);
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import type { InstanceStreamingLimits } from '@opencord/shared';
import { api } from '../api/client';
interface SettingsState {
streamingLimits: InstanceStreamingLimits | null;
isAdmin: boolean;
fetchStreamingLimits: () => Promise<void>;
updateStreamingLimits: (limits: Partial<InstanceStreamingLimits>) => Promise<void>;
setIsAdmin: (isAdmin: boolean) => void;
}
const DEFAULT_LIMITS: InstanceStreamingLimits = {
maxBitrateKbps: 20000,
minBitrateKbps: 500,
bitrateStepKbps: 500,
allowedResolutions: [540, 720, 1080],
allowedFramerates: [30, 45, 60],
maxResolution: 1080,
maxFramerate: 60,
};
export function getStreamingLimits(): InstanceStreamingLimits {
return useSettingsStore.getState().streamingLimits ?? DEFAULT_LIMITS;
}
export const useSettingsStore = create<SettingsState>((set) => ({
streamingLimits: null,
isAdmin: false,
fetchStreamingLimits: async () => {
try {
const limits = await api.settings.getStreaming();
set({ streamingLimits: limits });
} catch (err) {
console.warn('[Settings] Failed to fetch streaming limits, using defaults:', err);
set({ streamingLimits: DEFAULT_LIMITS });
}
},
updateStreamingLimits: async (limits: Partial<InstanceStreamingLimits>) => {
const updated = await api.settings.updateStreaming(limits);
set({ streamingLimits: updated });
},
setIsAdmin: (isAdmin: boolean) => set({ isAdmin }),
}));
+5 -1
View File
@@ -1,6 +1,7 @@
import { Room, Track } from 'livekit-client';
import { useVoiceStore } from '../stores/voiceStore';
import type { ScreenShareConfig } from '../stores/voiceStore';
import { getStreamingLimits } from '../stores/settingsStore';
import { AudioManager } from '../audio/AudioManager';
import { wsSend } from '../hooks/useWebSocket';
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
@@ -55,9 +56,12 @@ const WIDTH_MAP: Record<number, number> = { 1080: 1920, 720: 1280, 540: 960 };
export function buildScreenShareOptions(config: ScreenShareConfig): ScreenShareBuildResult {
const { height, fps, mode, customBitrateKbps } = config;
const width = WIDTH_MAP[height]!;
const maxBitrate = customBitrateKbps != null
const limits = getStreamingLimits();
const rawBitrate = customBitrateKbps != null
? customBitrateKbps * 1000
: BITRATE_MATRIX[height]![fps]!;
// Clamp to instance-level admin limits
const maxBitrate = Math.min(Math.max(rawBitrate, limits.minBitrateKbps * 1000), limits.maxBitrateKbps * 1000);
const minBitrate = Math.round(maxBitrate * 0.25);
return {