First working version

This commit is contained in:
EpicKiwi 2023-09-14 11:43:36 +02:00
commit b6fb65eb0a
Signed by: epickiwi
GPG Key ID: C4B28FD2729941CE
7 changed files with 356 additions and 0 deletions

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": false
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# LOLED
```
apt install xorg fluxbox lightdm
```

105
loled.js Executable file
View File

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

59
static/display/index.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Display</title>
<style>
body {
font-family: monospace;
color: white;
background: black;
overflow: hidden;
max-width: 100vw;
max-height: 100vh;
margin: 0;
padding: 0;
}
#display {
position: relative;
}
#display-name {
width: var(--display-height);
transform-origin: 0 0;
transform: rotate(90deg) translateY(-100%);
position: absolute;
top: 0;
left: 0;
font-size: 1.3rem;
text-align: left;
margin: 0;
padding: 5px 15px;
z-index: 1000;
font-weight: bold;
}
#display-video {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.75;
}
</style>
</head>
<body>
<main id="display">
<p id="display-name">LOLED<br/>http://10.0.0.28/</p>
<video id="display-video" muted></video>
</main>
<script type="module" src="../js/display.js"></script>
</body>
</html>

39
static/index.html Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LOLED</title>
</head>
<body>
<canvas></canvas>
<p>To send canvas of the page to LOLED click the following link :
<a id="link" href="">LOLED that canvas !</a>
</p>
<p>You can also drag this link in your bookmarks to call it on any page with a canvas</p>
<script>
document.getElementById("link").href = `javascript:(function(){let s = document.createElement('script');s.src = '${new URL("/js/grab-canvas.js", document.documentElement.baseURI).toString()}';document.body.appendChild(s);})()`
</script>
<script>
const canvas = document.querySelector("canvas");
canvas.width = 500
canvas.height = 500
const ctx = canvas.getContext("2d");
let incr = 0;
function draw(){
ctx.fillStyle = `hsl(${incr}deg 100% 50%)`;
ctx.fillRect(-50, -50, 100, 100)
incr += 1;
requestAnimationFrame(draw)
}
ctx.translate(canvas.width/2, canvas.height/2),
draw()
</script>
</body>
</html>

95
static/js/display.js Normal file
View File

@ -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"))
)

48
static/js/grab-canvas.js Normal file
View File

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