-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
1 parent
4a0f919
commit c185e6d
Showing
14 changed files
with
898 additions
and
556 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
"use client"; | ||
import ViewContextLayout from "@/layouts/view-context-layout"; | ||
import {ReactNode} from "react"; | ||
|
||
export default function DemoLayout({ | ||
children, | ||
}: Readonly<{ | ||
children: ReactNode; | ||
}>) { | ||
return <ViewContextLayout> | ||
{children} | ||
</ViewContextLayout> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
.container { | ||
scroll-snap-type: y mandatory; | ||
max-height: 100vh; | ||
overflow: auto; | ||
&:global(.eyes) { | ||
cursor:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='40' height='48' viewport='0 0 100 100' style='fill:black;font-size:24px;'><text y='50%'>👀</text></svg>") 16 0,auto; | ||
} | ||
} | ||
.contentContainer { | ||
scroll-snap-align: center; | ||
height: 100vh; | ||
display: flex; | ||
align-items: center; | ||
} | ||
.content { | ||
margin: 30px auto; | ||
display: flex; | ||
position: relative; | ||
border: 1px solid red; | ||
aspect-ratio: 1; | ||
perspective: 10cm; | ||
overflow: hidden; | ||
|
||
@media(orientation: portrait) { | ||
width: 90dvw; | ||
} | ||
@media(orientation: landscape) { | ||
height: 90dvh; | ||
} | ||
} | ||
|
||
.webcamControls { | ||
position: fixed; | ||
top: 0; | ||
left: 0; | ||
} | ||
|
||
.video { | ||
transform: scaleX(-1); // Selfie mode | ||
position: fixed; | ||
right: 8px; | ||
bottom: 8px; | ||
width: 300px; | ||
cursor: pointer; | ||
transition: opacity 0.25s ease-in-out, width 0.25s ease-in-out; | ||
|
||
&:global(.tiny) { | ||
width: 100px; | ||
transform: scaleX(1); | ||
} | ||
|
||
&:global(.hide) { | ||
pointer-events: none; | ||
opacity: 0; | ||
width: 50px; | ||
} | ||
} | ||
|
||
.videoMini { | ||
position: fixed; | ||
right: 8px; | ||
bottom: 8px; | ||
display: block; | ||
font-size: 30px; | ||
background: black; | ||
z-index: 100; | ||
border-radius: 10px; | ||
overflow: hidden; | ||
cursor: pointer; | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,235 @@ | ||
"use client"; | ||
import {useMouse} from "@/hooks/useMouse"; | ||
import {Box3d} from "@/components/box-3d/box-3d"; | ||
import {useWebcam} from "@/hooks/useWebcam"; | ||
|
||
import {ViewContext} from "@/providers/ViewContextProvider"; | ||
import {useContext, useMemo, useState} from "react"; | ||
|
||
import styles from "./page.module.scss"; | ||
|
||
interface State { | ||
// mouse | ||
isMouseEnabled: boolean; | ||
mousePosition: { x: number, y: number }; | ||
screenWidth: number; | ||
screenHeight: number; | ||
// debug | ||
isDebugEnabled: boolean; | ||
// UI | ||
isSettingsVisible: boolean; | ||
} | ||
|
||
export default function HomePage() { | ||
const {viewState} = useContext(ViewContext); | ||
|
||
const {enableMouse, disableMouse} = useMouse(); | ||
|
||
const { | ||
state: webcamState, videoRef, isDetectingVideo, | ||
disableDetectingVideo, | ||
enableDetectingVideo, handleVideoLoaded, enableWebcam, disableWebcam, | ||
showWebcam, | ||
hideWebcam | ||
} = useWebcam(); | ||
|
||
const [state, setState] = useState<State>({ | ||
// mouse | ||
isMouseEnabled: false, | ||
screenWidth: 1, | ||
screenHeight: 1, | ||
mousePosition: {x: 0, y: 0}, | ||
// debug | ||
isDebugEnabled: false, | ||
// UI | ||
isSettingsVisible: true | ||
}); | ||
|
||
const enableSettings = () => { | ||
setState((s) => ({ | ||
...s, | ||
isSettingsVisible: true, | ||
})); | ||
}; | ||
|
||
const disableSettings = () => { | ||
setState((s) => ({ | ||
...s, | ||
isSettingsVisible: false, | ||
})); | ||
} | ||
|
||
const enableDebug = () => { | ||
setState((s) => ({ | ||
...s, | ||
isDebugEnabled: true, | ||
})); | ||
}; | ||
|
||
const disableDebug = () => { | ||
setState((s) => ({ | ||
...s, | ||
isDebugEnabled: false, | ||
})); | ||
} | ||
|
||
const activateMouse = () => { | ||
setState((s) => ({ | ||
...s, | ||
isMouseEnabled: true, | ||
})); | ||
enableMouse(); | ||
} | ||
const deactivateMouse = () => { | ||
setState((s) => ({ | ||
...s, | ||
isMouseEnabled: false, | ||
mousePosition: {x: 0, y: 0}, | ||
})); | ||
disableMouse(); | ||
} | ||
|
||
const currentState = useMemo(() => ({ | ||
mouse: { | ||
x: state.mousePosition.x.toFixed(2), | ||
y: state.mousePosition.y.toFixed(2), | ||
}, | ||
eyes: { | ||
x: webcamState.eyesPosition.x.toFixed(2), | ||
y: webcamState.eyesPosition.y.toFixed(2), | ||
}, | ||
view: { | ||
x: viewState.x.toFixed(2), | ||
y: viewState.y.toFixed(2), | ||
z: viewState.z?.toFixed(2), | ||
}, | ||
webcam: { | ||
height: webcamState.webcamHeight, | ||
width: webcamState.webcamWidth | ||
}, | ||
screen: { | ||
height: state.screenHeight, | ||
width: state.screenWidth | ||
} | ||
}), [state.mousePosition.x, state.mousePosition.y, state.screenHeight, state.screenWidth, viewState.x, viewState.y, viewState.z, webcamState.eyesPosition.x, webcamState.eyesPosition.y, webcamState.webcamHeight, webcamState.webcamWidth]); | ||
|
||
return ( | ||
<> | ||
<main className={`${styles.container} ${state.isMouseEnabled ? 'eyes' : ''}`}> | ||
<div className={styles.contentContainer}> | ||
<div className={styles.content}> | ||
<Box3d layer={-100}></Box3d> | ||
<Box3d layer={-50}></Box3d> | ||
<Box3d layer={0}></Box3d> | ||
<Box3d layer={20}></Box3d> | ||
<Box3d layer={50}></Box3d> | ||
</div> | ||
</div> | ||
<div className={styles.contentContainer}> | ||
<div className={styles.content}> | ||
<Box3d layer={0}></Box3d> | ||
<Box3d layer={10}></Box3d> | ||
<Box3d layer={20}></Box3d> | ||
<Box3d layer={50}></Box3d> | ||
<Box3d layer={100}></Box3d> | ||
</div> | ||
</div> | ||
<div className={styles.contentContainer}> | ||
<div className={styles.content}> | ||
<Box3d layer={-100}></Box3d> | ||
<Box3d layer={-50}></Box3d> | ||
<Box3d layer={-20}></Box3d> | ||
<Box3d layer={-10}></Box3d> | ||
<Box3d layer={0}></Box3d> | ||
</div> | ||
</div> | ||
|
||
</main> | ||
|
||
|
||
<div className={styles.webcamControls}> | ||
{!state.isSettingsVisible && <button onClick={enableSettings} style={{fontSize: "30px"}}>⚙️</button>} | ||
{state.isSettingsVisible && | ||
<button onClick={disableSettings} style={{fontSize: "30px"}}>×</button>} | ||
|
||
{state.isSettingsVisible && <> | ||
{/*DEBUG*/} | ||
{!state.isDebugEnabled && | ||
<button onClick={enableDebug} className="btn-inverse"> | ||
Enable Debug | ||
</button>} | ||
{state.isDebugEnabled && <button onClick={disableDebug} className="btn-inverse"> | ||
Disable Debug | ||
</button>} | ||
|
||
{/*MOUSE*/} | ||
{!state.isMouseEnabled && !webcamState.isWebcamEnabled && | ||
<button onClick={activateMouse}> | ||
Enable Mouse | ||
</button>} | ||
{state.isMouseEnabled && <button onClick={deactivateMouse}> | ||
Disable Mouse | ||
</button>} | ||
|
||
{/*WEBCAM*/} | ||
{!state.isMouseEnabled && webcamState.hasWebcamSupport && <> | ||
{!webcamState.isWebcamEnabled && <button onClick={enableWebcam}> | ||
Enable Webcam | ||
</button>} | ||
{webcamState.isWebcamEnabled && | ||
<button onClick={disableWebcam}> | ||
Disable Webcam | ||
{!webcamState.isVideoLoaded && <small> (Loading video...)</small>} | ||
</button>} | ||
</>} | ||
|
||
{webcamState.hasWebcamSupport === false && | ||
<p>No webcam support, I'm sorry</p>} | ||
|
||
{webcamState.isWebcamEnabled && <> | ||
{!isDetectingVideo.current && webcamState.isVideoLoaded && | ||
<button | ||
onClick={enableDetectingVideo} | ||
disabled={webcamState.isModelLoading} | ||
> | ||
<span> | ||
Enable 3D | ||
</span> | ||
{webcamState.isModelLoading && <small> (loading...)</small>} | ||
</button> | ||
} | ||
{isDetectingVideo.current && | ||
<button | ||
onClick={disableDetectingVideo} | ||
> | ||
Disable 3D | ||
</button>} | ||
</>} | ||
|
||
{state.isDebugEnabled && | ||
<pre>State: {JSON.stringify(currentState, null, 2)}</pre>} | ||
{/*<pre>State: {JSON.stringify(state, null, 2)}</pre>*/} | ||
</>} | ||
|
||
|
||
</div> | ||
|
||
{webcamState.hasWebcamSupport && webcamState.isWebcamEnabled && <> | ||
<video | ||
className={`${styles.video} ${!webcamState.isWebcamVisible ? 'hide' : ''}`} | ||
onClick={() => hideWebcam()} | ||
autoPlay | ||
muted | ||
ref={videoRef} | ||
onLoadedData={handleVideoLoaded} | ||
style={{display: webcamState.isVideoLoaded ? 'block' : 'none'}} | ||
></video> | ||
|
||
{!webcamState.isWebcamVisible && | ||
<button onClick={() => showWebcam()} | ||
className={styles.videoMini}>📷</button>} | ||
</>} | ||
|
||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
"use client"; | ||
import ViewContextLayout from "@/layouts/view-context-layout"; | ||
import {ReactNode} from "react"; | ||
|
||
export default function DemoLayout({ | ||
children, | ||
}: Readonly<{ | ||
children: ReactNode; | ||
}>) { | ||
return <ViewContextLayout> | ||
{children} | ||
</ViewContextLayout> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
.container { | ||
display: block; | ||
height: 100dvh; | ||
width: 100dvw; | ||
overflow: hidden; | ||
position: absolute; | ||
perspective: 10cm; | ||
} | ||
|
||
.settings { | ||
position: fixed; | ||
z-index: 10000; | ||
bottom: 0; | ||
left: 0; | ||
right: 0; | ||
} | ||
|
||
.video { | ||
visibility: hidden; | ||
position: fixed; | ||
bottom: 0; | ||
right: 0; | ||
max-width: 100%; | ||
} | ||
|
||
.webcamWarning { | ||
position: fixed; | ||
z-index: 10000; | ||
top: 0; | ||
left: 0; | ||
padding: 10px; | ||
margin: 10px; | ||
display: inline-block; | ||
background-color: #ffd54d; | ||
color: black; | ||
border: 1px solid #d36c04; | ||
border-radius: 5px; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
"use client"; | ||
import {Box3d} from "@/components/box-3d/box-3d"; | ||
import {useMouse} from "@/hooks/useMouse"; | ||
import {usePermissions} from "@/hooks/usePermissions"; | ||
import {useWebcam} from "@/hooks/useWebcam"; | ||
import {ViewState} from "@/models/view-state.models"; | ||
import {ViewContext} from "@/providers/ViewContextProvider"; | ||
import {CSSProperties, useCallback, useContext, useEffect, useMemo, useRef, useState} from "react"; | ||
|
||
import styles from "./page.module.scss"; | ||
|
||
const Element = ({layer, style}: { layer: number; style: CSSProperties }) => { | ||
const {viewState: {x, y, z}} = useContext(ViewContext); | ||
|
||
const transform = useMemo(() => { | ||
const newLayer = layer ?? 0; | ||
const newZ = (z ?? 1) * newLayer; | ||
const newX = -(x ?? 0) * newZ; | ||
const newY = -(y ?? 0) * newZ; | ||
return `translate3d(${newX}%, ${newY}%, ${newZ}px)`; | ||
}, [layer, x, y, z]); | ||
|
||
return <div style={{ | ||
position: "absolute", | ||
backgroundColor: "red", | ||
display: "flex", | ||
alignContent: "center", | ||
justifyContent: "center", | ||
alignItems: "center", | ||
opacity: 0.5, | ||
...style, | ||
transform, | ||
}}>X</div>; | ||
} | ||
|
||
export default function DemoPage() { | ||
const {permissionState, handlePermission} = usePermissions("camera" as PermissionName); | ||
const [isSettingsVisible, setIsSettingsVisible] = useState(false); | ||
|
||
// const {enableMouse, disableMouse} = useMouse(); | ||
// useEffect(() => { | ||
// enableMouse(); | ||
// }, [disableMouse, enableMouse]); | ||
|
||
const { | ||
videoRef, | ||
state: webcamState, | ||
hideWebcam, | ||
handleVideoLoaded, | ||
isDetectingVideo, | ||
enableWebcam, | ||
disableWebcam, | ||
enableDetectingVideo, | ||
disableDetectingVideo, | ||
setHasWebcamSupport | ||
} = useWebcam(); | ||
|
||
useEffect(() => { | ||
enableWebcam(); | ||
|
||
return () => { | ||
disableWebcam(); | ||
disableDetectingVideo(); | ||
} | ||
}, [disableDetectingVideo, disableWebcam, enableWebcam]); | ||
|
||
useEffect(() => { | ||
if (permissionState === "granted") { | ||
setHasWebcamSupport(true); | ||
enableWebcam(); | ||
} | ||
}, [disableWebcam, enableWebcam, permissionState, setHasWebcamSupport]); | ||
|
||
return ( | ||
<> | ||
<div className={styles.settings}> | ||
|
||
{!isSettingsVisible && permissionState === "granted" && | ||
<button onClick={() => setIsSettingsVisible(true)}>⚙️</button>} | ||
|
||
{isSettingsVisible && permissionState === "granted" && <div> | ||
<button onClick={() => setIsSettingsVisible(false)}>×️</button> | ||
|
||
{/*3D*/} | ||
{isDetectingVideo.current && | ||
<button onClick={() => disableDetectingVideo()}>Disable 3D</button>} | ||
{!isDetectingVideo.current && webcamState.isWebcamEnabled && | ||
<button onClick={() => enableDetectingVideo()}>Enable 3D</button>} | ||
</div>} | ||
|
||
</div> | ||
|
||
{permissionState !== "granted" && | ||
<p className={styles.webcamWarning}>Please, grant webcam permission manually on your browser. Current | ||
permission: {permissionState}</p>} | ||
|
||
|
||
<main className={`${styles.container}`}> | ||
|
||
<Element layer={0} style={{ | ||
top: "45%", | ||
right: "45%", | ||
height: "10%", | ||
width: "10%", | ||
backgroundColor: "blue" | ||
}}></Element> | ||
<Element layer={100} style={{ | ||
top: "45%", | ||
right: "45%", | ||
height: "10%", | ||
width: "10%", | ||
backgroundColor: "lightblue" | ||
}}></Element> | ||
|
||
<Element layer={100} style={{ | ||
top: 0, | ||
left: "-10%", | ||
height: "200%", | ||
width: "20%", | ||
backgroundColor: "green" | ||
}}></Element> | ||
|
||
<Element layer={100} style={{ | ||
"top": 0, | ||
"left": "40%", | ||
"height": "50%", | ||
"width": "20%", | ||
"backgroundColor": "yellow" | ||
}}></Element> | ||
|
||
</main> | ||
|
||
{webcamState.hasWebcamSupport && webcamState.isWebcamEnabled && | ||
<video | ||
className={styles.video} | ||
onClick={() => hideWebcam()} | ||
autoPlay | ||
muted | ||
ref={videoRef} | ||
onLoadedData={handleVideoLoaded} | ||
></video> | ||
} | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,86 +1,15 @@ | ||
.container { | ||
scroll-snap-type: y mandatory; | ||
max-height: 100vh; | ||
overflow: auto; | ||
&:global(.eyes) { | ||
cursor:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='40' height='48' viewport='0 0 100 100' style='fill:black;font-size:24px;'><text y='50%'>👀</text></svg>") 16 0,auto; | ||
} | ||
} | ||
.contentContainer { | ||
scroll-snap-align: center; | ||
height: 100vh; | ||
display: flex; | ||
align-items: center; | ||
} | ||
.content { | ||
margin: 30px auto; | ||
display: flex; | ||
position: relative; | ||
border: 1px solid red; | ||
aspect-ratio: 1; | ||
perspective: 10cm; | ||
overflow: hidden; | ||
|
||
@media(orientation: portrait) { | ||
width: 90dvw; | ||
} | ||
@media(orientation: landscape) { | ||
height: 90dvh; | ||
} | ||
} | ||
|
||
.webcamControls { | ||
position: fixed; | ||
top: 0; | ||
left: 0; | ||
} | ||
|
||
.video { | ||
transform: scaleX(-1); // Selfie mode | ||
position: fixed; | ||
right: 8px; | ||
bottom: 8px; | ||
width: 300px; | ||
cursor: pointer; | ||
transition: opacity 0.25s ease-in-out, width 0.25s ease-in-out; | ||
|
||
&:global(.tiny) { | ||
width: 100px; | ||
transform: scaleX(1); | ||
} | ||
|
||
&:global(.hide) { | ||
pointer-events: none; | ||
opacity: 0; | ||
width: 50px; | ||
} | ||
} | ||
|
||
.videoMini { | ||
position: fixed; | ||
right: 8px; | ||
bottom: 8px; | ||
display: block; | ||
font-size: 30px; | ||
background: black; | ||
z-index: 100; | ||
border-radius: 10px; | ||
overflow: hidden; | ||
cursor: pointer; | ||
} | ||
text-align: center; | ||
|
||
.button { | ||
padding: 8px 16px; | ||
background: black; | ||
border: 0 solid gray; | ||
color: white; | ||
border-radius: 4px; | ||
cursor: pointer; | ||
margin: 5px; | ||
ul { | ||
list-style: none; | ||
padding: 0; | ||
display: flex; | ||
gap: 10px; | ||
justify-content: center; | ||
li { | ||
|
||
&:global(.alternate) { | ||
background: white; | ||
color: black; | ||
border: 1px solid black; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,16 @@ | ||
"use client"; | ||
import Link from "next/link"; | ||
import styles from './page.module.scss' | ||
|
||
import {PageContent} from "@/app/page-content"; | ||
import {ViewContextProvider} from "@/providers/ViewContextProvider"; | ||
export default function HomePage() { | ||
return ( | ||
<div className={styles.content}> | ||
<h1>Fake 3D website</h1> | ||
|
||
export default function Home() { | ||
return <ViewContextProvider> | ||
<PageContent/> | ||
</ViewContextProvider>; | ||
<ul> | ||
<li><Link href={"debug"}>Debug</Link></li> | ||
<li><Link href={"demo"}>Demo</Link></li> | ||
</ul> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import {ViewState} from "@/models/view-state.models"; | ||
import {ViewContext} from "@/providers/ViewContextProvider"; | ||
import {useCallback, useContext, useEffect} from "react"; | ||
|
||
export const useMouse = () => { | ||
const {setViewState} = useContext(ViewContext); | ||
|
||
const detectMousePosition = useCallback((e: MouseEvent) => { | ||
setViewState(s => ({ | ||
...s, | ||
x: ((e.clientX / window.innerWidth) * 2) - 1, | ||
y: ((e.clientY / window.innerHeight) * 2) - 1, | ||
z: s.z === undefined ? 1 : s.z > 0 ? s.z : 0, | ||
viewport: { | ||
width: window.innerWidth, | ||
height: window.innerHeight | ||
} | ||
})) | ||
}, [setViewState]) | ||
|
||
const detectMouseWheel = useCallback((e: WheelEvent) => { | ||
e.preventDefault(); | ||
const deltaZ = e.deltaY / window.innerHeight; | ||
|
||
setViewState((s: ViewState) => ({ | ||
...s, | ||
z: s.z !== undefined && s.z - deltaZ > 0 ? s.z - deltaZ : 0 | ||
})); | ||
}, [setViewState]); | ||
|
||
const enableMouse = useCallback(() => { | ||
window.addEventListener('mousemove', detectMousePosition) | ||
window.addEventListener('wheel', detectMouseWheel, {passive: false}) // See https://www.uriports.com/blog/easy-fix-for-unable-to-preventdefault-inside-passive-event-listener/ | ||
}, [detectMousePosition, detectMouseWheel]) | ||
|
||
const disableMouse = useCallback(() => { | ||
setViewState({ | ||
x: 0, | ||
y: 0 | ||
}); | ||
window.removeEventListener('mousemove', detectMousePosition) | ||
window.removeEventListener('wheel', detectMouseWheel) | ||
}, [detectMousePosition, detectMouseWheel, setViewState]); | ||
|
||
useEffect(() => { | ||
return () => { | ||
disableMouse(); | ||
} | ||
}, []); | ||
|
||
return { | ||
enableMouse, | ||
disableMouse | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import {useCallback, useEffect, useState} from "react"; | ||
|
||
export const usePermissions = (permissionName: PermissionName) => { | ||
const [permissionState, setPermissionState] = useState<PermissionState>() | ||
|
||
const handlePermission = useCallback(() => { | ||
navigator.permissions.query({name: permissionName}).then((result) => { | ||
setPermissionState(result.state); | ||
result.addEventListener("change", () => { | ||
setPermissionState(result.state); | ||
}); | ||
}); | ||
}, [permissionName]); | ||
|
||
useEffect(() => { | ||
handlePermission(); | ||
}, [handlePermission]); | ||
|
||
return { | ||
handlePermission, | ||
permissionState, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,253 @@ | ||
import {ViewState} from "@/models/view-state.models"; | ||
import {ViewContext} from "@/providers/ViewContextProvider"; | ||
import {createDetector, type FaceDetector, SupportedModels} from "@tensorflow-models/face-detection"; | ||
import type {MediaPipeFaceDetectorMediaPipeModelConfig} from "@tensorflow-models/face-detection/dist/mediapipe/types"; | ||
import {useCallback, useContext, useEffect, useRef, useState} from "react"; | ||
|
||
interface WebcamState { | ||
hasWebcamSupport?: boolean; | ||
isWebcamEnabled: boolean; | ||
isWebcamVisible: boolean; | ||
isVideoLoaded: boolean; | ||
isModelLoading: boolean; | ||
isVideoPictureInPicture: boolean; | ||
eyesPosition: { x: number, y: number }; | ||
webcamWidth: number; | ||
webcamHeight: number; | ||
} | ||
|
||
export const useWebcam = () => { | ||
|
||
const {viewState, setViewState} = useContext(ViewContext); | ||
|
||
const videoRef = useRef<HTMLVideoElement>(null); | ||
const webcamStream = useRef<MediaStream>(); | ||
const detector = useRef<FaceDetector>() | ||
const isDetectingVideo = useRef<boolean>(false); | ||
|
||
const [state, setState] = useState<WebcamState>({ | ||
hasWebcamSupport: undefined, | ||
isWebcamEnabled: false, | ||
isWebcamVisible: true, | ||
isVideoLoaded: false, | ||
isModelLoading: false, | ||
isVideoPictureInPicture: false, | ||
webcamHeight: 480, | ||
webcamWidth: 640, | ||
eyesPosition: {x: 0, y: 0}, | ||
}); | ||
|
||
const showWebcam = useCallback(() => { | ||
setState((s) => ({ | ||
...s, | ||
isWebcamVisible: true, | ||
})); | ||
}, []) | ||
|
||
const hideWebcam = useCallback(() => { | ||
setState((s) => ({ | ||
...s, | ||
isWebcamVisible: false, | ||
})); | ||
}, []) | ||
|
||
const setHasWebcamSupport = useCallback((hasWebcamSupport: boolean) => { | ||
setState((s) => ({ | ||
...s, | ||
hasWebcamSupport, | ||
})); | ||
}, []) | ||
|
||
const enableWebcam = useCallback(() => { | ||
setState((s) => ({ | ||
...s, | ||
isWebcamEnabled: true, | ||
})); | ||
|
||
// Activate the webcam stream. | ||
window.navigator.mediaDevices | ||
.getUserMedia({ | ||
video: { | ||
facingMode: "user", // front camera | ||
frameRate: {ideal: 25, max: 25}, // more than 25 fps not needed | ||
}, | ||
audio: false, | ||
}) | ||
.then(function (stream) { | ||
webcamStream.current = stream; | ||
if (videoRef.current) { | ||
videoRef.current.srcObject = stream; | ||
} | ||
const {width: webcamWidth, height: webcamHeight} = stream.getTracks()[0].getSettings(); | ||
setState((s) => ({ | ||
...s, | ||
webcamWidth: webcamWidth ?? 640, | ||
webcamHeight: webcamHeight ?? 480, | ||
isWebcamVisible: true, | ||
})); | ||
}) | ||
.catch(function (err) { | ||
console.error(err) | ||
setState((s) => ({ | ||
...s, | ||
isWebcamEnabled: false, | ||
hasWebcamSupport: false, | ||
})); | ||
}); | ||
}, []) | ||
|
||
const disableDetectingVideo = useCallback(() => { | ||
isDetectingVideo.current = false; | ||
}, []) | ||
|
||
const disableWebcam = useCallback(() => { | ||
webcamStream.current?.getTracks().forEach((t) => t.stop()); | ||
setState((s) => ({ | ||
...s, | ||
isWebcamEnabled: false, | ||
isWebcamVisible: false, | ||
eyesPosition: {x: 0, y: 0}, | ||
})); | ||
setViewState({ | ||
x: 0, | ||
y: 0 | ||
}); | ||
disableDetectingVideo(); | ||
}, [disableDetectingVideo, setViewState]) | ||
|
||
const detectVideo = useCallback(async () => { | ||
if (!videoRef.current) { | ||
console.debug("no video"); | ||
return; | ||
} | ||
|
||
if (!detector.current) { | ||
console.debug("no detector"); | ||
return; | ||
} | ||
|
||
const estimationConfig = {flipHorizontal: true}; | ||
const faces = await detector.current.estimateFaces(videoRef.current, estimationConfig); | ||
|
||
if (faces?.at(0)?.keypoints) { | ||
let position: ViewState = {x: 0, y: 0}; | ||
|
||
const eyesPosition = faces | ||
?.at(0) | ||
?.keypoints.filter(({name}) => ["rightEye", "leftEye"].includes(name ?? "")) || []; | ||
|
||
if (eyesPosition.length === 2) { | ||
// center | ||
position = { | ||
x: (eyesPosition[1].x + eyesPosition[0].x) / 2, | ||
y: (eyesPosition[1].y + eyesPosition[0].y) / 2, | ||
z: Math.abs(eyesPosition[1].x - eyesPosition[0].x) / state.webcamWidth, // NB: This should definitely be improved (it works only for horizontal eyes) | ||
} | ||
} | ||
if (eyesPosition.length === 1) { | ||
// right or left eye | ||
position = { | ||
x: eyesPosition[0].x, | ||
y: eyesPosition[0].y, | ||
} | ||
} | ||
|
||
setState((s) => ({ | ||
...s, | ||
eyesPosition: { | ||
x: position.x, | ||
y: position.y, | ||
}, | ||
})); | ||
setViewState({ | ||
x: ((position.x / state.webcamWidth) * 2) - 1, | ||
y: ((position.y / state.webcamHeight) * 2) - 1, | ||
...position.z && {z: position.z * 10} // 10 is a magic number to make the effect more visible | ||
}) | ||
// TODO - improvement: point between 2 eyes and not just right eye | ||
// TODO - improvement: calculate Z based on distance to the webcam -> E.G. (rightX - leftX) / webcam width | ||
} | ||
|
||
if (isDetectingVideo.current) { | ||
// requestAnimationFrame(() => { | ||
// detectVideo(); | ||
// }); | ||
setTimeout(() => { | ||
detectVideo(); | ||
}, 100); | ||
} else { | ||
setState((s) => ({ | ||
...s, | ||
eyesPosition: {x: 0, y: 0}, | ||
})); | ||
setViewState({ | ||
x: 0, | ||
y: 0 | ||
}); | ||
} | ||
}, []) | ||
|
||
|
||
const startVideoDetection = useCallback(async () => { | ||
setState((s) => ({ | ||
...s, | ||
isModelLoading: true, | ||
})); | ||
|
||
const model = SupportedModels.MediaPipeFaceDetector; | ||
const detectorConfig: MediaPipeFaceDetectorMediaPipeModelConfig = { | ||
runtime: "mediapipe", | ||
solutionPath: "models/face_detection", // NB: is public/models/face_detection | ||
}; | ||
|
||
createDetector(model, detectorConfig) | ||
.then((d) => { | ||
setState((s) => ({ | ||
...s, | ||
isModelLoading: false, | ||
})); | ||
isDetectingVideo.current = true; | ||
detector.current = d; | ||
detectVideo(); | ||
}) | ||
.catch((e) => { | ||
console.error("Problem on loading the model", e); | ||
}); | ||
}, [detectVideo]); | ||
|
||
const enableDetectingVideo = useCallback(async () => { | ||
void startVideoDetection(); | ||
}, [startVideoDetection]) | ||
|
||
const handleVideoLoaded = useCallback(() => { | ||
setState((s) => ({ | ||
...s, | ||
isVideoLoaded: true, | ||
})); | ||
enableDetectingVideo(); // Comment/Uncomment to disable/enable detecting video once enable webcam is clicked | ||
}, [enableDetectingVideo]); | ||
|
||
useEffect(() => { | ||
setState((s) => ({ | ||
...s, | ||
hasWebcamSupport: !!navigator.mediaDevices.getUserMedia, | ||
})); | ||
return () => { | ||
disableWebcam(); | ||
} | ||
}, [disableWebcam]); | ||
|
||
return { | ||
state, | ||
setHasWebcamSupport, | ||
videoRef, | ||
isDetectingVideo, | ||
disableDetectingVideo, | ||
enableDetectingVideo, | ||
enableWebcam, | ||
disableWebcam, | ||
handleVideoLoaded, | ||
showWebcam, | ||
hideWebcam | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
"use client"; | ||
|
||
import {ViewContextProvider} from "@/providers/ViewContextProvider"; | ||
import {ReactNode} from "react"; | ||
|
||
export default function ViewContextLayout({ | ||
children, | ||
}: Readonly<{ | ||
children: ReactNode; | ||
}>) { | ||
|
||
return <ViewContextProvider> | ||
{children} | ||
</ViewContextProvider>; | ||
} |