mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ feat: Add streaming support (#125)
This adds: - [x] Keyboard and mouse handling on the frontend - [x] Video and audio streaming from the backend to the frontend - [x] Input server that works with Websockets Update - 17/11 - [ ] Master docker container to run this - [ ] Steam runtime - [ ] Entrypoint.sh --------- Co-authored-by: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com> Co-authored-by: Kristian Ollikainen <DatCaptainHorse@users.noreply.github.com>
This commit is contained in:
23
apps/www/adapters/deno/vite.config.ts
Normal file
23
apps/www/adapters/deno/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { denoServerAdapter } from "@builder.io/qwik-city/adapters/deno-server/vite";
|
||||
import { extendConfig } from "@builder.io/qwik-city/vite";
|
||||
import baseConfig from "../../vite.config";
|
||||
|
||||
export default extendConfig(baseConfig, () => {
|
||||
return {
|
||||
build: {
|
||||
ssr: true,
|
||||
rollupOptions: {
|
||||
input: ["src/entry.deno.ts", "@qwik-city-plan"],
|
||||
},
|
||||
minify: false,
|
||||
},
|
||||
plugins: [
|
||||
denoServerAdapter({
|
||||
ssg: {
|
||||
include: ["/*"],
|
||||
origin: "https://yoursite.dev",
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
"build.client": "vite build",
|
||||
"build.preview": "vite build --ssr src/entry.preview.tsx",
|
||||
"build.server": "vite build -c adapters/cloudflare-pages/vite.config.ts",
|
||||
"deno:build.server": "vite build -c adapters/deno/vite.config.ts",
|
||||
"build.types": "tsc --incremental --noEmit",
|
||||
"deploy": "wrangler pages deploy ./dist",
|
||||
"dev": "vite --mode ssr",
|
||||
@@ -25,6 +26,7 @@
|
||||
"lint": "eslint \"src/**/*.ts*\"",
|
||||
"preview": "qwik build preview && vite preview --open",
|
||||
"serve": "wrangler pages dev ./dist --compatibility-flags=nodejs_als",
|
||||
"deno:serve": "deno run --allow-net --allow-read --allow-env server/entry.deno.js",
|
||||
"start": "vite --open --mode ssr",
|
||||
"qwik": "qwik"
|
||||
},
|
||||
@@ -34,7 +36,8 @@
|
||||
"@builder.io/qwik-react": "0.5.0",
|
||||
"@modular-forms/qwik": "^0.27.0",
|
||||
"@nestri/eslint-config": "*",
|
||||
"@nestri/moq": "*",
|
||||
"@nestri/input": "*",
|
||||
"@nestri/libmoq": "*",
|
||||
"@nestri/typescript-config": "*",
|
||||
"@nestri/ui": "*",
|
||||
"@types/eslint": "8.56.10",
|
||||
@@ -54,5 +57,9 @@
|
||||
"vite": "5.3.5",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"wrangler": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
45
apps/www/src/entry.deno.ts
Normal file
45
apps/www/src/entry.deno.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* WHAT IS THIS FILE?
|
||||
*
|
||||
* It's the entry point for the Deno HTTP server when building for production.
|
||||
*
|
||||
* Learn more about the Deno integration here:
|
||||
* - https://qwik.dev/docs/deployments/deno/
|
||||
* - https://docs.deno.com/runtime/tutorials/http_server
|
||||
*
|
||||
*/
|
||||
import { createQwikCity } from "@builder.io/qwik-city/middleware/deno";
|
||||
import qwikCityPlan from "@qwik-city-plan";
|
||||
import { manifest } from "@qwik-client-manifest";
|
||||
import render from "./entry.ssr";
|
||||
|
||||
// Create the Qwik City Deno middleware
|
||||
const { router, notFound, staticFile } = createQwikCity({
|
||||
render,
|
||||
qwikCityPlan,
|
||||
manifest,
|
||||
});
|
||||
|
||||
// Allow for dynamic port
|
||||
const port = Number(Deno.env.get("PORT") ?? 3009);
|
||||
|
||||
/* eslint-disable */
|
||||
console.log(`Server starter: http://localhost:${port}/app/`);
|
||||
|
||||
Deno.serve({ port }, async (request: Request, info: any) => {
|
||||
const staticResponse = await staticFile(request);
|
||||
if (staticResponse) {
|
||||
return staticResponse;
|
||||
}
|
||||
|
||||
// Server-side render this request with Qwik City
|
||||
const qwikCityResponse = await router(request, info);
|
||||
if (qwikCityResponse) {
|
||||
return qwikCityResponse;
|
||||
}
|
||||
|
||||
// Path not found
|
||||
return notFound(request);
|
||||
});
|
||||
|
||||
declare const Deno: any;
|
||||
@@ -34,7 +34,7 @@ export default component$(() => {
|
||||
<RouterHead />
|
||||
</head>
|
||||
<body
|
||||
class="bg-gray-100 text-gray-950 dark:bg-gray-900 dark:text-gray-50 font-body flex min-h-[100dvh] flex-col overflow-x-hidden antialiased"
|
||||
class="bg-gray-100 text-gray-950 dark:bg-gray-900 dark:text-gray-50 font-body flex flex-col items-center justify-center overflow-x-hidden antialiased"
|
||||
lang="en">
|
||||
<RouterOutlet />
|
||||
{/* {!isDev && <ServiceWorkerRegister />} */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as v from "valibot"
|
||||
import { Broadcast } from "./tester";
|
||||
//FIXME: Make sure this works
|
||||
// import { Broadcast } from "./tester";
|
||||
import { cn } from "@nestri/ui/design";
|
||||
import { routeLoader$ } from "@builder.io/qwik-city";
|
||||
import { component$, $, useSignal } from "@builder.io/qwik";
|
||||
@@ -36,11 +37,11 @@ export default component$(() => {
|
||||
|
||||
const handleSubmit = $<SubmitHandler<Form>>(async (values) => {
|
||||
const randomNamespace = generateRandomWord(6);
|
||||
const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
|
||||
// const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
|
||||
|
||||
setTimeout(() => {
|
||||
broadcasterOk.value = sub.isSubscribed()
|
||||
}, 1000);
|
||||
// setTimeout(() => {
|
||||
// broadcasterOk.value = sub.isSubscribed()
|
||||
// }, 1000);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,208 +1,208 @@
|
||||
import type { Connection, SubscribeRecv } from "@nestri/moq/transport"
|
||||
import { asError } from "@nestri/moq/common/error"
|
||||
import { Client } from "@nestri/moq/transport/client"
|
||||
import * as Catalog from "@nestri/moq/media/catalog"
|
||||
import { type GroupWriter } from "@nestri/moq/transport/objects"
|
||||
// import type { Connection, SubscribeRecv } from "@nestri/libmoq/transport"
|
||||
// import { asError } from "@nestri/moq/common/error"
|
||||
// import { Client } from "@nestri/moq/transport/client"
|
||||
// import * as Catalog from "@nestri/moq/media/catalog"
|
||||
// import { type GroupWriter } from "@nestri/moq/transport/objects"
|
||||
|
||||
export interface BroadcastConfig {
|
||||
namespace: string
|
||||
connection: Connection
|
||||
}
|
||||
export interface BroadcasterConfig {
|
||||
url: string
|
||||
namespace: string
|
||||
fingerprint?: string // URL to fetch TLS certificate fingerprint
|
||||
}
|
||||
// export interface BroadcastConfig {
|
||||
// namespace: string
|
||||
// connection: Connection
|
||||
// }
|
||||
// export interface BroadcasterConfig {
|
||||
// url: string
|
||||
// namespace: string
|
||||
// fingerprint?: string // URL to fetch TLS certificate fingerprint
|
||||
// }
|
||||
|
||||
export interface BroadcastConfigTrack {
|
||||
input: string
|
||||
bitrate: number
|
||||
}
|
||||
// export interface BroadcastConfigTrack {
|
||||
// input: string
|
||||
// bitrate: number
|
||||
// }
|
||||
|
||||
export class Broadcast {
|
||||
stream: GroupWriter | null
|
||||
subscriber: SubscribeRecv | null
|
||||
subscribed: boolean;
|
||||
// export class Broadcast {
|
||||
// stream: GroupWriter | null
|
||||
// subscriber: SubscribeRecv | null
|
||||
// subscribed: boolean;
|
||||
|
||||
|
||||
readonly config: BroadcastConfig
|
||||
readonly catalog: Catalog.Root
|
||||
readonly connection: Connection
|
||||
readonly namespace: string
|
||||
// readonly config: BroadcastConfig
|
||||
// readonly catalog: Catalog.Root
|
||||
// readonly connection: Connection
|
||||
// readonly namespace: string
|
||||
|
||||
#running: Promise<void>
|
||||
// #running: Promise<void>
|
||||
|
||||
constructor(config: BroadcastConfig) {
|
||||
this.subscribed = false
|
||||
this.namespace = config.namespace
|
||||
this.connection = config.connection
|
||||
this.config = config
|
||||
//Arbitrary values, just to keep TypeScript happy :)
|
||||
this.catalog = {
|
||||
version: 1,
|
||||
streamingFormat: 1,
|
||||
streamingFormatVersion: "0.2",
|
||||
supportsDeltaUpdates: false,
|
||||
commonTrackFields: {
|
||||
packaging: "loc",
|
||||
renderGroup: 1,
|
||||
},
|
||||
tracks: [{
|
||||
name: "tester",
|
||||
namespace: "tester",
|
||||
selectionParams: {}
|
||||
}],
|
||||
}
|
||||
this.stream = null
|
||||
this.subscriber = null
|
||||
// constructor(config: BroadcastConfig) {
|
||||
// this.subscribed = false
|
||||
// this.namespace = config.namespace
|
||||
// this.connection = config.connection
|
||||
// this.config = config
|
||||
// //Arbitrary values, just to keep TypeScript happy :)
|
||||
// this.catalog = {
|
||||
// version: 1,
|
||||
// streamingFormat: 1,
|
||||
// streamingFormatVersion: "0.2",
|
||||
// supportsDeltaUpdates: false,
|
||||
// commonTrackFields: {
|
||||
// packaging: "loc",
|
||||
// renderGroup: 1,
|
||||
// },
|
||||
// tracks: [{
|
||||
// name: "tester",
|
||||
// namespace: "tester",
|
||||
// selectionParams: {}
|
||||
// }],
|
||||
// }
|
||||
// this.stream = null
|
||||
// this.subscriber = null
|
||||
|
||||
this.#running = this.#run()
|
||||
}
|
||||
// this.#running = this.#run()
|
||||
// }
|
||||
|
||||
static async init(config: BroadcasterConfig): Promise<Broadcast> {
|
||||
const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
|
||||
const connection = await client.connect();
|
||||
// static async init(config: BroadcasterConfig): Promise<Broadcast> {
|
||||
// const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
|
||||
// const connection = await client.connect();
|
||||
|
||||
return new Broadcast({ connection, namespace: config.namespace })
|
||||
}
|
||||
// return new Broadcast({ connection, namespace: config.namespace })
|
||||
// }
|
||||
|
||||
async #run() {
|
||||
try {
|
||||
await this.connection.announce(this.namespace)
|
||||
this.subscribed = true
|
||||
} catch (error) {
|
||||
// async #run() {
|
||||
// try {
|
||||
// await this.connection.announce(this.namespace)
|
||||
// this.subscribed = true
|
||||
// } catch (error) {
|
||||
|
||||
this.subscribed = false
|
||||
}
|
||||
// this.subscribed = false
|
||||
// }
|
||||
|
||||
for (; ;) {
|
||||
const subscriber = await this.connection.subscribed()
|
||||
// for (; ;) {
|
||||
// const subscriber = await this.connection.subscribed()
|
||||
|
||||
if (!subscriber) {
|
||||
this.subscribed = false
|
||||
// if (!subscriber) {
|
||||
// this.subscribed = false
|
||||
|
||||
break
|
||||
}
|
||||
// break
|
||||
// }
|
||||
|
||||
await subscriber.ack()
|
||||
// await subscriber.ack()
|
||||
|
||||
this.subscriber = subscriber
|
||||
// this.subscriber = subscriber
|
||||
|
||||
this.subscribed = true
|
||||
// this.subscribed = true
|
||||
|
||||
const bytes = Catalog.encode(this.catalog);
|
||||
// const bytes = Catalog.encode(this.catalog);
|
||||
|
||||
const stream = await subscriber.group({ group: 0 });
|
||||
// const stream = await subscriber.group({ group: 0 });
|
||||
|
||||
await stream.write({ object: 0, payload: bytes })
|
||||
// await stream.write({ object: 0, payload: bytes })
|
||||
|
||||
this.stream = stream
|
||||
}
|
||||
}
|
||||
// this.stream = stream
|
||||
// }
|
||||
// }
|
||||
|
||||
isSubscribed(): boolean {
|
||||
return this.subscribed;
|
||||
}
|
||||
// isSubscribed(): boolean {
|
||||
// return this.subscribed;
|
||||
// }
|
||||
|
||||
// async #serveSubscribe(subscriber: SubscribeRecv) {
|
||||
// try {
|
||||
// // async #serveSubscribe(subscriber: SubscribeRecv) {
|
||||
// // try {
|
||||
|
||||
// // Send a SUBSCRIBE_OK
|
||||
// await subscriber.ack()
|
||||
// // // Send a SUBSCRIBE_OK
|
||||
// // await subscriber.ack()
|
||||
|
||||
// console.log("catalog track name:", subscriber.track)
|
||||
// // console.log("catalog track name:", subscriber.track)
|
||||
|
||||
// const stream = await subscriber.group({ group: 0 });
|
||||
// // const stream = await subscriber.group({ group: 0 });
|
||||
|
||||
// // const bytes = this.catalog.encode("Hello World")
|
||||
// // // const bytes = this.catalog.encode("Hello World")
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes })
|
||||
// // await stream.write({ object: 0, payload: bytes })
|
||||
|
||||
|
||||
|
||||
// } catch (e) {
|
||||
// const err = asError(e)
|
||||
// await subscriber.close(1n, `failed to process publish: ${err.message}`)
|
||||
// } finally {
|
||||
// // TODO we can't close subscribers because there's no support for clean termination
|
||||
// // await subscriber.close()
|
||||
// }
|
||||
// }
|
||||
// // } catch (e) {
|
||||
// // const err = asError(e)
|
||||
// // await subscriber.close(1n, `failed to process publish: ${err.message}`)
|
||||
// // } finally {
|
||||
// // // TODO we can't close subscribers because there's no support for clean termination
|
||||
// // // await subscriber.close()
|
||||
// // }
|
||||
// // }
|
||||
|
||||
// async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
|
||||
// // async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
|
||||
|
||||
// const mouse_move = {
|
||||
// input_type: "mouse_move",
|
||||
// delta_y: y,
|
||||
// delta_x: x,
|
||||
// }
|
||||
// // const mouse_move = {
|
||||
// // input_type: "mouse_move",
|
||||
// // delta_y: y,
|
||||
// // delta_x: x,
|
||||
// // }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
// // const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
// // await stream.write({ object: 0, payload: bytes });
|
||||
// // }
|
||||
|
||||
// async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
|
||||
// const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
|
||||
// // async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
|
||||
// // const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
|
||||
|
||||
// if (e.type === "mousedown") {
|
||||
// data["input_type"] = "mouse_key_down"
|
||||
// } else if (e.type === "mouseup") {
|
||||
// data["input_type"] = "mouse_key_up"
|
||||
// }
|
||||
// // if (e.type === "mousedown") {
|
||||
// // data["input_type"] = "mouse_key_down"
|
||||
// // } else if (e.type === "mouseup") {
|
||||
// // data["input_type"] = "mouse_key_up"
|
||||
// // }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
// // const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
// // await stream.write({ object: 0, payload: bytes });
|
||||
// // }
|
||||
|
||||
// async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
|
||||
// const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
|
||||
// // async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
|
||||
// // const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
|
||||
|
||||
// if (e.deltaY < 0.0) {
|
||||
// data["input_type"] = "mouse_wheel_up"
|
||||
// } else {
|
||||
// data["input_type"] = "mouse_wheel_down"
|
||||
// }
|
||||
// // if (e.deltaY < 0.0) {
|
||||
// // data["input_type"] = "mouse_wheel_up"
|
||||
// // } else {
|
||||
// // data["input_type"] = "mouse_wheel_down"
|
||||
// // }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
// // const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
// // await stream.write({ object: 0, payload: bytes });
|
||||
// // }
|
||||
|
||||
// async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
|
||||
// const data = {
|
||||
// input_type: "key_up",
|
||||
// key_code: e.keyCode
|
||||
// }
|
||||
// // async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
|
||||
// // const data = {
|
||||
// // input_type: "key_up",
|
||||
// // key_code: e.keyCode
|
||||
// // }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
// // const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
// // await stream.write({ object: 0, payload: bytes });
|
||||
// // }
|
||||
|
||||
// async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
|
||||
// const data = {
|
||||
// input_type: "key_down",
|
||||
// key_code: e.keyCode
|
||||
// }
|
||||
// // async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
|
||||
// // const data = {
|
||||
// // input_type: "key_down",
|
||||
// // key_code: e.keyCode
|
||||
// // }
|
||||
|
||||
// const bytes = Catalog.encode(this.catalog)
|
||||
// // const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// await stream.write({ object: 0, payload: bytes });
|
||||
// }
|
||||
// // await stream.write({ object: 0, payload: bytes });
|
||||
// // }
|
||||
|
||||
close() {
|
||||
// TODO implement publish close
|
||||
}
|
||||
// close() {
|
||||
// // TODO implement publish close
|
||||
// }
|
||||
|
||||
// Returns the error message when the connection is closed
|
||||
async closed(): Promise<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed") // clean termination
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// // Returns the error message when the connection is closed
|
||||
// async closed(): Promise<Error> {
|
||||
// try {
|
||||
// await this.#running
|
||||
// return new Error("closed") // clean termination
|
||||
// } catch (e) {
|
||||
// return asError(e)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -12,13 +12,39 @@ export default component$(() => {
|
||||
return (
|
||||
<>
|
||||
<HomeNavBar />
|
||||
<section class="flex flex-col gap-4 justify-center pt-20 items-center w-full text-left pb-4">
|
||||
{/* <div class="bg-red-500 h-[66px] w-screen"></div> */}
|
||||
{/* <section class="absolute flex mx-auto my-0 inset-[0_0_20%] overflow-hidden -z-[1] before:inset-0 before:absolute before:z-[1] after:absolute after:inset-0 after:[background:linear-gradient(180deg,transparent_60%,#000)] before:[background:linear-gradient(90deg,transparent_85%,#000),linear-gradient(-90deg,transparent_85%,#000)]" >
|
||||
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1172470/library_hero_2x.jpg" height={200} width={300} class="max-w-full min-w-full max-h-full min-h-full object-cover absolute top-0 bottom-0 left-0 right-0 w-0 h-0"/>
|
||||
</section> */}
|
||||
|
||||
<section class="w-full top-[70px] pb-5 ring-gray-300 ring-2 max-w-3xl rounded-xl overflow-hidden relative h-auto shadow-xl">
|
||||
<div class="w-full h-auto relative">
|
||||
<img src="https://media.rawg.io/media/games/511/5118aff5091cb3efec399c808f8c598f.jpg" height={200} width={300} class="w-full aspect-[16/9] object-cover" />
|
||||
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1174180/logo_2x.png?t=1671484934" height={200} width={300} class="w-[40%] aspect-[16/9] absolute bottom-4 left-1/2 -translate-x-1/2 object-cover" />
|
||||
</div>
|
||||
<div class="px-6 pt-2">
|
||||
<div class="flex gap-2 items-center h-max">
|
||||
{/* <img src="https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/1174180/5bf6edd7efb1110b457da905e7ac696c6c619ed1.ico" height={20} width={20} class="size-10 bg-black aspect-square rounded-xl ring-2 ring-gray-700" /> */}
|
||||
<p class="text-2xl font-title font-bold">Red Dead Redemption 2</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* <section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||
</section><section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||
</section><section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||
</section><section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||
</section> */}
|
||||
{/* <section class="flex flex-col gap-4 justify-center pt-20 items-center w-full text-left pb-4">
|
||||
<div class="flex flex-col gap-4 mx-auto max-w-2xl w-full">
|
||||
<h1 class="text-5xl font-bold font-title">{getGreeting()}, <span>Wanjohi</span></h1>
|
||||
<p class="dark:text-gray-50/70 text-gray-950/70 text-xl">What will you play today?</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex flex-col gap-4 justify-center pt-10 items-center w-full text-left pb-4">
|
||||
</section> */}
|
||||
{/* <section class="flex flex-col gap-4 justify-center pt-10 items-center w-full text-left pb-4">
|
||||
<ul class="gap-4 relative list-none w-full max-w-xl lg:max-w-4xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 after:pointer-events-none after:select-none after:bg-gradient-to-b after:from-transparent after:dark:to-gray-900 after:to-gray-100 after:fixed after:left-0 after:-bottom-[1px] after:z-10 after:backdrop-blur-sm after:h-[100px] after:w-full after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.primary.100)_50%,transparent)] after:dark:[-webkit-mask-image:linear-gradient(to_top,theme(colors.primary.900)_50%,transparent)]">
|
||||
<li class="col-span-full">
|
||||
<Card
|
||||
@@ -87,21 +113,7 @@ export default component$(() => {
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<nav class="w-full flex justify-center h-[100px] z-50 items-center gap-4 bg-transparent fixed -bottom-[1px] left-0 right-0">
|
||||
{/* <nav class="flex gap-4 w-max px-4 py-2 rounded-full shadow-2xl shadow-gray-950 bg-neutral-200 text-gray-900 dark:text-gray-100 dark:bg-neutral-800 ring-gray-300 dark:ring-gray-700 ring-1">
|
||||
<button class="text-xl font-title">
|
||||
<span class="material-symbols-outlined">
|
||||
home
|
||||
</span>
|
||||
</button>
|
||||
<button class="text-xl font-title">
|
||||
<span class="material-symbols-outlined">
|
||||
home
|
||||
</span>
|
||||
</button>
|
||||
</nav> */}
|
||||
</nav>
|
||||
</section> */}
|
||||
</>
|
||||
)
|
||||
})
|
||||
287
apps/www/src/routes/play/[id]/index.tsx
Normal file
287
apps/www/src/routes/play/[id]/index.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import {useLocation} from "@builder.io/qwik-city";
|
||||
import {Keyboard, Mouse, WebRTCStream} from "@nestri/input"
|
||||
import {component$, useSignal, useVisibleTask$} from "@builder.io/qwik";
|
||||
|
||||
export default component$(() => {
|
||||
const id = useLocation().params.id;
|
||||
const canvas = useSignal<HTMLCanvasElement>();
|
||||
|
||||
useVisibleTask$(({track}) => {
|
||||
track(() => canvas.value);
|
||||
|
||||
if (!canvas.value) return; // Ensure canvas is available
|
||||
|
||||
// Create video element and make it output to canvas (TODO: improve this)
|
||||
let video = document.getElementById("webrtc-video-player");
|
||||
if (!video) {
|
||||
video = document.createElement("video");
|
||||
video.id = "stream-video-player";
|
||||
video.style.visibility = "hidden";
|
||||
const webrtc = new WebRTCStream("https://relay.dathorse.com", id, (mediaStream) => {
|
||||
if (video && mediaStream && (video as HTMLVideoElement).srcObject === null) {
|
||||
console.log("Setting mediastream");
|
||||
(video as HTMLVideoElement).srcObject = mediaStream;
|
||||
|
||||
// @ts-ignore
|
||||
window.hasstream = true;
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement?.remove();
|
||||
|
||||
const playbtn = document.createElement("button");
|
||||
playbtn.style.position = "absolute";
|
||||
playbtn.style.left = "50%";
|
||||
playbtn.style.top = "50%";
|
||||
playbtn.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
playbtn.style.width = "12rem";
|
||||
playbtn.style.height = "6rem";
|
||||
playbtn.style.borderRadius = "1rem";
|
||||
playbtn.style.backgroundColor = "rgb(175, 50, 50)";
|
||||
playbtn.style.color = "black";
|
||||
playbtn.style.fontSize = "1.5em";
|
||||
playbtn.textContent = "< Start >";
|
||||
|
||||
playbtn.onclick = () => {
|
||||
playbtn.remove();
|
||||
(video as HTMLVideoElement).play().then(() => {
|
||||
if (canvas.value) {
|
||||
canvas.value.width = (video as HTMLVideoElement).videoWidth;
|
||||
canvas.value.height = (video as HTMLVideoElement).videoHeight;
|
||||
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
const renderer = () => {
|
||||
// @ts-ignore
|
||||
if (ctx && window.hasstream) {
|
||||
ctx.drawImage((video as HTMLVideoElement), 0, 0);
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
}
|
||||
}
|
||||
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
if (!canvas.value) return; // Ensure canvas is available
|
||||
// @ts-ignore
|
||||
if (document.pointerLockElement && !window.nestrimouse && !window.nestrikeyboard) {
|
||||
// @ts-ignore
|
||||
window.nestrimouse = new Mouse({canvas: canvas.value, webrtc});
|
||||
// @ts-ignore
|
||||
window.nestrikeyboard = new Keyboard({canvas: canvas.value, webrtc});
|
||||
// @ts-ignore
|
||||
} else if (!document.pointerLockElement && window.nestrimouse && window.nestrikeyboard) {
|
||||
// @ts-ignore
|
||||
window.nestrimouse.dispose();
|
||||
// @ts-ignore
|
||||
window.nestrimouse = undefined;
|
||||
// @ts-ignore
|
||||
window.nestrikeyboard.dispose();
|
||||
// @ts-ignore
|
||||
window.nestrikeyboard = undefined;
|
||||
}
|
||||
});
|
||||
};
|
||||
document.body.append(playbtn);
|
||||
} else if (mediaStream === null) {
|
||||
console.log("MediaStream is null, Room is offline");
|
||||
// Add a message to the screen
|
||||
const offline = document.createElement("div");
|
||||
offline.style.position = "absolute";
|
||||
offline.style.left = "50%";
|
||||
offline.style.top = "50%";
|
||||
offline.style.transform = "translateX(-50%) translateY(-50%)";
|
||||
offline.style.width = "auto";
|
||||
offline.style.height = "auto";
|
||||
offline.style.color = "lightgray";
|
||||
offline.style.fontSize = "2em";
|
||||
offline.textContent = "Offline";
|
||||
document.body.append(offline);
|
||||
// @ts-ignore
|
||||
window.roomOfflineElement = offline;
|
||||
// @ts-ignore
|
||||
window.hasstream = false;
|
||||
// Clear canvas if it has been set
|
||||
if (canvas.value) {
|
||||
const ctx = canvas.value.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
onClick$={async () => {
|
||||
// @ts-ignore
|
||||
if (canvas.value && window.hasstream) {
|
||||
// Do not use - unadjustedMovement: true - breaks input on linux
|
||||
await canvas.value.requestPointerLock();
|
||||
await canvas.value.requestFullscreen()
|
||||
if (document.fullscreenElement !== null) {
|
||||
// @ts-ignore
|
||||
if ('keyboard' in window.navigator && 'lock' in window.navigator.keyboard) {
|
||||
const keys = [
|
||||
"AltLeft",
|
||||
"AltRight",
|
||||
"Tab",
|
||||
"Escape",
|
||||
"ContextMenu",
|
||||
"MetaLeft",
|
||||
"MetaRight"
|
||||
];
|
||||
console.log("requesting keyboard lock");
|
||||
// @ts-ignore
|
||||
window.navigator.keyboard.lock(keys).then(
|
||||
() => {
|
||||
console.log("keyboard lock success");
|
||||
}
|
||||
).catch(
|
||||
(e: any) => {
|
||||
console.log("keyboard lock failed: ", e);
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.log("keyboard lock not supported, navigator is: ", window.navigator, navigator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
|
||||
class="aspect-video h-full w-full object-contain max-h-screen"/>
|
||||
)
|
||||
})
|
||||
|
||||
{/**
|
||||
.spinningCircleInner_b6db20 {
|
||||
transform: rotate(280deg);
|
||||
}
|
||||
.inner_b6db20 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
contain: paint;
|
||||
} */
|
||||
}
|
||||
|
||||
{/* <div class="loadingPopout_a8c724" role="dialog" tabindex="-1" aria-modal="true"><div class="spinner_b6db20 spinningCircle_b6db20" role="img" aria-label="Loading"><div class="spinningCircleInner_b6db20 inner_b6db20"><svg class="circular_b6db20" viewBox="25 25 50 50"><circle class="path_b6db20 path3_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20 path2_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20" cx="50" cy="50" r="20"></circle></svg></div></div></div> */
|
||||
}
|
||||
// .loadingPopout_a8c724 {
|
||||
// background-color: var(--background-secondary);
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// padding: 8px;
|
||||
// }
|
||||
|
||||
// .circular_b6db20 {
|
||||
// animation: spinner-spinning-circle-rotate_b6db20 2s linear infinite;
|
||||
// height: 100%;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
// 100% {
|
||||
// transform: rotate(360deg);
|
||||
// }
|
||||
|
||||
|
||||
{/* .path3_b6db20 {
|
||||
animation-delay: .23s;
|
||||
stroke: var(--text-brand);
|
||||
}
|
||||
.path_b6db20 {
|
||||
animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-linecap: round;
|
||||
stroke: var(--brand-500);
|
||||
}
|
||||
circle[Attributes Style] {
|
||||
cx: 50;
|
||||
cy: 50;
|
||||
r: 20;
|
||||
}
|
||||
user agent stylesheet
|
||||
:not(svg) {
|
||||
transform-origin: 0px 0px;
|
||||
} */
|
||||
}
|
||||
|
||||
|
||||
// .path2_b6db20 {
|
||||
// animation-delay: .15s;
|
||||
// stroke: var(--text-brand);
|
||||
// opacity: .6;
|
||||
// }
|
||||
// .path_b6db20 {
|
||||
// animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||
// stroke-dasharray: 1, 200;
|
||||
// stroke-dashoffset: 0;
|
||||
// fill: none;
|
||||
// stroke-width: 6;
|
||||
// stroke-miterlimit: 10;
|
||||
// stroke-linecap: round;
|
||||
// stroke: var(--brand-500);
|
||||
// }
|
||||
// circle[Attributes Style] {
|
||||
// cx: 50;
|
||||
// cy: 50;
|
||||
// r: 20;
|
||||
|
||||
|
||||
// function throttle(func, limit) {
|
||||
// let inThrottle;
|
||||
// return function(...args) {
|
||||
// if (!inThrottle) {
|
||||
// func.apply(this, args);
|
||||
// inThrottle = true;
|
||||
// setTimeout(() => inThrottle = false, limit);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Use it like this:
|
||||
// const throttledMouseMove = throttle((x, y) => {
|
||||
// websocket.send(JSON.stringify({
|
||||
// type: 'mousemove',
|
||||
// x: x,
|
||||
// y: y
|
||||
// }));
|
||||
// }, 16); // ~60fps
|
||||
|
||||
// use std::time::Instant;
|
||||
|
||||
// // Add these to your AppState
|
||||
// struct AppState {
|
||||
// pipeline: Arc<Mutex<gst::Pipeline>>,
|
||||
// last_mouse_move: Arc<Mutex<(i32, i32, Instant)>>, // Add this
|
||||
// }
|
||||
|
||||
// // Then in your MouseMove handler:
|
||||
// InputMessage::MouseMove { x, y } => {
|
||||
// let mut last_move = state.last_mouse_move.lock().unwrap();
|
||||
// let now = Instant::now();
|
||||
|
||||
// // Only process if coordinates are different or enough time has passed
|
||||
// if (last_move.0 != x || last_move.1 != y) &&
|
||||
// (now.duration_since(last_move.2).as_millis() > 16) { // ~60fps
|
||||
|
||||
// println!("Mouse moved to x: {}, y: {}", x, y);
|
||||
|
||||
// let structure = gst::Structure::builder("MouseMoveRelative")
|
||||
// .field("pointer_x", x as f64)
|
||||
// .field("pointer_y", y as f64)
|
||||
// .build();
|
||||
|
||||
// let event = gst::event::CustomUpstream::new(structure);
|
||||
// pipeline.send_event(event);
|
||||
|
||||
// // Update last position and time
|
||||
// *last_move = (x, y, now);
|
||||
// }
|
||||
// }
|
||||
@@ -41,5 +41,5 @@
|
||||
"./*.config.ts",
|
||||
"./*.config.js",
|
||||
"content-collections.ts"
|
||||
]
|
||||
, "../../packages/input/src/webrtc-stream.ts" ]
|
||||
}
|
||||
Reference in New Issue
Block a user