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:
@@ -174,6 +174,18 @@ function createTables(db: Database.Database): void {
|
|||||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (folder_id, server_id)
|
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
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'owner_id', type: 'TEXT' }
|
{ 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 ─────────
|
// ─── RBAC Migration: Ensure @everyone roles exist for all servers ─────────
|
||||||
migrateEveryoneRoles(db);
|
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.');
|
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 */
|
/** For each server, ensure an @everyone role exists with id === server.id */
|
||||||
function migrateEveryoneRoles(db: Database.Database): void {
|
function migrateEveryoneRoles(db: Database.Database): void {
|
||||||
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
|
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const users = sqliteTable('users', {
|
|||||||
avatar: text('avatar'),
|
avatar: text('avatar'),
|
||||||
status: text('status').default('offline'),
|
status: text('status').default('offline'),
|
||||||
customStatus: text('custom_status'),
|
customStatus: text('custom_status'),
|
||||||
|
isAdmin: integer('is_admin').default(0),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,3 +174,15 @@ export const serverFolderMembers = sqliteTable('server_folder_members', {
|
|||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
pk: primaryKey({ columns: [table.folderId, table.serverId] }),
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export async function seedDatabase(): Promise<void> {
|
|||||||
displayName: 'Admin',
|
displayName: 'Admin',
|
||||||
passwordHash: adminPasswordHash,
|
passwordHash: adminPasswordHash,
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
|
isAdmin: 1,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { uploadRoutes } from './routes/uploads.js';
|
|||||||
import { dmRoutes } from './routes/dm.js';
|
import { dmRoutes } from './routes/dm.js';
|
||||||
import { livekitRoutes } from './routes/livekit.js';
|
import { livekitRoutes } from './routes/livekit.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
import { utilRoutes } from './routes/utils.js';
|
import { utilRoutes } from './routes/utils.js';
|
||||||
import { registerWebSocket } from './ws/handler.js';
|
import { registerWebSocket } from './ws/handler.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -72,6 +73,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(dmRoutes);
|
await app.register(dmRoutes);
|
||||||
await app.register(livekitRoutes);
|
await app.register(livekitRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,12 +68,17 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const userId = generateSnowflake();
|
const userId = generateSnowflake();
|
||||||
const now = Date.now();
|
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({
|
db.insert(schema.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
username: trimmedUsername,
|
username: trimmedUsername,
|
||||||
displayName: displayName?.trim() || null,
|
displayName: displayName?.trim() || null,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
isAdmin: isFirstUser ? 1 : 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
// 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<InstanceStreamingLimits> }>('/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<string, number | string> = { 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface User {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,3 +367,15 @@ export interface SendFriendRequest {
|
|||||||
export interface UpdateFriendRequest {
|
export interface UpdateFriendRequest {
|
||||||
status: 'accepted' | 'declined';
|
status: 'accepted' | 'declined';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Instance Settings Types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InstanceStreamingLimits {
|
||||||
|
maxBitrateKbps: number;
|
||||||
|
minBitrateKbps: number;
|
||||||
|
bitrateStepKbps: number;
|
||||||
|
allowedResolutions: number[];
|
||||||
|
allowedFramerates: number[];
|
||||||
|
maxResolution: number;
|
||||||
|
maxFramerate: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
CreateDmMessageRequest,
|
CreateDmMessageRequest,
|
||||||
Friend,
|
Friend,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
|
InstanceStreamingLimits,
|
||||||
} from '@opencord/shared';
|
} from '@opencord/shared';
|
||||||
|
|
||||||
const BASE_URL = '/api';
|
const BASE_URL = '/api';
|
||||||
@@ -193,4 +194,10 @@ export const api = {
|
|||||||
dmToken: (dmChannelId: string) =>
|
dmToken: (dmChannelId: string) =>
|
||||||
request<LiveKitTokenResponse>('POST', '/livekit/token', { dmChannelId }),
|
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,
|
avatar: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -212,6 +213,7 @@ describe('FriendsPage', () => {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -256,6 +258,7 @@ describe('FriendsPage', () => {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -295,6 +298,7 @@ describe('FriendsPage', () => {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,219 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
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() {
|
export function ServerSettingsModal() {
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
@@ -19,9 +226,10 @@ export function ServerSettingsModal() {
|
|||||||
const deleteServer = useServerStore((s) => s.deleteServer);
|
const deleteServer = useServerStore((s) => s.deleteServer);
|
||||||
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const isAdmin = useSettingsStore((s) => s.isAdmin);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [tab, setTab] = useState<'overview' | 'members'>('overview');
|
const [tab, setTab] = useState<'overview' | 'members' | 'streaming'>('overview');
|
||||||
const [serverName, setServerName] = useState('');
|
const [serverName, setServerName] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -146,6 +354,16 @@ export function ServerSettingsModal() {
|
|||||||
>
|
>
|
||||||
Members
|
Members
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -289,6 +507,10 @@ export function ServerSettingsModal() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'streaming' && isAdmin && (
|
||||||
|
<StreamingLimitsPanel />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
||||||
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { buildScreenShareOptions } from '../../utils/screenShare';
|
import { buildScreenShareOptions } from '../../utils/screenShare';
|
||||||
|
|
||||||
const BITRATE_MIN = 500; // kbps
|
|
||||||
const BITRATE_MAX = 20000; // kbps
|
|
||||||
const BITRATE_STEP = 500; // kbps
|
|
||||||
|
|
||||||
interface ScreenShareSettingsPopoverProps {
|
interface ScreenShareSettingsPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
|
const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
|
||||||
{ value: 540, label: '540p' },
|
{ value: 540, label: '540p' },
|
||||||
{ value: 720, label: '720p' },
|
{ value: 720, label: '720p' },
|
||||||
{ value: 1080, label: '1080p' },
|
{ 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: 30, label: '30' },
|
||||||
{ value: 45, label: '45' },
|
{ value: 45, label: '45' },
|
||||||
{ value: 60, label: '60' },
|
{ value: 60, label: '60' },
|
||||||
@@ -52,6 +49,18 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
|||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const config = useVoiceStore((s) => s.screenShareConfig);
|
const config = useVoiceStore((s) => s.screenShareConfig);
|
||||||
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -64,6 +73,29 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
|||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [open, onClose]);
|
}, [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;
|
if (!open) return null;
|
||||||
|
|
||||||
const result = buildScreenShareOptions(config);
|
const result = buildScreenShareOptions(config);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useServerStore } from '../stores/serverStore';
|
|||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { useSocialStore } from '../stores/socialStore';
|
import { useSocialStore } from '../stores/socialStore';
|
||||||
|
import { useSettingsStore } from '../stores/settingsStore';
|
||||||
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared';
|
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared';
|
||||||
|
|
||||||
let globalWs: WebSocket | null = null;
|
let globalWs: WebSocket | null = null;
|
||||||
@@ -60,6 +61,8 @@ function handleEvent(event: ServerEvent): void {
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
setUser(event.user);
|
setUser(event.user);
|
||||||
|
useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false);
|
||||||
|
useSettingsStore.getState().fetchStreamingLimits();
|
||||||
populateFromReady(event.servers, event.folders, event.dmChannels);
|
populateFromReady(event.servers, event.folders, event.dmChannels);
|
||||||
if (currentServerId) {
|
if (currentServerId) {
|
||||||
loadServerDetail(currentServerId);
|
loadServerDetail(currentServerId);
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Room, Track } from 'livekit-client';
|
import { Room, Track } from 'livekit-client';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import type { ScreenShareConfig } from '../stores/voiceStore';
|
import type { ScreenShareConfig } from '../stores/voiceStore';
|
||||||
|
import { getStreamingLimits } from '../stores/settingsStore';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { wsSend } from '../hooks/useWebSocket';
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
|
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 {
|
export function buildScreenShareOptions(config: ScreenShareConfig): ScreenShareBuildResult {
|
||||||
const { height, fps, mode, customBitrateKbps } = config;
|
const { height, fps, mode, customBitrateKbps } = config;
|
||||||
const width = WIDTH_MAP[height]!;
|
const width = WIDTH_MAP[height]!;
|
||||||
const maxBitrate = customBitrateKbps != null
|
const limits = getStreamingLimits();
|
||||||
|
const rawBitrate = customBitrateKbps != null
|
||||||
? customBitrateKbps * 1000
|
? customBitrateKbps * 1000
|
||||||
: BITRATE_MATRIX[height]![fps]!;
|
: 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);
|
const minBitrate = Math.round(maxBitrate * 0.25);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user