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:
Kristian Ollikainen
2025-08-24 16:13:07 +03:00
committed by GitHub
parent 85d6fdd213
commit 51941e6560
11 changed files with 266 additions and 0 deletions

View 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
View 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/

View 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,
},
});

View 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"
}
}

View 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

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}