commit b6fb65eb0a72c7e27fb5adc065aed308e308f3a4 Author: EpicKiwi Date: Thu Sep 14 11:43:36 2023 +0200 First working version diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..01cede9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": false +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1828df --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# LOLED + +``` +apt install xorg fluxbox lightdm +``` \ No newline at end of file diff --git a/loled.js b/loled.js new file mode 100755 index 0000000..15a4a7b --- /dev/null +++ b/loled.js @@ -0,0 +1,105 @@ +#!/bin/env -S deno run --allow-net --allow-env --allow-read=. +import { Application, Router } from "https://deno.land/x/oak/mod.ts"; + +let router = new Router() + +let displayIndex = 0; +let display = null; + +router.get("/_loled/display/register", (ctx) => { + + let name = ctx.request.url.searchParams.get("name"); + if(!name){ + name = "display-"+displayIndex + } + displayIndex++; + + const target = ctx.sendEvents(); + display = { name, sse: target } + target.dispatchMessage({ type: "welcome", name }); +}); + +router.post("/_loled/grab-display",async (ctx) => { + const bdy = await ctx.request.body({type: "json"}).value; + + if(display){ + display.sse.dispatchMessage({ type: "offer", offer: bdy.offer, iceCandidates: bdy.iceCandidates }); + + const answr = await new Promise((res, rej) => { + + const timeout = setTimeout(() => { + rej(new Error("Timeout")) + delete display.putAnswer + }, 10000) + + display.putAnswer = (answr) => { + clearTimeout(timeout) + delete display.putAnswer + res(answr) + } + + }) + + ctx.response.type = "application/json" + ctx.response.body = JSON.stringify(answr) + + } else { + ctx.response.status = 404 + ctx.response.body = "No display available" + } +}) + +router.post("/_loled/display/put-answer", async (ctx) => { + const bdy = await ctx.request.body({type: "json"}).value; + + if(display){ + if(display.putAnswer){ + display.putAnswer(bdy) + ctx.response.status = 200 + } else { + ctx.response.status = 404 + ctx.response.body = "No offer pending" + } + } else { + ctx.response.status = 404 + ctx.response.body = "No display available" + } +}); + +const app = new Application(); + +// CORS +app.use((ctx, next) => { + ctx.response.headers.append("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + ctx.response.headers.append("Access-Control-Allow-Origin", "*") + ctx.response.headers.append("Access-Control-Max-Age", "3600") + ctx.response.headers.append("Access-Control-Allow-Headers", "*") + + if(ctx.request.method == "OPTION"){ + ctx.response.status = 200 + } else { + return next() + } +}) + +// Static +app.use(async (ctx, next) => { + if(ctx.request.url.pathname.startsWith("/_loled")) + return next() + + try { + await ctx.send({ + root: `${Deno.cwd()}/static`, + index: "index.html", + path: ctx.request.url.pathname + }); + } catch { + ctx.response.status = 404 + ctx.response.body = "Not found" + } +}) + +app.use(router.routes()) +app.use(router.allowedMethods()) + +await app.listen({ port: Deno.env.get("PORT") || 80 }); \ No newline at end of file diff --git a/static/display/index.html b/static/display/index.html new file mode 100644 index 0000000..abf54d6 --- /dev/null +++ b/static/display/index.html @@ -0,0 +1,59 @@ + + + + + + Display + + + + +
+

LOLED
http://10.0.0.28/

+ +
+ + + + \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..fee9a91 --- /dev/null +++ b/static/index.html @@ -0,0 +1,39 @@ + + + + + + LOLED + + + +

To send canvas of the page to LOLED click the following link : + LOLED that canvas ! +

+

You can also drag this link in your bookmarks to call it on any page with a canvas

+ + + + \ No newline at end of file diff --git a/static/js/display.js b/static/js/display.js new file mode 100644 index 0000000..756e33f --- /dev/null +++ b/static/js/display.js @@ -0,0 +1,95 @@ +async function init( + name="loled-display", + width = window.innerWidth, + height = window.innerHeight, +){ + + const display = document.getElementById("display") + const video = document.getElementById("display-video") + const nameEl = document.getElementById("display-name") + let peerConnection = null + + async function setNewConnection(offer, iceCandidates){ + const connection = new RTCPeerConnection({ + iceServers: [ + {urls: ["stun:stun.nextcloud.com:443"]} + ] + }) + + connection.addEventListener("track", (e) => { + if(e.streams.length > 0){ + video.srcObject = e.streams[0] + video.play() + } + }) + + connection.addEventListener("connectionstatechange", () => { + if(connection.connectionState == "connected"){ + nameEl.hidden = true + } else if(["closed", "failed", "disconnected"].indexOf(connection.connectionState)) { + nameEl.hidden = false + } + }) + + connection.setRemoteDescription(new RTCSessionDescription(offer)) + for(const it of iceCandidates){ + connection.addIceCandidate(it ? new RTCIceCandidate(it) : undefined) + } + + const answr = await connection.createAnswer() + connection.setLocalDescription(answr) + await fetch("/_loled/display/put-answer", { + method: "POST", + body: JSON.stringify(answr), + headers: {"content-type": "application/json"} + }) + console.log("New offer received") + + if(peerConnection){ + peerConnection.close() + } + + peerConnection = connection + nameEl.hidden = true + } + + display.style.width = width+"px" + display.style.height = height+"px" + display.style.setProperty("--display-width", width+"px") + display.style.setProperty("--display-height", height+"px") + + const [registration, registerdName] = await getRegistration(name) + name = registerdName; + + nameEl.hidden = false + + registration.addEventListener("message", e => { + const message = JSON.parse(e.data) + if(message.type == "offer"){ + setNewConnection(message.offer, message.iceCandidates) + } + }) +} + +async function getRegistration(name){ + console.info("Waiting welcome message") + const reg = new EventSource("/_loled/display/register?name="+name) + const registeredName = await new Promise((res, rej) => { + reg.addEventListener("message", e => { + const message = JSON.parse(e.data) + if(message.type != 'welcome'){ + rej(new Error("Invalid message received")) + } + res(message.name) + }, {once: true}) + }) + console.info(`display registerd with name ${registeredName}`) + return [reg, registeredName] +} + +const args = new URLSearchParams(location.search); +init( + args.get("name"), + parseInt(args.get("width")), + parseInt(args.get("height")) +) \ No newline at end of file diff --git a/static/js/grab-canvas.js b/static/js/grab-canvas.js new file mode 100644 index 0000000..43e3d50 --- /dev/null +++ b/static/js/grab-canvas.js @@ -0,0 +1,48 @@ +(async () => { + const globalConnnection = Symbol("grab-canvas-connection") + + const currentScriptSrc = document.currentScript.src; + const canvas = document.querySelector("canvas"); + + if(!canvas){ + console.error("No canvas found on this page") + } + + const conn = new RTCPeerConnection({ + iceServers: [ + {urls: ["stun:stun.nextcloud.com:443"]} + ] + }); + + const iceCandidates = [] + conn.addEventListener("icecandidate", e => { + iceCandidates.push(e.candidate) + }) + + const allCandidatesCollected = new Promise(res => + conn.addEventListener("icecandidate", e => e.candidate == null && res() )) + + const canvasStream = canvas.captureStream() + const canvasStreamTracks = canvasStream.getVideoTracks() + if(canvasStreamTracks.length > 0){ + conn.addTrack(canvasStreamTracks[0], canvasStream) + } + + const offer = await conn.createOffer(); + await conn.setLocalDescription(offer); + + await allCandidatesCollected + + const res = await fetch(new URL("/_loled/grab-display", currentScriptSrc), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + offer, + iceCandidates + }) + }) + const response = new RTCSessionDescription(await res.json()); + conn.setRemoteDescription(response); + + window[globalConnnection] = conn +})() \ No newline at end of file