canvas-website/src/shapes/VideoChatShapeUtil.tsx

742 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
import { useEffect, useState } from "react"
import { WORKER_URL } from "../constants/workerUrl"
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
import { usePinnedToView } from "../hooks/usePinnedToView"
import { useMaximize } from "../hooks/useMaximize"
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
pinnedToView: boolean
tags: string[]
}
>
export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
static override type = "VideoChat"
// VideoChat theme color: Red (Rainbow)
static readonly PRIMARY_COLOR = "#ef4444"
indicator(shape: IVideoChatShape) {
return <rect x={0} y={0} width={shape.props.w} height={shape.props.h} />
}
getDefaultProps(): IVideoChatShape["props"] {
return {
roomUrl: null,
w: 800,
h: 560, // Reduced from 600 to account for header (40px) and avoid scrollbars
allowCamera: false,
allowMicrophone: false,
enableRecording: true,
recordingId: null,
meetingToken: null,
isOwner: false,
pinnedToView: false,
tags: ['video-chat']
};
}
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
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';
}
// 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)) {
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')) {
localStorage.removeItem(storageKey);
localStorage.removeItem(`${storageKey}_token`);
} else {
await this.editor.updateShape<IVideoChatShape>({
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')) {
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;
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}`;
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
}
})
});
let url: string;
let isNewRoom: boolean = false;
if (!response.ok) {
const error = await response.json() as any
// Check if the room already exists
if (response.status === 400 && error.info && error.info.includes('already exists')) {
isNewRoom = false;
// Try to get the existing room info from Daily.co API
try {
const getRoomResponse = await fetch(`https://api.daily.co/v1/rooms/${roomName}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
if (getRoomResponse.ok) {
const roomData = await getRoomResponse.json() as any;
url = roomData.url;
} else {
throw new Error(`Room ${roomName} already exists but could not retrieve room URL. Please contact support.`);
}
} catch (getRoomError) {
throw new Error(`Room ${roomName} already exists but could not connect to it: ${(getRoomError as Error).message}`);
}
} else {
// Some other error occurred
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
}
} else {
// Room was created successfully
isNewRoom = true;
const data = (await response.json()) as DailyApiResponse;
url = data.url;
}
if (!url) {
throw new Error("Room URL is missing")
}
// Generate meeting token for the owner
const meetingToken = await this.generateMeetingToken(roomName);
// Store the room URL and token in localStorage
localStorage.setItem(storageKey, url);
localStorage.setItem(`${storageKey}_token`, meetingToken);
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
roomUrl: url,
meetingToken: meetingToken,
isOwner: isNewRoom, // Only owner if we created the room
},
})
} 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<IVideoChatShape>({
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<IVideoChatShape>({
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<Error | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [currentRoomUrl, setCurrentRoomUrl] = useState<string | null>(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])
// CRITICAL: Hooks must be called before any conditional returns
// Use the pinning hook to keep the shape fixed to viewport when pinned
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
// Use the maximize hook for fullscreen functionality
const { isMaximized, toggleMaximize } = useMaximize({
editor: this.editor,
shapeId: shape.id,
currentW: shape.props.w,
currentH: shape.props.h,
shapeType: 'VideoChat',
})
if (error) {
return <div>Error creating room: {error.message}</div>
}
if (isLoading || !currentRoomUrl || currentRoomUrl === 'undefined') {
return (
<div
style={{
width: shape.props.w,
height: shape.props.h,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f0f0f0",
borderRadius: "4px",
}}
>
{isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
</div>
)
}
// Validate room URL format
if (!currentRoomUrl || !currentRoomUrl.startsWith('http')) {
console.error('Invalid room URL format:', currentRoomUrl);
return <div>Error: Invalid room URL format</div>;
}
// 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)
}
const handlePinToggle = () => {
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
pinnedToView: !shape.props.pinnedToView,
},
})
}
return (
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
<StandardizedToolWrapper
title="Video Chat"
primaryColor={VideoChatShape.PRIMARY_COLOR}
isSelected={isSelected}
width={shape.props.w}
height={shape.props.h + 40}
onClose={handleClose}
onMinimize={handleMinimize}
isMinimized={isMinimized}
onMaximize={toggleMaximize}
isMaximized={isMaximized}
editor={this.editor}
shapeId={shape.id}
isPinnedToView={shape.props.pinnedToView}
onPinToggle={handlePinToggle}
tags={shape.props.tags}
onTagsChange={(newTags) => {
this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: 'VideoChat',
props: {
...shape.props,
tags: newTags,
}
})
}}
tagsEditable={true}
>
<div
style={{
width: '100%',
height: '100%',
position: "relative",
pointerEvents: "all",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Video Container */}
<div
style={{
width: '100%',
flex: 1,
position: "relative",
overflow: "hidden",
minHeight: 0, // Allow flex item to shrink below content size
}}
>
{!useFallback ? (
<iframe
key={`iframe-${retryCount}`}
src={roomUrlWithParams.toString()}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow={isNetworkIP ? "*" : "camera; microphone; fullscreen; display-capture; autoplay; encrypted-media; geolocation; web-share"}
referrerPolicy={isNetworkIP ? "unsafe-url" : "no-referrer-when-downgrade"}
sandbox={isNetworkIP ? "allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation" : undefined}
title="Daily.co Video Chat"
loading="lazy"
onError={(e) => {
console.error('Iframe loading error:', e);
setIframeError(true);
if (retryCount < 2) {
setTimeout(() => {
setRetryCount(prev => prev + 1);
setIframeError(false);
}, 2000);
} else {
setUseFallback(true);
setIframeError(false);
setRetryCount(0);
}
}}
onLoad={() => {
setIframeError(false);
setRetryCount(0);
}}
></iframe>
) : (
<iframe
key={`fallback-iframe-${retryCount}`}
src={currentRoomUrl}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow="*"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation"
title="Daily.co Video Chat (Fallback)"
onError={(e) => {
console.error('Fallback iframe loading error:', e);
setIframeError(true);
if (retryCount < 3) {
setTimeout(() => {
setRetryCount(prev => prev + 1);
setIframeError(false);
}, 2000);
} else {
setError(new Error('Failed to load video chat room after multiple attempts'));
}
}}
onLoad={() => {
setIframeError(false);
setRetryCount(0);
}}
></iframe>
)}
{/* Loading indicator */}
{iframeError && retryCount < 3 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '10px 20px',
borderRadius: '5px',
zIndex: 10
}}>
Retrying connection... (Attempt {retryCount + 1}/3)
</div>
)}
{/* Fallback button if iframe fails */}
{iframeError && retryCount >= 3 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.9)',
color: 'white',
padding: '20px',
borderRadius: '10px',
textAlign: 'center',
zIndex: 10
}}>
<p>Video chat failed to load in iframe</p>
{isNetworkIP && (
<p style={{fontSize: '12px', margin: '10px 0', color: '#ffc107'}}>
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.
</p>
)}
{isNonLocalhost && !isNetworkIP && (
<p style={{fontSize: '12px', margin: '10px 0', color: '#ffc107'}}>
CORS issue detected: Try accessing via localhost:5173 instead of {window.location.hostname}:5173
</p>
)}
<p style={{fontSize: '12px', margin: '10px 0'}}>
URL: {roomUrlWithParams.toString()}
</p>
<button
onClick={() => window.open(roomUrlWithParams.toString(), '_blank')}
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px'
}}
>
Open in New Tab
</button>
<button
onClick={() => {
setUseFallback(!useFallback);
setRetryCount(0);
setIframeError(false);
}}
style={{
background: '#28a745',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px',
marginLeft: '10px'
}}
>
Try {useFallback ? 'Normal' : 'Fallback'} Mode
</button>
</div>
)}
</div>
{/* URL Bubble - Overlay on bottom of video */}
<p
style={{
position: "absolute",
bottom: "8px",
left: "8px",
margin: 0,
padding: "4px 8px",
background: "rgba(255, 255, 255, 0.9)",
borderRadius: "4px",
fontSize: "12px",
pointerEvents: "all",
cursor: "text",
userSelect: "text",
zIndex: 1,
maxWidth: "calc(100% - 16px)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
url: {currentRoomUrl}
{shape.props.isOwner && " (Owner)"}
</p>
</div>
</StandardizedToolWrapper>
</HTMLContainer>
)
}
}