Spaces:
Running
Running
| /** | |
| * Video Streaming System - Input/Output Architecture | |
| * Clean separation between input sources and output destinations | |
| */ | |
| import { video } from '@robothub/transport-server-client'; | |
| import type { video as videoTypes } from '@robothub/transport-server-client'; | |
| import { settings } from '$lib/runes/settings.svelte'; | |
| // Input/Output state using runes | |
| export class VideoStreamingState { | |
| // Input state (what you're 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, | |
| }); | |
| // Output state (what you're broadcasting) | |
| output = $state({ | |
| active: false, | |
| client: null as videoTypes.VideoProducer | null, | |
| roomId: null as string | null, | |
| }); | |
| // Room listing state | |
| rooms = $state<videoTypes.RoomInfo[]>([]); | |
| roomsLoading = $state(false); | |
| // Derived state | |
| get hasInput() { | |
| return this.input.type !== null && this.input.stream !== null; | |
| } | |
| get hasOutput() { | |
| return this.output.active; | |
| } | |
| get canOutput() { | |
| // Can only output if input is local camera (not remote stream) | |
| return this.input.type === 'local-camera' && this.input.stream !== null; | |
| } | |
| get currentStream() { | |
| return this.input.stream; | |
| } | |
| } | |
| // Create global instance | |
| export const videoStreaming = new VideoStreamingState(); | |
| // External action functions | |
| export const videoActions = { | |
| // Room management | |
| async listRooms(workspaceId: string): Promise<videoTypes.RoomInfo[]> { | |
| videoStreaming.roomsLoading = true; | |
| try { | |
| const client = new video.VideoClientCore(settings.transportServerUrl); | |
| const rooms = await client.listRooms(workspaceId); | |
| videoStreaming.rooms = rooms; | |
| return rooms; | |
| } catch (error) { | |
| console.error('Failed to list rooms:', error); | |
| videoStreaming.rooms = []; | |
| return []; | |
| } finally { | |
| videoStreaming.roomsLoading = false; | |
| } | |
| }, | |
| // Input actions | |
| async connectLocalCamera(): Promise<{ success: boolean; error?: string }> { | |
| try { | |
| // Get local camera stream - no server connection needed for local viewing | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: 1280, height: 720 }, | |
| audio: true | |
| }); | |
| // First disconnect any existing input to avoid conflicts | |
| await this.disconnectInput(); | |
| // Update input state - purely local, no server interaction | |
| videoStreaming.input.type = 'local-camera'; | |
| videoStreaming.input.stream = stream; | |
| videoStreaming.input.client = null; | |
| videoStreaming.input.roomId = null; | |
| console.log('Local camera connected (local viewing only)'); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error('Failed to connect local camera:', error); | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| }, | |
| async connectRemoteStream(workspaceId: string, roomId: string): Promise<{ success: boolean; error?: string }> { | |
| try { | |
| // First disconnect any existing input | |
| await this.disconnectInput(); | |
| const consumer = new video.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) => { | |
| videoStreaming.input.stream = stream; | |
| }); | |
| // Update input state | |
| videoStreaming.input.type = 'remote-stream'; | |
| videoStreaming.input.client = consumer; | |
| videoStreaming.input.roomId = roomId; | |
| console.log('Remote stream connected'); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error('Failed to connect remote stream:', error); | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| }, | |
| async disconnectInput(): Promise<void> { | |
| // Stop local camera tracks if any | |
| if (videoStreaming.input.stream && videoStreaming.input.type === 'local-camera') { | |
| videoStreaming.input.stream.getTracks().forEach(track => track.stop()); | |
| } | |
| // Disconnect remote client if any | |
| if (videoStreaming.input.client) { | |
| videoStreaming.input.client.disconnect(); | |
| } | |
| // Reset input state | |
| videoStreaming.input.type = null; | |
| videoStreaming.input.stream = null; | |
| videoStreaming.input.client = null; | |
| videoStreaming.input.roomId = null; | |
| console.log('Input disconnected'); | |
| }, | |
| // Output actions | |
| async startOutput(workspaceId: string): Promise<{ success: boolean; error?: string; roomId?: string }> { | |
| if (!videoStreaming.canOutput) { | |
| return { success: false, error: 'Cannot output - input must be local camera' }; | |
| } | |
| try { | |
| const producer = new video.VideoProducer(settings.transportServerUrl); | |
| // Create room | |
| const roomData = await producer.createRoom(workspaceId); | |
| const connected = await producer.connect(roomData.workspaceId, roomData.roomId, 'producer-id'); | |
| if (!connected) { | |
| throw new Error('Failed to connect producer'); | |
| } | |
| // Use the current input stream for output by starting camera with existing stream | |
| if (videoStreaming.input.stream) { | |
| // We need to use the producer's startCamera method properly | |
| // For now, we'll start a new camera stream since we can't directly use existing stream | |
| await producer.startCamera({ | |
| video: { width: 1280, height: 720 }, | |
| audio: true | |
| }); | |
| } | |
| // Update output state | |
| videoStreaming.output.active = true; | |
| videoStreaming.output.client = producer; | |
| videoStreaming.output.roomId = roomData.roomId; | |
| // Refresh room list | |
| await this.listRooms(workspaceId); | |
| console.log('Output started, room created:', roomData.roomId); | |
| return { success: true, roomId: roomData.roomId }; | |
| } catch (error) { | |
| console.error('Failed to start output:', error); | |
| return { success: false, error: error instanceof Error ? error.message : String(error) }; | |
| } | |
| }, | |
| async stopOutput(): Promise<void> { | |
| if (videoStreaming.output.client) { | |
| videoStreaming.output.client.disconnect(); | |
| } | |
| // Reset output state | |
| videoStreaming.output.active = false; | |
| videoStreaming.output.client = null; | |
| videoStreaming.output.roomId = null; | |
| console.log('Output stopped'); | |
| }, | |
| // Utility functions | |
| async refreshRooms(workspaceId: string): Promise<void> { | |
| await this.listRooms(workspaceId); | |
| }, | |
| getAvailableRooms(): videoTypes.RoomInfo[] { | |
| return videoStreaming.rooms.filter(room => room.participants.producer !== null); | |
| }, | |
| getRoomById(roomId: string): videoTypes.RoomInfo | undefined { | |
| return videoStreaming.rooms.find(room => room.id === roomId); | |
| } | |
| }; |