From f22f5b1a6c856f7d9bdbc0d844bfc56c6a4abacb Mon Sep 17 00:00:00 2001 From: Jeff-Emmett Date: Sun, 16 Feb 2025 11:35:05 +0100 Subject: [PATCH] video fix --- src/shapes/VideoChatShapeUtil.tsx | 326 ++++++++++++++++++++++++------ 1 file changed, 261 insertions(+), 65 deletions(-) diff --git a/src/shapes/VideoChatShapeUtil.tsx b/src/shapes/VideoChatShapeUtil.tsx index 621e4d8..ba8385d 100644 --- a/src/shapes/VideoChatShapeUtil.tsx +++ b/src/shapes/VideoChatShapeUtil.tsx @@ -5,6 +5,10 @@ interface DailyApiResponse { url: string; } +interface DailyRecordingResponse { + id: string; +} + export type IVideoChatShape = TLBaseShape< "VideoChat", { @@ -13,6 +17,8 @@ export type IVideoChatShape = TLBaseShape< roomUrl: string | null allowCamera: boolean allowMicrophone: boolean + enableRecording: boolean + recordingId: string | null // Track active recording } > @@ -26,69 +32,185 @@ export class VideoChatShape extends BaseBoxShapeUtil { getDefaultProps(): IVideoChatShape["props"] { return { roomUrl: null, - w: 640, - h: 480, + w: 800, + h: 600, allowCamera: false, allowMicrophone: false, + enableRecording: true, + recordingId: null } } async ensureRoomExists(shape: IVideoChatShape) { - if (shape.props.roomUrl !== null) { - console.log("Room already exists:", shape.props.roomUrl) - return + const boardId = this.editor.getCurrentPageId(); + if (!boardId) { + throw new Error('Board ID is undefined'); + } + + // Try to get existing room URL from localStorage first + const storageKey = `videoChat_room_${boardId}`; + const existingRoomUrl = localStorage.getItem(storageKey); + + if (existingRoomUrl && existingRoomUrl !== 'undefined') { + console.log("Using existing room from storage:", existingRoomUrl); + await this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + roomUrl: existingRoomUrl, + }, + }); + return; + } + + if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined') { + console.log("Room already exists:", shape.props.roomUrl); + localStorage.setItem(storageKey, shape.props.roomUrl); + return; } try { - const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL - const apiKey = import.meta.env.VITE_DAILY_API_KEY + const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; + const apiKey = import.meta.env.VITE_DAILY_API_KEY; - if (!apiKey) { - throw new Error('Daily.co API key not configured') - } + if (!apiKey) { + throw new Error('Daily.co API key not configured'); + } - const response = await fetch(`${workerUrl}/daily/rooms`, { + if (!workerUrl) { + throw new Error('Worker URL is not configured'); + } + + // Create room name based on board ID and timestamp + const roomName = `board_${boardId}_${Date.now()}`; + + const response = await fetch(`${workerUrl}/daily/rooms`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + name: roomName, + properties: { + enable_chat: true, + enable_screenshare: true, + start_video_off: true, + start_audio_off: true, + enable_recording: "cloud", + start_cloud_recording: true, + start_cloud_recording_opts: { + layout: { + preset: "active-speaker" + }, + format: "mp4", + mode: "audio-only" + }, + auto_start_transcription: true, + recordings_template: "{room_name}/audio-{epoch_time}.mp4" + } + }) + }); + + if (!response.ok) { + const error = await response.json() + throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`) + } + + const data = (await response.json()) as DailyApiResponse; + const url = data.url; + + if (!url) throw new Error("Room URL is missing") + + // Store the room URL in localStorage + localStorage.setItem(storageKey, url); + + console.log("Room created successfully:", url) + console.log("Updating shape with new URL") + + await this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + roomUrl: url, + }, + }) + + console.log("Shape updated:", this.editor.getShape(shape.id)) + } catch (error) { + console.error("Error in ensureRoomExists:", error) + throw error + } + } + + async startRecording(shape: IVideoChatShape) { + if (!shape.props.roomUrl) return; + + const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; + const apiKey = import.meta.env.VITE_DAILY_API_KEY; + + try { + const response = await fetch(`${workerUrl}/daily/recordings/start`, { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}` + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' }, body: JSON.stringify({ - properties: { - enable_chat: true, - enable_screenshare: true, - start_video_off: true, - start_audio_off: true + room_name: shape.id, + layout: { + preset: "active-speaker" } }) - }) + }); - if (!response.ok) { - const error = await response.json() - throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`) - } + if (!response.ok) throw new Error('Failed to start recording'); + + const data = await response.json() as DailyRecordingResponse; + + await this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + recordingId: data.id + } + }); - const data = (await response.json()) as DailyApiResponse; - const url = data.url; + } catch (error) { + console.error('Error starting recording:', error); + throw error; + } + } - if (!url) throw new Error("Room URL is missing") + async stopRecording(shape: IVideoChatShape) { + if (!shape.props.recordingId) return; - console.log("Room created successfully:", url) - console.log("Updating shape with new URL") + const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; + const apiKey = import.meta.env.VITE_DAILY_API_KEY; + + try { + await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}` + } + }); await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, - roomUrl: url, - }, - }) + recordingId: null + } + }); - console.log("Shape updated:", this.editor.getShape(shape.id)) } catch (error) { - console.error("Error in ensureRoomExists:", error) - throw error + console.error('Error stopping recording:', error); + throw error; } } @@ -96,16 +218,43 @@ export class VideoChatShape extends BaseBoxShapeUtil { const [hasPermissions, setHasPermissions] = useState(false) const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [roomUrl, setRoomUrl] = useState(shape.props.roomUrl) useEffect(() => { - setIsLoading(true) - this.ensureRoomExists(shape) - .catch(console.error) - .finally(() => setIsLoading(false)) - }, []) + let mounted = true; + + const createRoom = async () => { + try { + setIsLoading(true); + await this.ensureRoomExists(shape); + + // Get the updated shape after room creation + const updatedShape = this.editor.getShape(shape.id); + if (mounted && updatedShape) { + setRoomUrl((updatedShape as IVideoChatShape).props.roomUrl); + } + } catch (err) { + if (mounted) { + console.error("Error creating room:", err); + setError(err as Error); + } + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + createRoom(); + + return () => { + mounted = false; + }; + }, [shape.id]); // Only re-run if shape.id changes useEffect(() => { - // Request permissions when needed + let mounted = true; + const requestPermissions = async () => { try { if (shape.props.allowCamera || shape.props.allowMicrophone) { @@ -114,41 +263,49 @@ export class VideoChatShape extends BaseBoxShapeUtil { audio: shape.props.allowMicrophone, } await navigator.mediaDevices.getUserMedia(constraints) - setHasPermissions(true) + if (mounted) { + setHasPermissions(true) + } + } + } catch (err) { + console.error("Permission request failed:", err) + if (mounted) { + setHasPermissions(false) } - } catch (error) { - console.error("Permission request failed:", error) - setHasPermissions(false) } } requestPermissions() + + return () => { + mounted = false; + } }, [shape.props.allowCamera, shape.props.allowMicrophone]) if (error) { - return
Error creating room: {error.message}
+ return
Error creating room: {error.message}
} - if (!shape.props.roomUrl) { - return ( -
- {isLoading ? "Creating room... Please wait" : "Creating room..."} -
- ) + if (isLoading || !roomUrl || roomUrl === 'undefined') { + return ( +
+ {isLoading ? "Creating room... Please wait" : "Error: No room URL available"} +
+ ) } // Construct URL with permission parameters - const roomUrlWithParams = new URL(shape.props.roomUrl) + const roomUrlWithParams = new URL(roomUrl) roomUrlWithParams.searchParams.set( "allow_camera", String(shape.props.allowCamera), @@ -158,7 +315,7 @@ export class VideoChatShape extends BaseBoxShapeUtil { String(shape.props.allowMicrophone), ) - console.log(shape.props.roomUrl) + console.log(roomUrl) return (
{ height: `${shape.props.h}px`, position: "relative", pointerEvents: "all", + overflow: "hidden", }} > + + {shape.props.enableRecording && ( + + )} +

{ pointerEvents: "all", cursor: "text", userSelect: "text", + zIndex: 1, }} > - url: {shape.props.roomUrl} + url: {roomUrl}

)