mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
⭐ feat: Standalone play site (#298)
## Description Adds a basic standalone "play site" that mimics current one in apps/www. This is so self-hosters don't need to host whole site, but can just use small version of it. Yet to test so marking as draft, not at home currently so may take some time. Also might be good idea to make Caddy-powered container out of this later? <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Introduces a standalone Play site with server output, accessible on 0.0.0.0:3000. - Streams video via WebRTC into a canvas with continuous frame rendering. - Fullscreen and pointer lock support with optional keyboard lock for navigation keys. - Room-based routing with offline and loading states. - Responsive 16:9 canvas and improved default layout styling. - Chores - Adds a multi-stage container build for efficient runtime images and a lightweight init process. - Includes configuration and project setup for the standalone package. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DatCaptainHorse <datcaptainhorse@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
85d6fdd213
commit
51941e6560
20
containers/playsite.Containerfile
Normal file
20
containers/playsite.Containerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM docker.io/node:24-alpine AS base
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /usr/src/app
|
||||
COPY package.json ./
|
||||
COPY patches ./patches
|
||||
COPY packages/input ./packages/input
|
||||
COPY packages/play-standalone ./packages/play-standalone
|
||||
RUN cd packages/play-standalone && npm install && npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /www
|
||||
COPY --from=build /usr/src/app/packages/play-standalone/dist ./dist
|
||||
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||
|
||||
RUN apk add --no-cache tini
|
||||
|
||||
EXPOSE 3000
|
||||
WORKDIR /www
|
||||
ENTRYPOINT ["/sbin/tini", "--", "node", "./dist/server/entry.mjs"]
|
||||
25
packages/play-standalone/.gitignore
vendored
Normal file
25
packages/play-standalone/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# setting folders
|
||||
.idea/
|
||||
.vscode/
|
||||
15
packages/play-standalone/astro.config.mjs
Normal file
15
packages/play-standalone/astro.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from "astro/config";
|
||||
import node from "@astrojs/node";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
output: "server",
|
||||
server: {
|
||||
"host": "0.0.0.0",
|
||||
"port": 3000,
|
||||
},
|
||||
});
|
||||
16
packages/play-standalone/package.json
Normal file
16
packages/play-standalone/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "play-standalone",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.4.2",
|
||||
"@nestri/input": "*",
|
||||
"astro": "5.13.2"
|
||||
}
|
||||
}
|
||||
14
packages/play-standalone/public/favicon.svg
Normal file
14
packages/play-standalone/public/favicon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48.672001"
|
||||
height="36.804001"
|
||||
viewBox="0 0 12.8778 9.7377253"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="layer1">
|
||||
<path
|
||||
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
|
||||
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 590 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
35
packages/play-standalone/src/layouts/DefaultLayout.astro
Normal file
35
packages/play-standalone/src/layouts/DefaultLayout.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
|
||||
<meta name="viewport" content="width=device-width"/>
|
||||
<meta name="generator" content={Astro.generator}/>
|
||||
<title>Nestri Standalone Play</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot></slot>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Basement Grotesque";
|
||||
src: url("/fonts/BasementGrotesque-Black.woff") format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Basement Grotesque", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #191919;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
136
packages/play-standalone/src/pages/[room].astro
Normal file
136
packages/play-standalone/src/pages/[room].astro
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
import DefaultLayout from "../layouts/DefaultLayout.astro";
|
||||
const { room } = Astro.params;
|
||||
---
|
||||
|
||||
<DefaultLayout>
|
||||
<h1 id="offlineText" class="offline">Offline</h1>
|
||||
<h1 id="loadingText" class="loading">Warming up the GPU...</h1>
|
||||
<canvas id="playCanvas" class="playCanvas" data-room={room}></canvas>
|
||||
</DefaultLayout>
|
||||
|
||||
<script>
|
||||
import { Mouse, Keyboard, WebRTCStream } from "@nestri/input";
|
||||
|
||||
// Elements
|
||||
const canvas = document.getElementById("playCanvas")! as HTMLCanvasElement;
|
||||
const offlineText = document.getElementById("offlineText")! as HTMLHeadingElement;
|
||||
const loadingText = document.getElementById("loadingText")! as HTMLHeadingElement;
|
||||
|
||||
const room = canvas.dataset.room;
|
||||
if (!room || room.length <= 0) {
|
||||
throw new Error("Room parameter is required");
|
||||
}
|
||||
|
||||
offlineText.style.display = "flex";
|
||||
loadingText.style.display = "none";
|
||||
|
||||
// Get query parameter "peerURL" from the URL
|
||||
let peerURL = new URLSearchParams(window.location.search).get("peerURL");
|
||||
if (!peerURL || peerURL.length <= 0) {
|
||||
peerURL = "/dnsaddr/relay.dathorse.com/p2p/12D3KooWPK4v5wKYNYx9oXWjqLM8Xix6nm13o91j1Feqq98fLBsw";
|
||||
}
|
||||
|
||||
// Stream
|
||||
const stream = new WebRTCStream(peerURL, room, async (mediaStream) => {
|
||||
if (mediaStream && video.srcObject === null) {
|
||||
video.srcObject = mediaStream;
|
||||
offlineText.style.display = "none";
|
||||
loadingText.style.display = "flex";
|
||||
|
||||
await video.play().catch((e) => {
|
||||
console.error("Failed to play video:", e);
|
||||
});
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const renderer = () => {
|
||||
if (ctx && video.srcObject) {
|
||||
ctx.drawImage(video, 0, 0);
|
||||
video.requestVideoFrameCallback(renderer);
|
||||
}
|
||||
};
|
||||
video.requestVideoFrameCallback(renderer);
|
||||
loadingText.style.display = "none";
|
||||
}
|
||||
});
|
||||
const video = document.createElement("video") as HTMLVideoElement;
|
||||
|
||||
// Input
|
||||
let nestriMouse: Mouse | null = null;
|
||||
let nestriKeyboard: Keyboard | null = null;
|
||||
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
if (document.pointerLockElement === canvas) {
|
||||
if (nestriMouse || nestriKeyboard)
|
||||
return;
|
||||
|
||||
nestriMouse = new Mouse({
|
||||
canvas: canvas,
|
||||
webrtc: stream,
|
||||
});
|
||||
nestriKeyboard = new Keyboard({
|
||||
canvas: canvas,
|
||||
webrtc: stream,
|
||||
});
|
||||
} else {
|
||||
if (nestriMouse) {
|
||||
nestriMouse.dispose();
|
||||
nestriMouse = null;
|
||||
}
|
||||
if (nestriKeyboard) {
|
||||
nestriKeyboard.dispose();
|
||||
nestriKeyboard = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const lockPlay = async function () {
|
||||
await canvas.requestFullscreen();
|
||||
await canvas.requestPointerLock();
|
||||
|
||||
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);
|
||||
console.log("Keyboard lock acquired");
|
||||
} catch (e) {
|
||||
console.warn("Keyboard lock failed:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
canvas.addEventListener("click", lockPlay);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.playCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
object-fit: contain;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.offline, .loading {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: lightgray;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
5
packages/play-standalone/tsconfig.json
Normal file
5
packages/play-standalone/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user