diff --git a/packages/www/package.json b/packages/www/package.json index 69dddbbb..6385b117 100644 --- a/packages/www/package.json +++ b/packages/www/package.json @@ -30,6 +30,7 @@ "@solidjs/router": "^0.15.3", "modern-normalize": "^3.0.1", "solid-js": "^1.9.5", + "solid-notifications": "^1.1.2", "valibot": "^1.0.0-rc.3", "zod": "^3.24.2" } diff --git a/packages/www/src/App.tsx b/packages/www/src/App.tsx index 22c73bc3..a28406b1 100644 --- a/packages/www/src/App.tsx +++ b/packages/www/src/App.tsx @@ -6,6 +6,7 @@ import '@fontsource/geist-sans/600.css'; import '@fontsource/geist-sans/700.css'; import '@fontsource/geist-sans/800.css'; import '@fontsource/geist-sans/900.css'; +import { PlayComponent } from './pages/play'; import { styled } from "@macaron-css/solid"; import { useStorage } from './providers/account'; import { CreateTeamComponent } from './pages/new'; @@ -14,6 +15,7 @@ import { AuthProvider, useAuth } from './providers/auth'; import { Navigate, Route, Router } from "@solidjs/router"; import { globalStyle, macaron$ } from "@macaron-css/core"; import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js'; +import TestComponent from './pages/test'; const Root = styled("div", { base: { @@ -93,6 +95,7 @@ export const App: Component = () => { // props.children )} > + void) | (() => void); + children: JSX.Element; + mountPoint?: HTMLElement; + containerClass?: string; + overlayClass?: string; +} + +export function createModalController() { + const [isOpen, setIsOpen] = createSignal(false); + + return { + isOpen, + open: () => setIsOpen(true), + close: () => setIsOpen(false), + toggle: () => setIsOpen(!isOpen()), + }; +} + +export const Modal: Component = (props) => { + const mountPoint = props.mountPoint || document.getElementById("styled") || document.body; + const isOpen = () => props.isOpen ?? false; + + const defaultOverlayStyle = ` + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.5); + z-index: 50; + `; + + return ( + + +
{ + if (e.target === e.currentTarget && props.onClose) { + if (props.onClose.length > 0) { + (props.onClose as (value: boolean) => void)(false); + } else { + (props.onClose as () => void)(); + } + } + }} + > + + {props.children} + +
+
+
+ ); +}; diff --git a/packages/www/src/index.tsx b/packages/www/src/index.tsx index 608b9165..80a66cb8 100644 --- a/packages/www/src/index.tsx +++ b/packages/www/src/index.tsx @@ -8,6 +8,7 @@ import { render } from "solid-js/web"; import "modern-normalize/modern-normalize.css"; import { App } from "./App"; import { StorageProvider } from "./providers/account"; +// import { ToastProvider, Toaster } from "solid-notifications"; const root = document.getElementById("root"); @@ -19,9 +20,12 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { render( () => ( - - - + // + // + + + + // ), root! ); \ No newline at end of file diff --git a/packages/www/src/pages/play.tsx b/packages/www/src/pages/play.tsx new file mode 100644 index 00000000..91ba5102 --- /dev/null +++ b/packages/www/src/pages/play.tsx @@ -0,0 +1,326 @@ +// FIXME: We need to make from the modal a reusable component +// FIXME: The mousepointer lock is somehow shifted when the window gets resized +import { Text } from "@nestri/www/ui/text"; +import { createSignal, createEffect, onCleanup, onMount, Show } from "solid-js"; +import { Portal } from "solid-js/web"; +import { useParams } from "@solidjs/router"; +import { Keyboard, Mouse, WebRTCStream } from "@nestri/input"; +import { Container, FullScreen } from "@nestri/www/ui/layout"; +import { styled } from "@macaron-css/solid"; +import { lightClass, theme, darkClass } from "@nestri/www/ui/theme"; +import { Modal, createModalController } from "../components/Modal"; + +const Canvas = styled("canvas", { + base: { + aspectRatio: 16 / 9, + width: "100%", + height: "100%", + objectFit: "contain", + maxHeight: "100vh", + } +}); + + + +const Button = styled("button", { + base: { + outline: "none", + width: "100%", + backgroundColor: theme.color.background.d100, + padding: "12px 16px", + borderRadius: 10, + borderWidth: 1, + borderStyle: "solid", + borderColor: theme.color.gray.d500, + ":hover": { + backgroundColor: theme.color.gray.d300, + } + } +}) + +export function PlayComponent() { + const params = useParams(); + const id = params.id; + + const [showBannerModal, setShowBannerModal] = createSignal(false); + const [showButtonModal, setShowButtonModal] = createSignal(false); + const [gamepadConnected, setGamepadConnected] = createSignal(false); + const [buttonPressed, setButtonPressed] = createSignal(null); + const [leftStickX, setLeftStickX] = createSignal(0); + const [leftStickY, setLeftStickY] = createSignal(0); + const [hasStream, setHasStream] = createSignal(false); + const [nestriLock, setNestriLock] = createSignal(false); + const [showOffline, setShowOffline] = createSignal(false); + + const [canvas, setCanvas] = createSignal(undefined); + let video: HTMLVideoElement; + let webrtc: WebRTCStream; + let nestriMouse: Mouse, nestriKeyboard: Keyboard; + + const { Modal, openModal } = createModal(); + const { WelcomeModal, openWelcomeModal } = createWelcomeModal(); + + const initializeInputDevices = () => { + const canvasElement = canvas(); + if (!canvasElement || !webrtc) return; + try { + nestriMouse = new Mouse({ canvas: canvasElement, webrtc }); + nestriKeyboard = new Keyboard({ canvas: canvasElement, webrtc }); + console.log("Input devices initialized successfully"); + } catch (error) { + console.error("Failed to initialize input devices:", error); + } + }; + + /*const initializeGamepad = () => { + console.log("Initializing gamepad..."); + + const updateGamepadState = () => { + const gamepads = navigator.getGamepads(); + const gamepad = gamepads[0]; + if (gamepad) { + setButtonPressed(gamepad.buttons.findIndex(btn => btn.pressed) !== -1 ? "Button pressed" : null); + setLeftStickX(Number(gamepad.axes[0].toFixed(2))); + setLeftStickY(Number(gamepad.axes[1].toFixed(2))); + } + requestAnimationFrame(updateGamepadState); + }; + + window.addEventListener("gamepadconnected", () => { + setGamepadConnected(true); + console.log("Gamepad connected!"); + updateGamepadState(); + }); + + window.addEventListener("gamepaddisconnected", () => { + setGamepadConnected(false); + console.log("Gamepad disconnected!"); + }); + };*/ + + const lockPlay = async () => { + const canvasElement = canvas(); + if (!canvasElement || !hasStream()) return; + try { + await canvasElement.requestPointerLock(); + await canvasElement.requestFullscreen(); + //initializeGamepad(); + + if (document.fullscreenElement !== null) { + if ('keyboard' in navigator && 'lock' in (navigator.keyboard as any)) { + const keys = [ + "AltLeft", "AltRight", "Tab", "Escape", + "ContextMenu", "MetaLeft", "MetaRight" + ]; + + try { + await (navigator.keyboard as any).lock(keys); + setNestriLock(true); + console.log("Keyboard lock acquired"); + } catch (e) { + console.warn("Keyboard lock failed:", e); + setNestriLock(false); + } + } + } + + } catch (error) { + console.error("Error during lock sequence:", error); + } + }; + + const setupPointerLockListener = () => { + document.addEventListener("pointerlockchange", () => { + const canvasElement = canvas(); + if (!canvasElement) return; + if (document.pointerLockElement === canvasElement) { + initializeInputDevices(); + } else { + console.log("Pointer lock lost Show Banner Modal:", showBannerModal()); + if (!showBannerModal()) { + console.log("Pointer lock lost, showing banner"); + const playing = sessionStorage.getItem("showedBanner"); + setShowBannerModal(!playing || playing !== "true"); + openWelcomeModal(); + + if (playing) { + setShowButtonModal(true); + openModal(); + } + } + + + + nestriKeyboard?.dispose(); + nestriMouse?.dispose(); + } + }); + }; + + const handleVideoInput = async () => { + const canvasElement = canvas(); + if (!video || !canvasElement) return; + + try { + + await video.play(); + if (canvasElement && video) { + canvasElement.width = video.videoWidth; + canvasElement.height = video.videoHeight; + + + const ctx = canvasElement.getContext("2d"); + const renderer = () => { + if (ctx && hasStream() && video) { + ctx.drawImage(video, 0, 0); + video.requestVideoFrameCallback(renderer); + } + }; + + video.requestVideoFrameCallback(renderer); + } + } catch (error) { + console.error("Error playing video:", error); + } + }; + + + onMount(() => { + const canvasElement = canvas(); + if (!canvasElement) return; + + setupPointerLockListener(); + video = document.createElement("video"); + video.style.visibility = "hidden"; + webrtc = new WebRTCStream("https://relay.dathorse.com", id, async (mediaStream) => { + if (video && mediaStream) { + video.srcObject = mediaStream; + setHasStream(true); + setShowOffline(false); + + const playing = sessionStorage.getItem("showedBanner") + console.log("Playing:", playing); + if (!playing || playing != "true") { + console.log("Showing banner: ", showBannerModal()); + if (!showBannerModal()) { + setShowBannerModal(false) + openWelcomeModal(); + } + } else { + if (!showButtonModal()) { + setShowButtonModal(true) + openModal(); + } + } + + await handleVideoInput(); + } else if (mediaStream === null) { + console.log("MediaStream is null, Room is offline"); + setShowOffline(true); + setHasStream(false); + + const ctx = canvasElement.getContext("2d"); + if (ctx) ctx.clearRect(0, 0, canvasElement.width, canvasElement.height); + } else if (video && video.srcObject !== null) { + setHasStream(true); + setShowOffline(true); + await handleVideoInput(); + } + }); + }); + + onCleanup(() => { + nestriKeyboard?.dispose(); + nestriMouse?.dispose(); + }); + + return ( + {showOffline() ? ( +
+ Offline +
+ ) : ( + + )} + + + + +
+ ); +} + +interface ModalProps { + show: () => boolean; + setShow: (value: boolean) => void; + closeOnBackdropClick?: boolean; + handleVideoInput?: () => Promise; + lockPlay?: () => Promise; +} + +type GameModalProps = ModalProps & { + handleVideoInput?: () => Promise; + lockPlay?: () => Promise; + setShow?: (show: boolean) => void; +} + +function createWelcomeModal() { + const controller = createModalController(); + + return { + openWelcomeModal: controller.open, + WelcomeModal(props: GameModalProps) { + return ( + +
+ Happy that you use Nestri! + +
+
+ ); + }, + }; +} + +function createModal() { + const controller = createModalController(); + + return { + openModal: controller.open, + Modal(props: GameModalProps) { + return ( + +
+ + +
+
+ ); + }, + }; +} \ No newline at end of file diff --git a/packages/www/src/pages/test.tsx b/packages/www/src/pages/test.tsx new file mode 100644 index 00000000..f6c7c4bb --- /dev/null +++ b/packages/www/src/pages/test.tsx @@ -0,0 +1,18 @@ +import { styled } from "@macaron-css/solid"; +import { theme } from "../ui/theme"; + + +const Testing = styled("div", { + base: { + height: "100%", + width: "100%", + position: "fixed", + backgroundColor: theme.color.blue.d600 + } +}) + +export default function TestComponent() { + return ( + + ) +} \ No newline at end of file diff --git a/packages/www/src/ui/theme.ts b/packages/www/src/ui/theme.ts index 9082ba5b..e2bb15ba 100644 --- a/packages/www/src/ui/theme.ts +++ b/packages/www/src/ui/theme.ts @@ -241,6 +241,7 @@ const light = (() => { }, }; + const boxShadow = "0 0 0 1px rgba(19,21,23,0.08), 0 3.3px 2.7px rgba(0,0,0,.03),0 8.3px 6.9px rgba(0,0,0,.04),0 17px 14.2px rgba(0,0,0,.05),0 35px 29.2px rgba(0,0,0,.06),0px -4px 4px 0px rgba(0,0,0,.07) inset"; return { gray, blue, @@ -256,6 +257,7 @@ const light = (() => { focusBorder, focusColor, d1000, + boxShadow, brand, text }; @@ -403,6 +405,8 @@ const dark = (() => { }, }; + const boxShadow = "0 0 0 1px rgba(255,255,255,0.08), 0 3.3px 2.7px rgba(0,0,0,.1),0 8.3px 6.9px rgba(0,0,0,.13),0 17px 14.2px rgba(0,0,0,.17),0 35px 29.2px rgba(0,0,0,.22),0px -4px 4px 0px rgba(0,0,0,.04) inset"; + return { gray, blue, @@ -419,6 +423,7 @@ const dark = (() => { focusColor, d1000, text, + boxShadow, brand }; })()