video fix

This commit is contained in:
Jeff-Emmett 2025-02-16 11:35:05 +01:00
parent 4380a7bdd6
commit f22f5b1a6c
1 changed files with 261 additions and 65 deletions

View File

@ -5,6 +5,10 @@ interface DailyApiResponse {
url: string; url: string;
} }
interface DailyRecordingResponse {
id: string;
}
export type IVideoChatShape = TLBaseShape< export type IVideoChatShape = TLBaseShape<
"VideoChat", "VideoChat",
{ {
@ -13,6 +17,8 @@ export type IVideoChatShape = TLBaseShape<
roomUrl: string | null roomUrl: string | null
allowCamera: boolean allowCamera: boolean
allowMicrophone: boolean allowMicrophone: boolean
enableRecording: boolean
recordingId: string | null // Track active recording
} }
> >
@ -26,69 +32,185 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
getDefaultProps(): IVideoChatShape["props"] { getDefaultProps(): IVideoChatShape["props"] {
return { return {
roomUrl: null, roomUrl: null,
w: 640, w: 800,
h: 480, h: 600,
allowCamera: false, allowCamera: false,
allowMicrophone: false, allowMicrophone: false,
enableRecording: true,
recordingId: null
} }
} }
async ensureRoomExists(shape: IVideoChatShape) { async ensureRoomExists(shape: IVideoChatShape) {
if (shape.props.roomUrl !== null) { const boardId = this.editor.getCurrentPageId();
console.log("Room already exists:", shape.props.roomUrl) if (!boardId) {
return 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<IVideoChatShape>({
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 { try {
const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
const apiKey = import.meta.env.VITE_DAILY_API_KEY const apiKey = import.meta.env.VITE_DAILY_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error('Daily.co API key not configured') 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<IVideoChatShape>({
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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`,
'Authorization': `Bearer ${apiKey}` 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
properties: { room_name: shape.id,
enable_chat: true, layout: {
enable_screenshare: true, preset: "active-speaker"
start_video_off: true,
start_audio_off: true
} }
}) })
}) });
if (!response.ok) { if (!response.ok) throw new Error('Failed to start recording');
const error = await response.json()
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`) const data = await response.json() as DailyRecordingResponse;
}
await this.editor.updateShape<IVideoChatShape>({
id: shape.id,
type: shape.type,
props: {
...shape.props,
recordingId: data.id
}
});
const data = (await response.json()) as DailyApiResponse; } catch (error) {
const url = data.url; 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) const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL;
console.log("Updating shape with new 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>({ await this.editor.updateShape<IVideoChatShape>({
id: shape.id, id: shape.id,
type: shape.type, type: shape.type,
props: { props: {
...shape.props, ...shape.props,
roomUrl: url, recordingId: null
}, }
}) });
console.log("Shape updated:", this.editor.getShape(shape.id))
} catch (error) { } catch (error) {
console.error("Error in ensureRoomExists:", error) console.error('Error stopping recording:', error);
throw error throw error;
} }
} }
@ -96,16 +218,43 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
const [hasPermissions, setHasPermissions] = useState(false) const [hasPermissions, setHasPermissions] = useState(false)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [roomUrl, setRoomUrl] = useState<string | null>(shape.props.roomUrl)
useEffect(() => { useEffect(() => {
setIsLoading(true) let mounted = true;
this.ensureRoomExists(shape)
.catch(console.error) const createRoom = async () => {
.finally(() => setIsLoading(false)) 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(() => { useEffect(() => {
// Request permissions when needed let mounted = true;
const requestPermissions = async () => { const requestPermissions = async () => {
try { try {
if (shape.props.allowCamera || shape.props.allowMicrophone) { if (shape.props.allowCamera || shape.props.allowMicrophone) {
@ -114,41 +263,49 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
audio: shape.props.allowMicrophone, audio: shape.props.allowMicrophone,
} }
await navigator.mediaDevices.getUserMedia(constraints) 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() requestPermissions()
return () => {
mounted = false;
}
}, [shape.props.allowCamera, shape.props.allowMicrophone]) }, [shape.props.allowCamera, shape.props.allowMicrophone])
if (error) { if (error) {
return <div>Error creating room: {error.message}</div> return <div>Error creating room: {error.message}</div>
} }
if (!shape.props.roomUrl) { if (isLoading || !roomUrl || roomUrl === 'undefined') {
return ( return (
<div <div
style={{ style={{
width: shape.props.w, width: shape.props.w,
height: shape.props.h, height: shape.props.h,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
backgroundColor: "#f0f0f0", backgroundColor: "#f0f0f0",
borderRadius: "4px", borderRadius: "4px",
}} }}
> >
{isLoading ? "Creating room... Please wait" : "Creating room..."} {isLoading ? "Creating room... Please wait" : "Error: No room URL available"}
</div> </div>
) )
} }
// Construct URL with permission parameters // Construct URL with permission parameters
const roomUrlWithParams = new URL(shape.props.roomUrl) const roomUrlWithParams = new URL(roomUrl)
roomUrlWithParams.searchParams.set( roomUrlWithParams.searchParams.set(
"allow_camera", "allow_camera",
String(shape.props.allowCamera), String(shape.props.allowCamera),
@ -158,7 +315,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
String(shape.props.allowMicrophone), String(shape.props.allowMicrophone),
) )
console.log(shape.props.roomUrl) console.log(roomUrl)
return ( return (
<div <div
@ -167,17 +324,55 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
height: `${shape.props.h}px`, height: `${shape.props.h}px`,
position: "relative", position: "relative",
pointerEvents: "all", pointerEvents: "all",
overflow: "hidden",
}} }}
> >
<iframe <iframe
src={roomUrlWithParams.toString()} src={roomUrlWithParams.toString()}
width="100%" width="100%"
height="100%" height="100%"
style={{ border: "none" }} style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${ allow={`camera ${shape.props.allowCamera ? "self" : ""}; microphone ${
shape.props.allowMicrophone ? "self" : "" shape.props.allowMicrophone ? "self" : ""
}`} }`}
></iframe> ></iframe>
{shape.props.enableRecording && (
<button
onClick={async () => {
try {
if (shape.props.recordingId) {
await this.stopRecording(shape);
} else {
await this.startRecording(shape);
}
} catch (err) {
console.error('Recording error:', err);
}
}}
style={{
position: "absolute",
top: "8px",
right: "8px",
padding: "4px 8px",
background: shape.props.recordingId ? "#ff4444" : "#ffffff",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
zIndex: 1,
}}
>
{shape.props.recordingId ? "Stop Recording" : "Start Recording"}
</button>
)}
<p <p
style={{ style={{
position: "absolute", position: "absolute",
@ -191,9 +386,10 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
pointerEvents: "all", pointerEvents: "all",
cursor: "text", cursor: "text",
userSelect: "text", userSelect: "text",
zIndex: 1,
}} }}
> >
url: {shape.props.roomUrl} url: {roomUrl}
</p> </p>
</div> </div>
) )