import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw" import { useEffect, useState } from "react" import { WORKER_URL } from "../constants/workerUrl" import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" interface DailyApiResponse { url: string; } interface DailyRecordingResponse { id: string; } export type IVideoChatShape = TLBaseShape< "VideoChat", { w: number h: number roomUrl: string | null allowCamera: boolean allowMicrophone: boolean enableRecording: boolean recordingId: string | null // Track active recording meetingToken: string | null isOwner: boolean } > export class VideoChatShape extends BaseBoxShapeUtil { static override type = "VideoChat" // VideoChat theme color: Red (Rainbow) static readonly PRIMARY_COLOR = "#ef4444" indicator(shape: IVideoChatShape) { return } getDefaultProps(): IVideoChatShape["props"] { const props = { roomUrl: null, w: 800, h: 600, allowCamera: false, allowMicrophone: false, enableRecording: true, recordingId: null, meetingToken: null, isOwner: false }; console.log('🔧 getDefaultProps called, returning:', props); return props; } async generateMeetingToken(roomName: string) { const workerUrl = WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; if (!apiKey) { throw new Error('Daily.co API key not configured'); } if (!workerUrl) { throw new Error('Worker URL is not configured'); } // For now, let's skip token generation and use a simpler approach // We'll use the room URL directly and handle owner permissions differently console.log('Skipping meeting token generation for now'); return `token_${roomName}_${Date.now()}`; } async ensureRoomExists(shape: IVideoChatShape) { // Try to get the actual room ID from the URL or use a fallback let roomId = 'default-room'; // Try to extract room ID from the current URL const currentUrl = window.location.pathname; const roomMatch = currentUrl.match(/\/board\/([^\/]+)/); if (roomMatch) { roomId = roomMatch[1]; } else { // Fallback: try to get from localStorage or use a default roomId = localStorage.getItem('currentRoomId') || 'default-room'; } console.log('🔧 Using room ID:', roomId); // Clear old storage entries that use the old boardId format // This ensures we don't load old rooms with the wrong naming convention const oldStorageKeys = [ 'videoChat_room_page_page', 'videoChat_room_page:page', 'videoChat_room_board_page_page' ]; oldStorageKeys.forEach(key => { if (localStorage.getItem(key)) { console.log(`Clearing old storage entry: ${key}`); localStorage.removeItem(key); localStorage.removeItem(`${key}_token`); } }); // Try to get existing room URL from localStorage first const storageKey = `videoChat_room_${roomId}`; const existingRoomUrl = localStorage.getItem(storageKey); const existingToken = localStorage.getItem(`${storageKey}_token`); if (existingRoomUrl && existingRoomUrl !== 'undefined' && existingToken) { // Check if the existing room URL uses the old naming pattern if (existingRoomUrl.includes('board_page_page_') || existingRoomUrl.includes('page_page')) { console.log("Found old room URL format, clearing and creating new room:", existingRoomUrl); localStorage.removeItem(storageKey); localStorage.removeItem(`${storageKey}_token`); } else { console.log("Using existing room from storage:", existingRoomUrl); await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, roomUrl: existingRoomUrl, meetingToken: existingToken, isOwner: true, // Assume the creator is the owner }, }); return; } } if (shape.props.roomUrl !== null && shape.props.roomUrl !== 'undefined' && shape.props.meetingToken) { // Check if the shape's room URL uses the old naming pattern if (shape.props.roomUrl.includes('board_page_page_') || shape.props.roomUrl.includes('page_page')) { console.log("Shape has old room URL format, will create new room:", shape.props.roomUrl); } else { console.log("Room already exists:", shape.props.roomUrl); localStorage.setItem(storageKey, shape.props.roomUrl); localStorage.setItem(`${storageKey}_token`, shape.props.meetingToken); return; } } try { const workerUrl = WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; // Debug logging console.log('🔧 VideoChat Debug:'); console.log('WORKER_URL:', WORKER_URL); console.log('workerUrl:', workerUrl); console.log('apiKey exists:', !!apiKey); if (!apiKey) { throw new Error('Daily.co API key not configured'); } if (!workerUrl) { throw new Error('Worker URL is not configured'); } // Create a simple, clean room name // Use a short hash of the room ID to keep URLs readable const shortId = roomId.length > 8 ? roomId.substring(0, 8) : roomId; const cleanId = shortId.replace(/[^A-Za-z0-9]/g, ''); const roomName = `canvas-${cleanId}`; console.log('🔧 Room name generation:'); console.log('Original roomId:', roomId); console.log('Short ID:', shortId); console.log('Clean ID:', cleanId); console.log('Final roomName:', roomName); console.log('🔧 Creating Daily.co room with:', { name: roomName, properties: { enable_chat: true, enable_screenshare: true, start_video_off: true, start_audio_off: true } }); 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 } }) }); console.log('🔧 Daily.co API response status:', response.status); console.log('🔧 Daily.co API response ok:', response.ok); if (!response.ok) { const error = await response.json() console.error('🔧 Daily.co API error:', error); throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`) } const data = (await response.json()) as DailyApiResponse; console.log('🔧 Daily.co API response data:', data); const url = data.url; if (!url) { console.error('🔧 Room URL is missing from API response:', data); throw new Error("Room URL is missing") } console.log('🔧 Room URL from API:', url); // Generate meeting token for the owner // First ensure the room exists, then generate token const meetingToken = await this.generateMeetingToken(roomName); // Store the room URL and token in localStorage localStorage.setItem(storageKey, url); localStorage.setItem(`${storageKey}_token`, meetingToken); console.log("Room created successfully:", url) console.log("Meeting token generated:", meetingToken) console.log("Updating shape with new URL and token") console.log("Setting isOwner to true") await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, roomUrl: url, meetingToken: meetingToken, isOwner: true, }, }) console.log("Shape updated:", this.editor.getShape(shape.id)) const updatedShape = this.editor.getShape(shape.id) as IVideoChatShape; console.log("Updated shape isOwner:", updatedShape?.props.isOwner) } catch (error) { console.error("Error in ensureRoomExists:", error) throw error } } async startRecording(shape: IVideoChatShape) { if (!shape.props.roomUrl) return; const workerUrl = WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; try { // Extract room name from URL (same as transcription methods) const roomName = shape.props.roomUrl.split('/').pop(); if (!roomName) { throw new Error('Could not extract room name from URL'); } const response = await fetch(`${workerUrl}/daily/recordings/start`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ room_name: roomName, layout: { preset: "active-speaker" } }) }); 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 } }); } catch (error) { console.error('Error starting recording:', error); throw error; } } async stopRecording(shape: IVideoChatShape) { if (!shape.props.recordingId) return; const workerUrl = 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, recordingId: null } }); } catch (error) { console.error('Error stopping recording:', error); throw error; } } component(shape: IVideoChatShape) { // Ensure shape props exist with defaults const props = shape.props || {} const roomUrl = props.roomUrl || "" const [hasPermissions, setHasPermissions] = useState(false) const [forceRender, setForceRender] = useState(0) const [isMinimized, setIsMinimized] = useState(false) const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) // Force re-render function const forceComponentUpdate = () => { setForceRender(prev => prev + 1) } const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(true) const [currentRoomUrl, setCurrentRoomUrl] = useState(roomUrl) const [iframeError, setIframeError] = useState(false) const [retryCount, setRetryCount] = useState(0) const [useFallback, setUseFallback] = useState(false) useEffect(() => { 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) { setCurrentRoomUrl((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(() => { let mounted = true; const requestPermissions = async () => { try { if (shape.props.allowCamera || shape.props.allowMicrophone) { const constraints = { video: shape.props.allowCamera, audio: shape.props.allowMicrophone, } await navigator.mediaDevices.getUserMedia(constraints) if (mounted) { setHasPermissions(true) } } } catch (err) { console.error("Permission request failed:", err) if (mounted) { setHasPermissions(false) } } } requestPermissions() return () => { mounted = false; } }, [shape.props.allowCamera, shape.props.allowMicrophone]) if (error) { return
Error creating room: {error.message}
} if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') { return (
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
) } // Validate room URL format if (!currentRoomUrl || !currentRoomUrl.startsWith('http')) { console.error('Invalid room URL format:', currentRoomUrl); return
Error: Invalid room URL format
; } // Check if we're running on a network IP (which can cause WebRTC/CORS issues) const isNonLocalhost = window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'; const isNetworkIP = window.location.hostname.startsWith('172.') || window.location.hostname.startsWith('192.168.') || window.location.hostname.startsWith('10.'); // Try the original URL first, then add parameters if needed let roomUrlWithParams; try { roomUrlWithParams = new URL(currentRoomUrl) roomUrlWithParams.searchParams.set( "allow_camera", String(shape.props.allowCamera), ) roomUrlWithParams.searchParams.set( "allow_mic", String(shape.props.allowMicrophone), ) // Add parameters for better network access if (isNetworkIP) { roomUrlWithParams.searchParams.set("embed", "true") roomUrlWithParams.searchParams.set("iframe", "true") roomUrlWithParams.searchParams.set("show_leave_button", "false") roomUrlWithParams.searchParams.set("show_fullscreen_button", "false") roomUrlWithParams.searchParams.set("show_participants_bar", "true") roomUrlWithParams.searchParams.set("show_local_video", "true") roomUrlWithParams.searchParams.set("show_remote_video", "true") } // Only add embed parameters if the original URL doesn't work if (retryCount > 0) { roomUrlWithParams.searchParams.set("embed", "true") roomUrlWithParams.searchParams.set("iframe", "true") } } catch (e) { console.error('Error constructing URL:', e); roomUrlWithParams = new URL(currentRoomUrl); } // Note: Removed HEAD request test due to CORS issues with non-localhost IPs const handleClose = () => { this.editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } return (
{/* Video Container */}
{!useFallback ? ( ) : ( )} {/* Loading indicator */} {iframeError && retryCount < 3 && (
Retrying connection... (Attempt {retryCount + 1}/3)
)} {/* Fallback button if iframe fails */} {iframeError && retryCount >= 3 && (

Video chat failed to load in iframe

{isNetworkIP && (

⚠️ Network access issue detected: Video chat may not work on {window.location.hostname}:5173 due to WebRTC/CORS restrictions. Try accessing via localhost:5173 or use the "Open in New Tab" button below.

)} {isNonLocalhost && !isNetworkIP && (

⚠️ CORS issue detected: Try accessing via localhost:5173 instead of {window.location.hostname}:5173

)}

URL: {roomUrlWithParams.toString()}

)}
{/* URL Bubble - Below the video iframe */}

url: {currentRoomUrl} {shape.props.isOwner && " (Owner)"}

) } }