import { MouseEventHandler, useEffect, useRef, useState } from 'react' import { getNormalisedCoordinates } from '../lib/utils' import Loading from './Loading' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useModelingContext } from 'hooks/useModelingContext' import { useNetworkContext } from 'hooks/useNetworkContext' import { NetworkHealthState } from 'hooks/useNetworkStatus' import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp' import { btnName } from 'lib/cameraControls' import { sendSelectEventToEngine } from 'lib/selections' import { kclManager, engineCommandManager, sceneInfra } from 'lib/singletons' import { useAppStream } from 'AppState' export const Stream = () => { const [isLoading, setIsLoading] = useState(true) const [isFirstRender, setIsFirstRender] = useState(kclManager.isFirstRender) const [clickCoords, setClickCoords] = useState<{ x: number; y: number }>() const videoRef = useRef(null) const { settings } = useSettingsAuthContext() const { state, send, context } = useModelingContext() const { mediaStream } = useAppStream() const { overallState } = useNetworkContext() const [isFreezeFrame, setIsFreezeFrame] = useState(false) const isNetworkOkay = overallState === NetworkHealthState.Ok || overallState === NetworkHealthState.Weak // Linux has a default behavior to paste text on middle mouse up // This adds a listener to block that pasting if the click target // is not a text input, so users can move in the 3D scene with // middle mouse drag with a text input focused without pasting. useEffect(() => { const handlePaste = (e: ClipboardEvent) => { const isHtmlElement = e.target && e.target instanceof HTMLElement const isEditable = (isHtmlElement && !('explicitOriginalTarget' in e)) || ('explicitOriginalTarget' in e && ((e.explicitOriginalTarget as HTMLElement).contentEditable === 'true' || ['INPUT', 'TEXTAREA'].some( (tagName) => tagName === (e.explicitOriginalTarget as HTMLElement).tagName ))) if (!isEditable) { e.preventDefault() e.stopPropagation() e.stopImmediatePropagation() } } globalThis?.window?.document?.addEventListener('paste', handlePaste, { capture: true, }) const IDLE_TIME_MS = 1000 * 20 let timeoutIdIdleA: ReturnType | undefined = undefined const teardown = () => { videoRef.current?.pause() setIsFreezeFrame(true) sceneInfra.modelingSend({ type: 'Cancel' }) // Give video time to pause window.requestAnimationFrame(() => { engineCommandManager.engineConnection?.tearDown({ freeze: true }) }) } // Teardown everything if we go hidden or reconnect if (globalThis?.window?.document) { globalThis.window.document.onvisibilitychange = () => { if (globalThis.window.document.visibilityState === 'hidden') { clearTimeout(timeoutIdIdleA) timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS) } else if (!engineCommandManager.engineConnection?.isReady()) { clearTimeout(timeoutIdIdleA) engineCommandManager.engineConnection?.connect(true) } } } let timeoutIdIdleB: ReturnType | undefined = undefined const onAnyInput = () => { if (!engineCommandManager.engineConnection?.isReady()) { engineCommandManager.engineConnection?.connect(true) } // Clear both timers clearTimeout(timeoutIdIdleA) clearTimeout(timeoutIdIdleB) timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) } globalThis?.window?.document?.addEventListener('keydown', onAnyInput) globalThis?.window?.document?.addEventListener('mousemove', onAnyInput) globalThis?.window?.document?.addEventListener('mousedown', onAnyInput) globalThis?.window?.document?.addEventListener('scroll', onAnyInput) globalThis?.window?.document?.addEventListener('touchstart', onAnyInput) timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) return () => { globalThis?.window?.document?.removeEventListener('paste', handlePaste, { capture: true, }) globalThis?.window?.document?.removeEventListener('keydown', onAnyInput) globalThis?.window?.document?.removeEventListener('mousemove', onAnyInput) globalThis?.window?.document?.removeEventListener('mousedown', onAnyInput) globalThis?.window?.document?.removeEventListener('scroll', onAnyInput) globalThis?.window?.document?.removeEventListener( 'touchstart', onAnyInput ) } }, []) useEffect(() => { setIsFirstRender(kclManager.isFirstRender) if (!kclManager.isFirstRender) videoRef.current?.play() }, [kclManager.isFirstRender]) useEffect(() => { if ( typeof window === 'undefined' || typeof RTCPeerConnection === 'undefined' ) return if (!videoRef.current) return if (!mediaStream) return // Do not immediately play the stream! videoRef.current.srcObject = mediaStream videoRef.current.pause() send({ type: 'Set context', data: { videoElement: videoRef.current, }, }) }, [mediaStream]) const handleMouseDown: MouseEventHandler = (e) => { if (!isNetworkOkay) return if (!videoRef.current) return if (state.matches('Sketch')) return if (state.matches('Sketch no face')) return const { x, y } = getNormalisedCoordinates({ clientX: e.clientX, clientY: e.clientY, el: videoRef.current, ...context.store?.streamDimensions, }) send({ type: 'Set context', data: { buttonDownInStream: e.button, }, }) setClickCoords({ x, y }) } const handleMouseUp: MouseEventHandler = (e) => { if (!isNetworkOkay) return if (!videoRef.current) return send({ type: 'Set context', data: { buttonDownInStream: undefined, }, }) if (state.matches('Sketch')) return if (state.matches('Sketch no face')) return if (!context.store?.didDragInStream && btnName(e).left) { sendSelectEventToEngine( e, videoRef.current, context.store?.streamDimensions ) } send({ type: 'Set context', data: { didDragInStream: false, }, }) setClickCoords(undefined) } const handleMouseMove: MouseEventHandler = (e) => { if (!isNetworkOkay) return if (state.matches('Sketch')) return if (state.matches('Sketch no face')) return if (!clickCoords) return const delta = ((clickCoords.x - e.clientX) ** 2 + (clickCoords.y - e.clientY) ** 2) ** 0.5 if (delta > 5 && !context.store?.didDragInStream) { send({ type: 'Set context', data: { didDragInStream: true, }, }) } } return (
e.preventDefault()} onContextMenuCapture={(e) => e.preventDefault()} >
) }