feat: embed optional apps in player server

This commit is contained in:
2024-01-16 09:27:04 +01:00
parent acd71c84f6
commit 8d46ff7ff8
32 changed files with 1285 additions and 113 deletions

19
apps/home/app.js Normal file
View File

@ -0,0 +1,19 @@
fetch("/api/v1/apps")
.then((res) => res.json())
.then((res) => {
const defaultApp = res.data.defaultApp;
const apps = res.data.apps;
const container = document.createElement("div");
container.className = "container";
apps.forEach((app) => {
if (app.id === defaultApp || app.hidden) return;
const appLink = document.createElement("a");
appLink.className = "app-link";
appLink.href = "/apps/" + app.id + "/";
appLink.innerText = app.title["fr"];
container.appendChild(appLink);
});
document.getElementById("main").replaceWith(container);
});

16
apps/home/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Home</title>
<link rel="stylesheet" href="/apps/lib/style.css">
<link rel="stylesheet" href="style.css">
<script type="text/javascript" src="app.js" defer></script>
</head>
<body>
<div id="main" class="container">
<p class="text-center">Loading...</p>
</div>
</body>
</html>

10
apps/home/manifest.json Normal file
View File

@ -0,0 +1,10 @@
{
"title": {
"fr": "Accueil",
"en": "Home"
},
"description": {
"fr": "Voir la liste des applications",
"en": "See apps list"
}
}

23
apps/home/style.css Normal file
View File

@ -0,0 +1,23 @@
.mt {
margin-top: 1em;
display: block;
}
.app-link {
display: block;
background: white;
border-radius: 5px;
width: 100px;
height: 100px;
box-shadow: 1px 1px 3px #ccc;
color: #333;
text-decoration: none;
text-align: center;
padding-top: 30px;
}
.app-link:hover {
background-color: #abdbdb;
box-shadow: 1px 1px 3px #aaa;
text-shadow: 1px 1px white;
}

46
apps/lib/arcast.js Normal file
View File

@ -0,0 +1,46 @@
(function (Arcast) {
Arcast.getInfo = function () {
return fetch("/api/v1/info")
.then((res) => res.json())
.then((res) => res.data);
};
Arcast.getBroadcastingChannel = function (id, onmessage) {
var scheme = "wss";
if (window.location.protocol === "http:") {
scheme = "ws";
}
var ws = new WebSocket(
`${scheme}://${window.location.host}/api/v1/broadcast/${id}`
);
var channel = {
opened: false,
send: (message) => {
ws.send(message);
},
close: () => {
ws.close();
},
};
ws.onmessage = (evt) => {
if (typeof onmessage !== "function") {
return;
}
onmessage(evt.data);
};
ws.onclose = () => {
ws.onmessage = null;
ws.onclose = null;
ws.onopen = null;
channel.opened = false;
};
ws.onopen = () => {
channel.opened = true;
};
return channel;
};
})((window.Arcast = window.Arcast || {}));

3
apps/lib/manifest.json Normal file
View File

@ -0,0 +1,3 @@
{
"hidden": true
}

77
apps/lib/style.css Normal file
View File

@ -0,0 +1,77 @@
html {
box-sizing: border-box;
font-size: 16px;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
width: 100%;
height: 100%;
background-color: #e1e1e1;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
ol,
ul {
list-style: none;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ol,
ul {
margin: 0;
padding: 0;
font-weight: normal;
}
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.panel {
display: block;
background-color: #fff;
border-radius: 5px;
padding: 10px 20px;
box-shadow: 2px 2px #3333331d;
}
.panel p, .panel ul {
margin-top: 10px;
}
.text-centered {
text-align: center;
}
.text-italic {
font-style: italic;
}
.text-small {
font-size: 0.8em;
}
.fullwidth {
width: 100%;
}

View File

@ -0,0 +1,66 @@
function main() {
refreshStatus();
setInterval(refreshStatus, 10000)
}
function refreshStatus() {
return fetch("/api/v1/status")
.then(res => res.json())
.then(res => {
const newStatus = document.createElement("tr")
newStatus.id = "status"
let td = document.createElement("td")
td.innerText = res.data.status
newStatus.appendChild(td)
td = document.createElement("td")
td.innerText = res.data.title
newStatus.appendChild(td)
td = document.createElement("td")
td.innerText = res.data.url
document.getElementById("url-input").placeholder = res.data.url
newStatus.appendChild(td)
document.getElementById("status").replaceWith(newStatus)
})
.catch(err => {
console.error(err);
window.location.reload()
})
}
function castUrl() {
const urlInput = document.getElementById("url-input")
const url = urlInput.value
if (url === "") return Promise.resolve()
return fetch("/api/v1/cast", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"url": url,
})
})
.then(res => res.json())
.then(() => refreshStatus())
.then(() => {
urlInput.value = ""
})
}
function reset() {
const urlInput = document.getElementById("url-input")
return fetch("/api/v1/cast", {
method: "DELETE",
})
.then(res => res.json())
.then(() => refreshStatus())
.then(() => {
urlInput.value = ""
})
}
main()

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Remote Control</title>
<link rel="stylesheet" href="/apps/lib/style.css" />
<script type="text/javascript" src="../lib/arcast.js" defer></script>
<script type="text/javascript" src="app.js" defer></script>
</head>
<body>
<div id="main" class="container">
<div class="panel">
<h1>Remote control</h1>
<table class="fullwidth">
<thead>
<tr>
<th>Status</th>
<th>Title</th>
<th>URL</th>
</tr>
</thead>
<tbody>
<tr id="status" class="text-centered">
<td colspan="3">Refreshing...</td>
</tr>
</tbody>
</table>
<input class="text-input" id="url-input" placeholder />
<button onclick="castUrl()">Cast</button>
<button onclick="reset()">Reset</button>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
{
"title": {
"fr": "Contrôle à distance",
"en": "Remote control"
},
"description": {
"fr": "Contrôler l'afficheur numérique",
"en": "Control the cast player"
}
}

200
apps/screen-sharing/app.js Normal file
View File

@ -0,0 +1,200 @@
const displayMediaOptions = {
video: {
displaySurface: "browser",
},
audio: {
suppressLocalAudioPlayback: false,
},
preferCurrentTab: false,
selfBrowserSurface: "exclude",
systemAudio: "include",
surfaceSwitching: "include",
monitorTypeSurfaces: "include",
};
const peerConnection = new RTCPeerConnection();
const signaling = Arcast.getBroadcastingChannel(
"screen-sharing",
onSignalReceived
);
var secret = window.crypto.randomUUID();
var receivedAnswer = false;
var receivedOffer = false;
var isOffering = false;
peerConnection.onconnectionstatechange = (evt) => {
console.log("Connection state changed", evt);
};
peerConnection.oniceconnectionstatechange = () => {
console.log("ICE state: ", peerConnection.iceConnectionState);
};
peerConnection.onicecandidate = (evt) => {
signaling.send(
JSON.stringify({
type: "icecandidate",
data: {
candidate: evt.candidate,
},
})
);
};
peerConnection.ontrack = function (e) {
var screenplay = document.getElementById("screenplay");
if (!screenplay) {
screenplay = document.createElement("video");
screenplay.id = "screenplay";
document.body.appendChild(screenplay);
}
screenplay.autoplay = true;
screenplay.playsInline = true;
screenplay.srcObject = e.streams[0];
screenplay.muted = true;
e.track.onended = (e) => (screenplay.srcObject = screenplay.srcObject);
};
function main() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("secret")) {
secret = urlParams.get("secret");
}
}
function shareScreen() {
return Arcast.getInfo().then((info) => {
return navigator.mediaDevices
.getDisplayMedia(displayMediaOptions)
.then((captureStream) => {
isOffering = true;
const videoTrack = captureStream.getVideoTracks()[0];
peerConnection.addTrack(videoTrack, captureStream);
peerConnection.createOffer().then((offer) => {
peerConnection.setLocalDescription(offer).then(() => {
const intervalId = setInterval(() => {
if (receivedAnswer) {
clearInterval(intervalId);
return;
}
signaling.send(
JSON.stringify({
type: "offer",
data: {
secret: secret,
offer: offer,
},
})
);
}, 1000);
const url =
"http://127.0.0.1:" +
info.port +
"/apps/screen-sharing/?secret=" +
encodeURIComponent(secret);
fetch("/api/v1/cast", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: url,
}),
});
});
});
});
});
}
function onSignalReceived(data) {
const message = JSON.parse(data);
switch (message.type) {
case "offer":
if (message.data.secret !== secret) {
return;
}
if (receivedOffer || isOffering) {
return;
}
peerConnection.setRemoteDescription(
new RTCSessionDescription(message.data.offer),
() => {
peerConnection.createAnswer(function (answer) {
peerConnection.setLocalDescription(
answer,
function () {
signaling.send(
JSON.stringify({
type: "answer",
data: {
secret: secret,
answer: answer,
},
})
);
},
error
);
}, error);
},
error
);
receivedOffer = true;
break;
case "answer":
if (receivedAnswer || !isOffering) {
return;
}
if (message.data.secret !== secret) {
return;
}
peerConnection.setRemoteDescription(
new RTCSessionDescription(message.data.answer),
() => {},
error
);
receivedAnswer = true;
break;
case "icecandidate":
if (message.data.candidate) {
peerConnection.addIceCandidate(message.data.candidate);
}
break;
default:
console.log("Received unhandled message", message);
}
}
function endCall() {
var videos = document.getElementsByTagName("video");
for (var i = 0; i < videos.length; i++) {
videos[i].pause();
}
peerConnection.close();
}
function error(err) {
console.error(err);
endCall();
}
main();

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcast - Screen sharing</title>
<link rel="stylesheet" href="/apps/lib/style.css" />
<script type="text/javascript" src="/apps/lib/arcast.js" defer></script>
<script type="text/javascript" src="app.js" defer></script>
<style>
#screenplay {
width: 100% !important;
height: auto !important;
object-fit: contain;
position: absolute;
top: 50%;
transform: translateY(-50%);
object-fit: fill;
}
</style>
</head>
<body>
<div id="main" class="container">
<div class="panel">
<h1>Screen sharing</h1>
<button onclick="shareScreen()">Start</button>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
{
"title": {
"fr": "Partage d'écran",
"en": "Screen sharing"
},
"description": {
"fr": "Partager son écran",
"en": "Share your screen"
}
}