RobotHub-Frontend / src /lib /elements /video /VideoManager.svelte.ts
blanchon's picture
Update
67a499d
raw
history blame
18.3 kB
/* 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,
type: null as "recording" | "remote-broadcast" | null,
stream: null as MediaStream | null,
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.type = "remote-broadcast";
video.output.stream = video.input.stream;
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.type = "remote-broadcast";
video.output.stream = video.input.stream;
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.type = null;
video.output.stream = null;
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();