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:
Wanjohi
2024-12-08 14:54:56 +03:00
committed by GitHub
parent 5eb21eeadb
commit 379db1c87b
137 changed files with 12737 additions and 5234 deletions

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

View File

@@ -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 />} */}

View File

@@ -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 (

View File

@@ -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)
// }
// }
// }

View File

@@ -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()},&nbsp;<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> */}
</>
)
})

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