Spaces:
Running
Running
| /* eslint-disable @typescript-eslint/no-unused-vars */ | |
| /** | |
| * Video Manager - Multiple Video Instances | |
| * Manages multiple video instances, each with their own streaming state | |
| */ | |
| import { video as videoClient } from '@robothub/transport-server-client'; | |
| import type { video as videoTypes } from '@robothub/transport-server-client'; | |
| import { generateName } from "$lib/utils/generateName"; | |
| import type { Positionable, Position3D } from '$lib/types/positionable'; | |
| import { positionManager } from '$lib/utils/positionManager'; | |
| import { settings } from '$lib/runes/settings.svelte'; | |
| /** | |
| * Individual video instance state | |
| */ | |
| export class VideoInstance implements Positionable { | |
| public id: string; | |
| public name: string; | |
| // Input state (what this video is viewing) | |
| input = $state({ | |
| type: null as 'local-camera' | 'remote-stream' | null, | |
| stream: null as MediaStream | null, | |
| client: null as videoTypes.VideoConsumer | null, | |
| roomId: null as string | null, | |
| // Connection lifecycle state | |
| connectionState: 'disconnected' as 'disconnected' | 'connecting' | 'connected' | 'prepared' | 'paused', | |
| preparedRoomId: null as string | null, | |
| // Connection policy - determines if connection should persist or can be paused | |
| connectionPolicy: 'persistent' as 'persistent' | 'lazy', | |
| }); | |
| // Output state (what this video is broadcasting) | |
| output = $state({ | |
| active: false, | |
| client: null as videoTypes.VideoProducer | null, | |
| roomId: null as string | null, | |
| }); | |
| // Position (reactive and bindable) | |
| position = $state<Position3D>({ x: 0, y: 0, z: 0 }); | |
| constructor(id: string, name?: string) { | |
| this.id = id; | |
| this.name = name || `Video ${id}`; | |
| } | |
| /** | |
| * Update position (implements Positionable interface) | |
| */ | |
| updatePosition(newPosition: Position3D): void { | |
| this.position = { ...newPosition }; | |
| } | |
| // Derived state - simplified to prevent reactive loops | |
| get hasInput(): boolean { | |
| return this.input.type !== null && this.input.stream !== null; | |
| } | |
| get hasOutput(): boolean { | |
| return this.output.active; | |
| } | |
| get canOutput(): boolean { | |
| // Can only output if input is local camera (not remote stream) | |
| return this.input.type === 'local-camera' && this.input.stream !== null; | |
| } | |
| get currentStream(): MediaStream | null { | |
| return this.input.stream; | |
| } | |
| get status() { | |
| // Return a stable object reference to prevent infinite loops | |
| // Only create new object when values actually change | |
| const hasInput = this.hasInput; | |
| const hasOutput = this.hasOutput; | |
| const inputType = this.input.type; | |
| const outputRoomId = this.output.roomId; | |
| const inputRoomId = this.input.roomId; | |
| const connectionState = this.input.connectionState; | |
| const preparedRoomId = this.input.preparedRoomId; | |
| const connectionPolicy = this.input.connectionPolicy; | |
| const canActivate = (connectionState === 'prepared' || connectionState === 'paused') && preparedRoomId !== null; | |
| const canPause = connectionState === 'connected' && connectionPolicy === 'lazy'; | |
| return { | |
| id: this.id, | |
| name: this.name, | |
| hasInput, | |
| hasOutput, | |
| inputType, | |
| outputRoomId, | |
| inputRoomId, | |
| connectionState, | |
| preparedRoomId, | |
| connectionPolicy, | |
| canActivate, | |
| canPause, | |
| }; | |
| } | |
| } | |
| /** | |
| * Video status information for UI components | |
| */ | |
| export interface VideoStatus { | |
| id: string; | |
| name: string; | |
| hasInput: boolean; | |
| hasOutput: boolean; | |
| inputType: 'local-camera' | 'remote-stream' | null; | |
| outputRoomId: string | null; | |
| inputRoomId: string | null; | |
| connectionState: 'disconnected' | 'connecting' | 'connected' | 'prepared' | 'paused'; | |
| preparedRoomId: string | null; | |
| connectionPolicy: 'persistent' | 'lazy'; | |
| canActivate: boolean; | |
| canPause: boolean; | |
| } | |
| /** | |
| * Central manager for all video instances | |
| */ | |
| export class VideoManager { | |
| private _videos = $state<VideoInstance[]>([]); | |
| // Room listing state (shared across all videos) - using transport server | |
| rooms = $state<videoTypes.RoomInfo[]>([]); | |
| roomsLoading = $state(false); | |
| // Reactive getters - simplified to prevent loops | |
| get videos(): VideoInstance[] { | |
| return this._videos; | |
| } | |
| get videosWithInput(): VideoInstance[] { | |
| return this._videos.filter((video) => video.hasInput); | |
| } | |
| get videosWithOutput(): VideoInstance[] { | |
| return this._videos.filter((video) => video.hasOutput); | |
| } | |
| /** | |
| * Create a new video instance | |
| */ | |
| createVideo(id?: string, name?: string, position?: Position3D): VideoInstance { | |
| const videoId = id || generateName(); | |
| // Check if video already exists | |
| if (this._videos.find((v) => v.id === videoId)) { | |
| throw new Error(`Video with ID ${videoId} already exists`); | |
| } | |
| // Create video instance | |
| const video = new VideoInstance(videoId, name); | |
| // Set position (from position manager if not provided) | |
| video.position = position || positionManager.getNextPosition(); | |
| // Add to reactive array | |
| this._videos.push(video); | |
| console.log(`Created video ${videoId} at position (${video.position.x.toFixed(1)}, ${video.position.y.toFixed(1)}, ${video.position.z.toFixed(1)}). Total videos: ${this._videos.length}`); | |
| return video; | |
| } | |
| /** | |
| * Get video by ID | |
| */ | |
| getVideo(id: string): VideoInstance | undefined { | |
| return this._videos.find((v) => v.id === id); | |
| } | |
| /** | |
| * Get video status by ID | |
| */ | |
| getVideoStatus(id: string): VideoStatus | undefined { | |
| const video = this.getVideo(id); | |
| return video?.status; | |
| } | |
| /** | |
| * Remove a video | |
| */ | |
| async removeVideo(id: string): Promise<void> { | |
| const videoIndex = this._videos.findIndex((v) => v.id === id); | |
| if (videoIndex === -1) return; | |
| const video = this._videos[videoIndex]; | |
| // Clean up video resources | |
| await this.disconnectVideoInput(id); | |
| await this.stopVideoOutput(id); | |
| // Remove from reactive array | |
| this._videos.splice(videoIndex, 1); | |
| console.log(`Removed video ${id}. Remaining videos: ${this._videos.length}`); | |
| } | |
| // ============= ROOM MANAGEMENT ============= | |
| async listRooms(workspaceId: string): Promise<videoTypes.RoomInfo[]> { | |
| this.roomsLoading = true; | |
| try { | |
| const client = new videoClient.VideoClientCore(settings.transportServerUrl); | |
| const rooms = await client.listRooms(workspaceId); | |
| this.rooms = rooms; | |
| return rooms; | |
| } catch (error) { | |
| console.error('Failed to list rooms:', error); | |
| this.rooms = []; | |
| return []; | |
| } finally { | |
| this.roomsLoading = false; | |
| } | |
| } | |
| async refreshRooms(workspaceId: string): Promise<void> { | |
| await this.listRooms(workspaceId); | |
| } | |
| async createVideoRoom(workspaceId: string, roomId?: string): Promise<{ success: boolean; roomId?: string; error?: string }> { | |
| try { | |
| const client = new videoClient.VideoClientCore(settings.transportServerUrl); | |
| const result = await client.createRoom(workspaceId, roomId); | |
| // Refresh rooms list to include the new room | |
| await this.refreshRooms(workspaceId); | |
| return { success: true, roomId: result.roomId }; | |
| } catch (error) { | |
| console.error('Failed to create video room:', error); | |
| return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; | |
| } | |
| } | |
| generateRoomId(videoId: string): string { | |
| return `${videoId}-${generateName()}`; | |
| } | |
| /** | |
| * Start video output to an existing room | |
| */ | |
| async startVideoOutputToRoom(workspaceId: string, videoId: string, roomId: string): Promise<{ success: boolean; error?: string }> { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| return { success: false, error: `Video ${videoId} not found` }; | |
| } | |
| if (!video.canOutput) { | |
| return { success: false, error: 'Cannot output - input must be local camera' }; | |
| } | |
| try { | |
| const producer = new videoClient.VideoProducer(settings.transportServerUrl); | |
| const connected = await producer.connect(workspaceId, roomId, 'producer-id'); | |
| if (!connected) { | |
| throw new Error('Failed to connect to room'); | |
| } | |
| // Start camera streaming - VideoProducer creates its own stream | |
| await producer.startCamera({ | |
| video: { width: 1280, height: 720 }, | |
| audio: true | |
| }); | |
| // Update output state | |
| video.output.active = true; | |
| video.output.client = producer; | |
| video.output.roomId = roomId; | |
| console.log(`Video output started to room ${roomId} for video ${videoId}`); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error(`Failed to start video output for ${videoId}:`, error); | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| } | |
| /** | |
| * Create a new room and start video output as producer | |
| */ | |
| async startVideoOutputAsProducer(workspaceId: string, videoId: string, roomId?: string): Promise<{ success: boolean; roomId?: string; error?: string }> { | |
| try { | |
| // Create room first if roomId provided, otherwise generate one | |
| const finalRoomId = roomId || this.generateRoomId(videoId); | |
| const createResult = await this.createVideoRoom(workspaceId, finalRoomId); | |
| if (!createResult.success) { | |
| return createResult; | |
| } | |
| // Start output to the new room | |
| const outputResult = await this.startVideoOutputToRoom(workspaceId, videoId, createResult.roomId!); | |
| if (!outputResult.success) { | |
| return { success: false, error: outputResult.error }; | |
| } | |
| return { success: true, roomId: createResult.roomId }; | |
| } catch (error) { | |
| return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; | |
| } | |
| } | |
| // ============= INPUT MANAGEMENT ============= | |
| /** | |
| * Prepare a remote stream connection (stores roomId without connecting) | |
| */ | |
| prepareRemoteStream(videoId: string, roomId: string, policy: 'persistent' | 'lazy' = 'lazy'): { success: boolean; error?: string } { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| return { success: false, error: `Video ${videoId} not found` }; | |
| } | |
| video.input.preparedRoomId = roomId; | |
| video.input.connectionState = 'prepared'; | |
| video.input.connectionPolicy = policy; | |
| console.log(`Prepared remote stream for video ${videoId}, roomId: ${roomId}, policy: ${policy}`); | |
| return { success: true }; | |
| } | |
| /** | |
| * Activate a prepared or paused remote stream connection | |
| */ | |
| async activateRemoteStream(videoId: string, workspaceId: string): Promise<{ success: boolean; error?: string }> { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| return { success: false, error: `Video ${videoId} not found` }; | |
| } | |
| if (!video.input.preparedRoomId) { | |
| return { success: false, error: 'No prepared room ID to activate' }; | |
| } | |
| return await this.connectRemoteStream(workspaceId, videoId, video.input.preparedRoomId, video.input.connectionPolicy); | |
| } | |
| /** | |
| * Pause a remote stream connection (keeps roomId for later activation) | |
| */ | |
| async pauseRemoteStream(videoId: string): Promise<void> { | |
| const video = this.getVideo(videoId); | |
| if (!video || video.input.type !== 'remote-stream') return; | |
| // Store the current roomId for later activation | |
| if (video.input.roomId && !video.input.preparedRoomId) { | |
| video.input.preparedRoomId = video.input.roomId; | |
| } | |
| // Disconnect but keep prepared connection info | |
| if (video.input.client) { | |
| video.input.client.disconnect(); | |
| } | |
| video.input.type = null; | |
| video.input.stream = null; | |
| video.input.client = null; | |
| video.input.roomId = null; | |
| video.input.connectionState = 'paused'; | |
| console.log(`Paused remote stream for video ${videoId}, can activate later`); | |
| } | |
| async connectLocalCamera(videoId: string): Promise<{ success: boolean; error?: string }> { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| return { success: false, error: `Video ${videoId} not found` }; | |
| } | |
| try { | |
| // First disconnect any existing input to avoid conflicts | |
| await this.disconnectVideoInput(videoId); | |
| // Get local camera stream | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: 1280, height: 720 }, | |
| audio: true | |
| }); | |
| // Update input state atomically to prevent reactive loops | |
| video.input.type = 'local-camera'; | |
| video.input.stream = stream; | |
| video.input.client = null; | |
| video.input.roomId = null; | |
| video.input.connectionState = 'connected'; | |
| video.input.preparedRoomId = null; | |
| video.input.connectionPolicy = 'persistent'; | |
| console.log(`Local camera connected to video ${videoId}`); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error(`Failed to connect local camera to video ${videoId}:`, error); | |
| // Ensure clean state on error | |
| video.input.connectionState = 'disconnected'; | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| } | |
| async connectRemoteStream(workspaceId: string, videoId: string, roomId: string, policy: 'persistent' | 'lazy' = 'persistent'): Promise<{ success: boolean; error?: string }> { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| return { success: false, error: `Video ${videoId} not found` }; | |
| } | |
| try { | |
| // First disconnect any existing input | |
| await this.disconnectVideoInput(videoId); | |
| // Update connection state | |
| video.input.connectionState = 'connecting'; | |
| const consumer = new videoClient.VideoConsumer(settings.transportServerUrl); | |
| const connected = await consumer.connect(workspaceId, roomId, 'consumer-id'); | |
| if (!connected) { | |
| throw new Error('Failed to connect to remote stream'); | |
| } | |
| // Start receiving video | |
| await consumer.startReceiving(); | |
| // Set up stream receiving | |
| consumer.on('streamReceived', (stream: MediaStream) => { | |
| video.input.stream = stream; | |
| }); | |
| // Update input state | |
| video.input.type = 'remote-stream'; | |
| video.input.client = consumer; | |
| video.input.roomId = roomId; | |
| video.input.preparedRoomId = null; // Clear prepared since we're now connected | |
| video.input.connectionState = 'connected'; | |
| video.input.connectionPolicy = policy; | |
| console.log(`Remote stream connected to video ${videoId} with policy ${policy}`); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error(`Failed to connect remote stream to video ${videoId}:`, error); | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| } | |
| async disconnectVideoInput(videoId: string): Promise<void> { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| console.warn(`Video ${videoId} not found for disconnection`); | |
| return; | |
| } | |
| console.log(`Disconnecting input from video ${videoId}, current type: ${video.input.type}`); | |
| try { | |
| // Stop local camera tracks if any | |
| if (video.input.stream && video.input.type === 'local-camera') { | |
| console.log(`Stopping ${video.input.stream.getTracks().length} camera tracks`); | |
| video.input.stream.getTracks().forEach(track => { | |
| console.log(`Stopping track: ${track.kind} (${track.label})`); | |
| track.stop(); | |
| }); | |
| } | |
| // Disconnect remote client if any | |
| if (video.input.client) { | |
| console.log(`Disconnecting remote client for video ${videoId}`); | |
| video.input.client.disconnect(); | |
| } | |
| // Reset input state atomically | |
| video.input.type = null; | |
| video.input.stream = null; | |
| video.input.client = null; | |
| video.input.roomId = null; | |
| video.input.connectionState = 'disconnected'; | |
| video.input.preparedRoomId = null; | |
| video.input.connectionPolicy = 'persistent'; | |
| console.log(`Input successfully disconnected from video ${videoId}`); | |
| } catch (error) { | |
| console.error(`Error during disconnection for video ${videoId}:`, error); | |
| // Still reset the state even if there was an error | |
| video.input.type = null; | |
| video.input.stream = null; | |
| video.input.client = null; | |
| video.input.roomId = null; | |
| video.input.connectionState = 'disconnected'; | |
| video.input.preparedRoomId = null; | |
| video.input.connectionPolicy = 'persistent'; | |
| throw error; | |
| } | |
| } | |
| // ============= OUTPUT MANAGEMENT ============= | |
| async startVideoOutput(workspaceId: string, videoId: string): Promise<{ success: boolean; error?: string; roomId?: string }> { | |
| const video = this.getVideo(videoId); | |
| if (!video) { | |
| return { success: false, error: `Video ${videoId} not found` }; | |
| } | |
| if (!video.canOutput) { | |
| return { success: false, error: 'Cannot output - input must be local camera' }; | |
| } | |
| try { | |
| const producer = new videoClient.VideoProducer(settings.transportServerUrl); | |
| // Create room | |
| const result = await producer.createRoom(workspaceId); | |
| const connected = await producer.connect(result.workspaceId, result.roomId, 'producer-id'); | |
| if (!connected) { | |
| throw new Error('Failed to connect producer'); | |
| } | |
| // Start camera with existing stream | |
| if (video.input.stream) { | |
| await producer.startCamera({ | |
| video: { width: 1280, height: 720 }, | |
| audio: true | |
| }); | |
| } | |
| // Update output state | |
| video.output.active = true; | |
| video.output.client = producer; | |
| video.output.roomId = result.roomId; | |
| // Refresh room list | |
| await this.listRooms(workspaceId); | |
| console.log(`Output started for video ${videoId}, room created: ${result.roomId}`); | |
| return { success: true, roomId: result.roomId }; | |
| } catch (error) { | |
| console.error(`Failed to start output for video ${videoId}:`, error); | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| } | |
| async stopVideoOutput(videoId: string): Promise<void> { | |
| const video = this.getVideo(videoId); | |
| if (!video) return; | |
| if (video.output.client) { | |
| video.output.client.stopStreaming(); | |
| video.output.client.disconnect(); | |
| } | |
| video.output.active = false; | |
| video.output.client = null; | |
| video.output.roomId = null; | |
| console.log(`Output stopped for video ${videoId}`); | |
| } | |
| /** | |
| * Clean up all videos | |
| */ | |
| async destroy(): Promise<void> { | |
| const cleanupPromises = this._videos.map(async (video) => { | |
| await this.disconnectVideoInput(video.id); | |
| await this.stopVideoOutput(video.id); | |
| }); | |
| await Promise.allSettled(cleanupPromises); | |
| this._videos.length = 0; | |
| } | |
| } | |
| // Global video manager instance | |
| export const videoManager = new VideoManager(); |