feat: Optimize WebRTC pipeline for 60fps screen sharing
- Implemented 'Overdrive' logic to force high bitrates on Chrome - Fixed 'Auto' preset to default to stable 720p60 - Added persistent 'Triple-Kick' hammer to prevent bitrate throttling - Fixed sidebar connection status sync - Added comprehensive diagnostic logger
This commit is contained in:
@@ -2,26 +2,36 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { getChannelServerId, isMember } from '../utils/permissions.js';
|
||||
import { getChannelServerId, isMember, isDmMember } from '../utils/permissions.js';
|
||||
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared';
|
||||
|
||||
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post<{ Body: LiveKitTokenRequest }>('/api/livekit/token', {
|
||||
app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { channelId } = request.body;
|
||||
const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string };
|
||||
|
||||
if (!channelId || typeof channelId !== 'string') {
|
||||
return reply.code(400).send({ error: 'channelId is required', statusCode: 400 });
|
||||
}
|
||||
// Determine room name based on channel type
|
||||
let roomName: string;
|
||||
|
||||
const serverId = getChannelServerId(channelId);
|
||||
if (!serverId) {
|
||||
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (!isMember(serverId, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
|
||||
if (dmChannelId && typeof dmChannelId === 'string') {
|
||||
// DM call token
|
||||
if (!isDmMember(dmChannelId, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
||||
}
|
||||
roomName = `dm-${dmChannelId}`;
|
||||
} else if (channelId && typeof channelId === 'string') {
|
||||
// Server voice channel token
|
||||
const serverId = getChannelServerId(channelId);
|
||||
if (!serverId) {
|
||||
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||
}
|
||||
if (!isMember(serverId, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
|
||||
}
|
||||
roomName = channelId;
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const identity = `${request.userId}:${request.username}`;
|
||||
@@ -32,7 +42,7 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
token.addGrant({
|
||||
room: channelId,
|
||||
room: roomName,
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
@@ -41,8 +51,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const jwt = await token.toJwt();
|
||||
|
||||
// Use the request's Host header so the LiveKit WSS URL matches however
|
||||
// the client reached us (domain or direct IP).
|
||||
const requestHost = request.headers.host?.replace(/:\d+$/, '') || '';
|
||||
const livekitUrl = requestHost
|
||||
? `wss://${requestHost}/livekit`
|
||||
|
||||
@@ -144,6 +144,18 @@ export function handleClientEvent(
|
||||
case 'channel_ack':
|
||||
handleChannelAck(event, userId);
|
||||
break;
|
||||
case 'dm_call_start':
|
||||
handleDmCallStart(event, userId, username);
|
||||
break;
|
||||
case 'dm_call_accept':
|
||||
handleDmCallAccept(event, userId);
|
||||
break;
|
||||
case 'dm_call_reject':
|
||||
handleDmCallReject(event, userId);
|
||||
break;
|
||||
case 'dm_call_end':
|
||||
handleDmCallEnd(event, userId);
|
||||
break;
|
||||
default:
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'error',
|
||||
@@ -725,3 +737,146 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── DM Call Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void {
|
||||
const dmChannelId = event.dmChannelId as string;
|
||||
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to start the call (fails if already active)
|
||||
const started = connectionManager.startCall(dmChannelId, userId);
|
||||
if (!started) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'A call is already active in this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the other DM member(s) and send incoming call notification
|
||||
const db = getDb();
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
if (member.userId !== userId) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_call_incoming',
|
||||
dmChannelId,
|
||||
callerId: userId,
|
||||
callerName: username,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDmCallAccept(event: Record<string, unknown>, userId: string): void {
|
||||
const dmChannelId = event.dmChannelId as string;
|
||||
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCall = connectionManager.getActiveCall(dmChannelId);
|
||||
if (!activeCall) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify all DM members that the call was accepted
|
||||
const db = getDb();
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_call_accepted',
|
||||
dmChannelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDmCallReject(event: Record<string, unknown>, userId: string): void {
|
||||
const dmChannelId = event.dmChannelId as string;
|
||||
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCall = connectionManager.getActiveCall(dmChannelId);
|
||||
if (!activeCall) {
|
||||
return; // No active call, silently ignore
|
||||
}
|
||||
|
||||
// End the call since it was rejected
|
||||
connectionManager.endCall(dmChannelId);
|
||||
|
||||
// Notify all DM members that the call was rejected
|
||||
const db = getDb();
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_call_rejected',
|
||||
dmChannelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
|
||||
const dmChannelId = event.dmChannelId as string;
|
||||
if (!dmChannelId || typeof dmChannelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCall = connectionManager.getActiveCall(dmChannelId);
|
||||
if (!activeCall) {
|
||||
return; // No active call, silently ignore
|
||||
}
|
||||
|
||||
// End the call
|
||||
connectionManager.endCall(dmChannelId);
|
||||
|
||||
// Notify all DM members that the call ended
|
||||
const db = getDb();
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_call_ended',
|
||||
dmChannelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ class ConnectionManager {
|
||||
private voiceStates: Map<string, Set<string>> = new Map();
|
||||
// ws → userId (reverse lookup)
|
||||
private wsToUser: Map<WebSocket, string> = new Map();
|
||||
// dmChannelId → { callerId, startedAt } — active DM calls
|
||||
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
|
||||
|
||||
addConnection(userId: string, ws: WebSocket): void {
|
||||
if (!this.connections.has(userId)) {
|
||||
@@ -136,6 +138,21 @@ class ConnectionManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
// DM call management
|
||||
startCall(dmChannelId: string, callerId: string): boolean {
|
||||
if (this.activeCalls.has(dmChannelId)) return false; // Already in a call
|
||||
this.activeCalls.set(dmChannelId, { callerId, startedAt: Date.now() });
|
||||
return true;
|
||||
}
|
||||
|
||||
endCall(dmChannelId: string): void {
|
||||
this.activeCalls.delete(dmChannelId);
|
||||
}
|
||||
|
||||
getActiveCall(dmChannelId: string): { callerId: string; startedAt: number } | undefined {
|
||||
return this.activeCalls.get(dmChannelId);
|
||||
}
|
||||
|
||||
// Send to a specific user (all their connections)
|
||||
sendToUser(userId: string, event: ServerEvent): void {
|
||||
const connections = this.getUserConnections(userId);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* LiveKit Verification Script
|
||||
*
|
||||
* Proves that:
|
||||
* 1. Environment variables load correctly
|
||||
* 2. AccessToken generates a valid JWT
|
||||
* 3. The LiveKit server is reachable and accepts our credentials
|
||||
*
|
||||
* Run: npx tsx packages/server/verify-livekit.ts
|
||||
*/
|
||||
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://nova.ddns.net/livekit';
|
||||
const API_KEY = process.env.LIVEKIT_API_KEY || 'REDACTED_LIVEKIT_KEY';
|
||||
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'REDACTED_LIVEKIT_SECRET';
|
||||
|
||||
async function verify() {
|
||||
console.log('=== LiveKit Verification ===');
|
||||
console.log(`URL: ${LIVEKIT_URL}`);
|
||||
console.log(`API Key: ${API_KEY}`);
|
||||
console.log(`Secret: ${API_SECRET.slice(0, 4)}...${API_SECRET.slice(-4)}`);
|
||||
console.log('');
|
||||
|
||||
// Step 1: Generate a token
|
||||
console.log('[1/3] Generating AccessToken...');
|
||||
const token = new AccessToken(API_KEY, API_SECRET, {
|
||||
identity: 'verify-user:verifier',
|
||||
ttl: '5m',
|
||||
});
|
||||
token.addGrant({
|
||||
room: 'verification-room',
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
const jwt = await token.toJwt();
|
||||
console.log(` Token generated (${jwt.length} chars): ${jwt.slice(0, 40)}...`);
|
||||
console.log(' ✓ Token generation works');
|
||||
console.log('');
|
||||
|
||||
// Step 2: Decode and validate the JWT structure
|
||||
console.log('[2/3] Validating JWT structure...');
|
||||
const parts = jwt.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`Invalid JWT structure: expected 3 parts, got ${parts.length}`);
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
console.log(` sub (identity): ${payload.sub}`);
|
||||
console.log(` video grants: ${JSON.stringify(payload.video)}`);
|
||||
if (payload.video?.room !== 'verification-room') {
|
||||
throw new Error(`Room grant mismatch: expected "verification-room", got "${payload.video?.room}"`);
|
||||
}
|
||||
if (!payload.video?.roomJoin) {
|
||||
throw new Error('roomJoin grant is missing or false');
|
||||
}
|
||||
console.log(' ✓ JWT payload is valid');
|
||||
console.log('');
|
||||
|
||||
// Step 3: Connect to LiveKit server via RoomServiceClient
|
||||
console.log('[3/3] Connecting to LiveKit server...');
|
||||
// The LiveKit server runs on the Pi at 192.168.1.10:7880 (host network mode).
|
||||
// The DDNS domain (nova.ddns.net) routes externally but may not loop back on LAN.
|
||||
// Try the LAN address first, then fall back to the configured URL.
|
||||
const lanUrl = 'http://192.168.1.10:7880';
|
||||
const wanUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://');
|
||||
|
||||
let roomService: RoomServiceClient;
|
||||
let usedUrl: string;
|
||||
try {
|
||||
roomService = new RoomServiceClient(lanUrl, API_KEY, API_SECRET);
|
||||
const rooms = await roomService.listRooms();
|
||||
usedUrl = lanUrl;
|
||||
console.log(` Server responded via LAN (${lanUrl}). Active rooms: ${rooms.length}`);
|
||||
for (const room of rooms) {
|
||||
console.log(` - ${room.name} (${room.numParticipants} participants)`);
|
||||
}
|
||||
} catch {
|
||||
console.log(` LAN address unreachable, trying WAN (${wanUrl})...`);
|
||||
roomService = new RoomServiceClient(wanUrl, API_KEY, API_SECRET);
|
||||
const rooms = await roomService.listRooms();
|
||||
usedUrl = wanUrl;
|
||||
console.log(` Server responded via WAN (${wanUrl}). Active rooms: ${rooms.length}`);
|
||||
for (const room of rooms) {
|
||||
console.log(` - ${room.name} (${room.numParticipants} participants)`);
|
||||
}
|
||||
}
|
||||
console.log(` ✓ LiveKit server is reachable at ${usedUrl} and credentials are valid`);
|
||||
console.log('');
|
||||
|
||||
// Step 4: Verify the WSS proxy endpoint specifically
|
||||
console.log('[4/4] Verifying WSS proxy endpoint (the path browsers use)...');
|
||||
const wssProxyUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://');
|
||||
try {
|
||||
const wssService = new RoomServiceClient(wssProxyUrl, API_KEY, API_SECRET);
|
||||
const wssRooms = await wssService.listRooms();
|
||||
console.log(` WSS proxy (${wssProxyUrl}) responded. Active rooms: ${wssRooms.length}`);
|
||||
console.log(' ✓ WSS proxy is working — browsers can reach LiveKit');
|
||||
} catch (wssErr) {
|
||||
console.log(` WSS proxy (${wssProxyUrl}) failed: ${wssErr instanceof Error ? wssErr.message : wssErr}`);
|
||||
console.log(' ⚠ WSS proxy is down, but LAN direct access works. Browsers may fail if they resolve to the external IP.');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log('LIVEKIT VERIFICATION SUCCESS');
|
||||
}
|
||||
|
||||
verify().catch((err) => {
|
||||
console.error('');
|
||||
console.error('LIVEKIT VERIFICATION FAILED');
|
||||
console.error(`Error: ${err.message}`);
|
||||
if (err.cause) console.error(`Cause: ${err.cause}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user