feat: Replace Daily.co with Jeffsi Meet for video calls
- Remove Daily.co API dependencies - Use self-hosted Jeffsi Meet (Jitsi fork) at meet.jeffemmett.com - Simplify room creation (Jitsi creates rooms on-the-fly) - Add Copy Link and Pop Out buttons for sharing - Configure Jitsi embed with custom branding settings - No recurring per-minute costs with self-hosted solution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ed61902fab
commit
30daf2a8cb
|
|
@ -1,30 +1,20 @@
|
|||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
||||
import { useEffect, useState } from "react"
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
import { useMaximize } from "../hooks/useMaximize"
|
||||
|
||||
interface DailyApiResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DailyRecordingResponse {
|
||||
id: string;
|
||||
}
|
||||
// Jeffsi Meet domain (self-hosted Jitsi)
|
||||
const JITSI_DOMAIN = "meet.jeffemmett.com"
|
||||
|
||||
export type IVideoChatShape = TLBaseShape<
|
||||
"VideoChat",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
roomUrl: string | null
|
||||
roomName: string | null
|
||||
allowCamera: boolean
|
||||
allowMicrophone: boolean
|
||||
enableRecording: boolean
|
||||
recordingId: string | null // Track active recording
|
||||
meetingToken: string | null
|
||||
isOwner: boolean
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
|
@ -42,355 +32,64 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
|
||||
getDefaultProps(): IVideoChatShape["props"] {
|
||||
return {
|
||||
roomUrl: null,
|
||||
roomName: 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,
|
||||
h: 560,
|
||||
allowCamera: true,
|
||||
allowMicrophone: true,
|
||||
pinnedToView: false,
|
||||
tags: ['video-chat']
|
||||
};
|
||||
}
|
||||
|
||||
async generateMeetingToken(roomName: string) {
|
||||
const workerUrl = WORKER_URL;
|
||||
|
||||
if (!workerUrl) {
|
||||
throw new Error('Worker URL is not configured');
|
||||
generateRoomName(shapeId: string): string {
|
||||
// Extract board ID from URL
|
||||
let boardId = 'default';
|
||||
const currentUrl = window.location.pathname;
|
||||
const boardMatch = currentUrl.match(/\/board\/([^\/]+)/);
|
||||
if (boardMatch) {
|
||||
boardId = boardMatch[1].substring(0, 8); // First 8 chars
|
||||
}
|
||||
|
||||
// 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()}`;
|
||||
// Clean the shape ID (remove 'shape:' prefix and special chars)
|
||||
const cleanShapeId = shapeId.replace(/^shape:/, '').replace(/[^A-Za-z0-9]/g, '').substring(0, 8);
|
||||
|
||||
// Create a readable room name
|
||||
return `canvas-${boardId}-${cleanShapeId}`;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
component(shape: IVideoChatShape) {
|
||||
const props = shape.props || {}
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [roomName, setRoomName] = useState<string | null>(props.roomName)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// 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`);
|
||||
}
|
||||
});
|
||||
// Initialize room name if not set
|
||||
useEffect(() => {
|
||||
if (!roomName) {
|
||||
const newRoomName = this.generateRoomName(shape.id);
|
||||
setRoomName(newRoomName);
|
||||
|
||||
// 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;
|
||||
|
||||
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}`;
|
||||
|
||||
// Worker uses server-side API key, no need to send it from client
|
||||
const response = await fetch(`${workerUrl}/daily/rooms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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 via worker (API key is server-side)
|
||||
try {
|
||||
const getRoomResponse = await fetch(`${workerUrl}/daily/rooms/${roomName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
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>({
|
||||
// Update shape props with room name
|
||||
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
|
||||
roomName: newRoomName,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error in ensureRoomExists:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async startRecording(shape: IVideoChatShape) {
|
||||
if (!shape.props.roomUrl) return;
|
||||
|
||||
const workerUrl = WORKER_URL;
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [shape.id]);
|
||||
|
||||
// Worker uses server-side API key, no need to send it from client
|
||||
const response = await fetch(`${workerUrl}/daily/recordings/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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;
|
||||
|
||||
try {
|
||||
// Worker uses server-side API key, no need to send it from client
|
||||
await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
// Use the pinning hook
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
// Use the maximize hook for fullscreen functionality
|
||||
// Use the maximize hook
|
||||
const { isMaximized, toggleMaximize } = useMaximize({
|
||||
editor: this.editor,
|
||||
shapeId: shape.id,
|
||||
|
|
@ -400,72 +99,50 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
})
|
||||
|
||||
if (error) {
|
||||
return <div>Error creating room: {error.message}</div>
|
||||
return <div>Error: {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),
|
||||
if (isLoading || !roomName) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f0f0f0",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
Initializing Jeffsi Meet...
|
||||
</div>
|
||||
)
|
||||
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
|
||||
// Construct Jitsi Meet URL with configuration
|
||||
const jitsiUrl = new URL(`https://${JITSI_DOMAIN}/${roomName}`)
|
||||
|
||||
// Add configuration via URL params (Jitsi supports this)
|
||||
const config = {
|
||||
// UI Configuration
|
||||
'config.prejoinPageEnabled': 'false',
|
||||
'config.startWithAudioMuted': props.allowMicrophone ? 'false' : 'true',
|
||||
'config.startWithVideoMuted': props.allowCamera ? 'false' : 'true',
|
||||
'config.disableModeratorIndicator': 'true',
|
||||
'config.enableWelcomePage': 'false',
|
||||
// Interface configuration
|
||||
'interfaceConfig.SHOW_JITSI_WATERMARK': 'false',
|
||||
'interfaceConfig.SHOW_BRAND_WATERMARK': 'false',
|
||||
'interfaceConfig.SHOW_POWERED_BY': 'false',
|
||||
'interfaceConfig.HIDE_INVITE_MORE_HEADER': 'true',
|
||||
'interfaceConfig.MOBILE_APP_PROMO': 'false',
|
||||
}
|
||||
|
||||
// Add config params to URL
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
jitsiUrl.hash = `${jitsiUrl.hash}${jitsiUrl.hash ? '&' : ''}${key}=${value}`
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
|
|
@ -486,10 +163,19 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
})
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const shareUrl = `https://${JITSI_DOMAIN}/${roomName}`
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
}
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(`https://${JITSI_DOMAIN}/${roomName}`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Video Chat"
|
||||
title="Jeffsi Meet"
|
||||
primaryColor={VideoChatShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
|
|
@ -527,202 +213,109 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
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')}
|
||||
{/* Video Container */}
|
||||
<div
|
||||
style={{
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '10px'
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
Open in New Tab
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUseFallback(!useFallback);
|
||||
setRetryCount(0);
|
||||
setIframeError(false);
|
||||
}}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={jitsiUrl.toString()}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
border: "none",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
allow="camera; microphone; fullscreen; display-capture; autoplay; clipboard-write"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title="Jeffsi Meet Video Chat"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
console.error('Iframe loading error:', e);
|
||||
setError(new Error('Failed to load video chat'));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar with Room Info and Actions */}
|
||||
<div
|
||||
style={{
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '10px',
|
||||
marginLeft: '10px'
|
||||
position: "absolute",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
right: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
Try {useFallback ? 'Normal' : 'Fallback'} Mode
|
||||
</button>
|
||||
{/* Room Name */}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "4px 8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
pointerEvents: "all",
|
||||
cursor: "text",
|
||||
userSelect: "text",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "60%",
|
||||
}}
|
||||
>
|
||||
Room: {roomName}
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
fontSize: "11px",
|
||||
cursor: "pointer",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
title="Copy invite link"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
fontSize: "11px",
|
||||
cursor: "pointer",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
title="Open in new tab"
|
||||
>
|
||||
Pop Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue