diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 8a2e8c6e..48b7bd00 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -174,6 +174,18 @@ function createTables(db: Database.Database): void { server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, PRIMARY KEY (folder_id, server_id) ); + + CREATE TABLE IF NOT EXISTS instance_settings ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000, + min_bitrate_kbps INTEGER NOT NULL DEFAULT 500, + bitrate_step_kbps INTEGER NOT NULL DEFAULT 500, + allowed_resolutions TEXT NOT NULL DEFAULT '540,720,1080', + allowed_framerates TEXT NOT NULL DEFAULT '30,45,60', + max_resolution INTEGER NOT NULL DEFAULT 1080, + max_framerate INTEGER NOT NULL DEFAULT 60, + updated_at INTEGER NOT NULL DEFAULT 0 + ); `); } diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 06624bcc..205b5323 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -48,6 +48,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'owner_id', type: 'TEXT' } ] + }, + { + name: 'users', + columns: [ + { name: 'is_admin', type: 'INTEGER DEFAULT 0' } + ] } ]; @@ -82,9 +88,38 @@ export function runMigrations(db: Database.Database): void { // ─── RBAC Migration: Ensure @everyone roles exist for all servers ───────── migrateEveryoneRoles(db); + // ─── Instance settings: ensure default row exists ────────────────────────── + migrateInstanceSettings(db); + + // ─── Admin flag: ensure at least one admin exists (first registered user) ── + migrateFirstAdmin(db); + console.log('Migrations complete.'); } +/** Ensure the single-row instance_settings row exists */ +function migrateInstanceSettings(db: Database.Database): void { + const row = db.prepare('SELECT id FROM instance_settings WHERE id = 1').get(); + if (!row) { + db.prepare( + 'INSERT OR IGNORE INTO instance_settings (id, max_bitrate_kbps, min_bitrate_kbps, bitrate_step_kbps, allowed_resolutions, allowed_framerates, max_resolution, max_framerate, updated_at) VALUES (1, 20000, 500, 500, ?, ?, 1080, 60, ?)' + ).run('540,720,1080', '30,45,60', Date.now()); + console.log('Migrating: Inserted default instance_settings row'); + } +} + +/** Ensure at least one user has is_admin = 1 (the earliest registered user) */ +function migrateFirstAdmin(db: Database.Database): void { + const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get(); + if (!anyAdmin) { + const firstUser = db.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1').get() as { id: string } | undefined; + if (firstUser) { + db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id); + console.log(`Migrating: Set first user ${firstUser.id} as instance admin`); + } + } +} + /** For each server, ensure an @everyone role exists with id === server.id */ function migrateEveryoneRoles(db: Database.Database): void { const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 63eaa17a..5b405d6b 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -8,6 +8,7 @@ export const users = sqliteTable('users', { avatar: text('avatar'), status: text('status').default('offline'), customStatus: text('custom_status'), + isAdmin: integer('is_admin').default(0), createdAt: integer('created_at').notNull(), }); @@ -173,3 +174,15 @@ export const serverFolderMembers = sqliteTable('server_folder_members', { }, (table) => ({ pk: primaryKey({ columns: [table.folderId, table.serverId] }), })); + +export const instanceSettings = sqliteTable('instance_settings', { + id: integer('id').primaryKey().default(1), + maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000), + minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500), + bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500), + allowedResolutions: text('allowed_resolutions').notNull().default('540,720,1080'), + allowedFramerates: text('allowed_framerates').notNull().default('30,45,60'), + maxResolution: integer('max_resolution').notNull().default(1080), + maxFramerate: integer('max_framerate').notNull().default(60), + updatedAt: integer('updated_at').notNull(), +}); diff --git a/packages/server/src/db/seed.ts b/packages/server/src/db/seed.ts index 5a41e810..56292500 100644 --- a/packages/server/src/db/seed.ts +++ b/packages/server/src/db/seed.ts @@ -24,6 +24,7 @@ export async function seedDatabase(): Promise { displayName: 'Admin', passwordHash: adminPasswordHash, status: 'offline', + isAdmin: 1, createdAt: Date.now(), }).run(); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 52204c3f..655e6ec6 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -16,6 +16,7 @@ import { uploadRoutes } from './routes/uploads.js'; import { dmRoutes } from './routes/dm.js'; import { livekitRoutes } from './routes/livekit.js'; import { socialRoutes } from './routes/social.js'; +import { settingsRoutes } from './routes/settings.js'; import { utilRoutes } from './routes/utils.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; @@ -72,6 +73,7 @@ async function main(): Promise { await app.register(dmRoutes); await app.register(livekitRoutes); await app.register(socialRoutes); + await app.register(settingsRoutes); await app.register(utilRoutes); await app.register(registerWebSocket); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 51ffb8e5..2263b955 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -14,6 +14,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } @@ -67,12 +68,17 @@ export async function authRoutes(app: FastifyInstance): Promise { const userId = generateSnowflake(); const now = Date.now(); + // First registered user becomes instance admin + const userCount = db.select().from(schema.users).all().length; + const isFirstUser = userCount === 0; + db.insert(schema.users).values({ id: userId, username: trimmedUsername, displayName: displayName?.trim() || null, passwordHash, status: 'online', + isAdmin: isFirstUser ? 1 : 0, createdAt: now, }).run(); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 159a5db9..6729677e 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -26,6 +26,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index f97ade51..abe92362 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -22,6 +22,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index 38904beb..ec5b65be 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -28,6 +28,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts new file mode 100644 index 00000000..3a9852e4 --- /dev/null +++ b/packages/server/src/routes/settings.ts @@ -0,0 +1,124 @@ +import type { FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import type { InstanceStreamingLimits } from '@opencord/shared'; + +const VALID_RESOLUTIONS = [540, 720, 1080]; +const VALID_FRAMERATES = [30, 45, 60]; + +function rowToLimits(row: typeof schema.instanceSettings.$inferSelect): InstanceStreamingLimits { + return { + maxBitrateKbps: row.maxBitrateKbps, + minBitrateKbps: row.minBitrateKbps, + bitrateStepKbps: row.bitrateStepKbps, + allowedResolutions: row.allowedResolutions.split(',').map(Number).filter((n) => VALID_RESOLUTIONS.includes(n)), + allowedFramerates: row.allowedFramerates.split(',').map(Number).filter((n) => VALID_FRAMERATES.includes(n)), + maxResolution: row.maxResolution, + maxFramerate: row.maxFramerate, + }; +} + +export async function settingsRoutes(app: FastifyInstance): Promise { + // GET /api/settings/streaming — any authenticated user can read instance limits + app.get('/api/settings/streaming', { preHandler: authenticate }, async (_request, reply) => { + const db = getDb(); + const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + if (!row) { + return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 }); + } + return reply.code(200).send(rowToLimits(row)); + }); + + // PATCH /api/settings/streaming — admin only + app.patch<{ Body: Partial }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => { + const db = getDb(); + + // Verify caller is an instance admin + const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!caller || caller.isAdmin !== 1) { + return reply.code(403).send({ error: 'Only instance admins can modify streaming settings', statusCode: 403 }); + } + + const body = request.body; + const updateData: Record = { updatedAt: Date.now() }; + + if (body.maxBitrateKbps !== undefined) { + if (typeof body.maxBitrateKbps !== 'number' || body.maxBitrateKbps < 500 || body.maxBitrateKbps > 50000) { + return reply.code(400).send({ error: 'maxBitrateKbps must be between 500 and 50000', statusCode: 400 }); + } + updateData.maxBitrateKbps = body.maxBitrateKbps; + } + + if (body.minBitrateKbps !== undefined) { + if (typeof body.minBitrateKbps !== 'number' || body.minBitrateKbps < 100 || body.minBitrateKbps > 50000) { + return reply.code(400).send({ error: 'minBitrateKbps must be between 100 and 50000', statusCode: 400 }); + } + updateData.minBitrateKbps = body.minBitrateKbps; + } + + if (body.bitrateStepKbps !== undefined) { + if (typeof body.bitrateStepKbps !== 'number' || body.bitrateStepKbps < 50 || body.bitrateStepKbps > 5000) { + return reply.code(400).send({ error: 'bitrateStepKbps must be between 50 and 5000', statusCode: 400 }); + } + updateData.bitrateStepKbps = body.bitrateStepKbps; + } + + if (body.allowedResolutions !== undefined) { + if (!Array.isArray(body.allowedResolutions) || body.allowedResolutions.length === 0) { + return reply.code(400).send({ error: 'allowedResolutions must be a non-empty array', statusCode: 400 }); + } + const invalid = body.allowedResolutions.filter((r) => !VALID_RESOLUTIONS.includes(r)); + if (invalid.length > 0) { + return reply.code(400).send({ error: `Invalid resolutions: ${invalid.join(', ')}. Allowed: ${VALID_RESOLUTIONS.join(', ')}`, statusCode: 400 }); + } + updateData.allowedResolutions = body.allowedResolutions.sort((a, b) => a - b).join(','); + } + + if (body.allowedFramerates !== undefined) { + if (!Array.isArray(body.allowedFramerates) || body.allowedFramerates.length === 0) { + return reply.code(400).send({ error: 'allowedFramerates must be a non-empty array', statusCode: 400 }); + } + const invalid = body.allowedFramerates.filter((f) => !VALID_FRAMERATES.includes(f)); + if (invalid.length > 0) { + return reply.code(400).send({ error: `Invalid framerates: ${invalid.join(', ')}. Allowed: ${VALID_FRAMERATES.join(', ')}`, statusCode: 400 }); + } + updateData.allowedFramerates = body.allowedFramerates.sort((a, b) => a - b).join(','); + } + + if (body.maxResolution !== undefined) { + if (!VALID_RESOLUTIONS.includes(body.maxResolution)) { + return reply.code(400).send({ error: `maxResolution must be one of: ${VALID_RESOLUTIONS.join(', ')}`, statusCode: 400 }); + } + updateData.maxResolution = body.maxResolution; + } + + if (body.maxFramerate !== undefined) { + if (!VALID_FRAMERATES.includes(body.maxFramerate)) { + return reply.code(400).send({ error: `maxFramerate must be one of: ${VALID_FRAMERATES.join(', ')}`, statusCode: 400 }); + } + updateData.maxFramerate = body.maxFramerate; + } + + // Cross-field validation: min < max + const currentRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + if (!currentRow) { + return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 }); + } + + const effectiveMin = (updateData.minBitrateKbps as number | undefined) ?? currentRow.minBitrateKbps; + const effectiveMax = (updateData.maxBitrateKbps as number | undefined) ?? currentRow.maxBitrateKbps; + if (effectiveMin >= effectiveMax) { + return reply.code(400).send({ error: 'minBitrateKbps must be less than maxBitrateKbps', statusCode: 400 }); + } + + db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run(); + + const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + if (!updatedRow) { + return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 }); + } + + return reply.code(200).send(rowToLimits(updatedRow)); + }); +} diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 1def8fd2..0818464f 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -20,6 +20,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index b5beab49..a313e0d5 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -13,6 +13,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index b3c923a2..11a20f0a 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -15,6 +15,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 21a4b66a..0295120c 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -38,6 +38,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { avatar: row.avatar, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, + isAdmin: row.isAdmin === 1, createdAt: row.createdAt, }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 47b3b798..dff5ff69 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -7,6 +7,7 @@ export interface User { avatar: string | null; status: UserStatus; customStatus: string | null; + isAdmin: boolean; createdAt: number; } @@ -366,3 +367,15 @@ export interface SendFriendRequest { export interface UpdateFriendRequest { status: 'accepted' | 'declined'; } + +// ─── Instance Settings Types ──────────────────────────────────────────────── + +export interface InstanceStreamingLimits { + maxBitrateKbps: number; + minBitrateKbps: number; + bitrateStepKbps: number; + allowedResolutions: number[]; + allowedFramerates: number[]; + maxResolution: number; + maxFramerate: number; +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index c2bfad66..ae52192b 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -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('POST', '/livekit/token', { dmChannelId }), }, + + settings: { + getStreaming: () => request('GET', '/settings/streaming'), + updateStreaming: (data: Partial) => + request('PATCH', '/settings/streaming', data), + }, }; diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index 57495ff7..ea859355 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -59,6 +59,7 @@ const makeRequest = (overrides: Partial = {}): 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(), }, }); diff --git a/packages/web/src/components/modals/ServerSettings.tsx b/packages/web/src/components/modals/ServerSettings.tsx index 93315a5d..8a928365 100644 --- a/packages/web/src/components/modals/ServerSettings.tsx +++ b/packages/web/src/components/modals/ServerSettings.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [saveSuccess, setSaveSuccess] = useState(false); + + useEffect(() => { + if (limits) setDraft({ ...limits }); + }, [limits]); + + if (!draft) return
Loading settings...
; + + 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 ( +
+
+ These limits apply to all users on this instance. Users can pick values within these bounds. +
+ + {/* Bitrate Range */} +
+
+ Bitrate Range +
+
+
+ + 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" + /> +
{formatKbps(draft.minBitrateKbps)}
+
+
+ + 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" + /> +
{formatKbps(draft.maxBitrateKbps)}
+
+
+
+ + {/* Bitrate Step */} +
+
+ Slider Step +
+
+ { + 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" + /> + kbps +
+
+ + {/* Allowed Resolutions */} +
+
+ Allowed Resolutions +
+
+ {VALID_RESOLUTIONS.map((res) => ( + + ))} +
+
+ + {/* Allowed Frame Rates */} +
+
+ Allowed Frame Rates +
+
+ {VALID_FRAMERATES.map((fps) => ( + + ))} +
+
+ + {/* Save / Reset */} + {saveError && ( +
{saveError}
+ )} + {saveSuccess && ( +
Settings saved
+ )} + {hasChanges && ( +
+ + +
+ )} +
+ ); +} 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 + {isAdmin && ( + + )} {/* Content */} @@ -289,6 +507,10 @@ export function ServerSettingsModal() { })} )} + + {tab === 'streaming' && isAdmin && ( + + )} diff --git a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx index d81683c3..0acaa92f 100644 --- a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx +++ b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx @@ -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(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 = {}; + 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); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 91c1eb09..caef7452 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -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); diff --git a/packages/web/src/stores/settingsStore.ts b/packages/web/src/stores/settingsStore.ts new file mode 100644 index 00000000..745e1789 --- /dev/null +++ b/packages/web/src/stores/settingsStore.ts @@ -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; + updateStreamingLimits: (limits: Partial) => Promise; + 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((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) => { + const updated = await api.settings.updateStreaming(limits); + set({ streamingLimits: updated }); + }, + + setIsAdmin: (isAdmin: boolean) => set({ isAdmin }), +})); diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts index cfd8762d..bb61e80c 100644 --- a/packages/web/src/utils/screenShare.ts +++ b/packages/web/src/utils/screenShare.ts @@ -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 = { 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 {