feat: embed optional apps in player server
This commit is contained in:
19
apps/home/app.js
Normal file
19
apps/home/app.js
Normal 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
16
apps/home/index.html
Normal 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
10
apps/home/manifest.json
Normal 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
23
apps/home/style.css
Normal 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
46
apps/lib/arcast.js
Normal 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
3
apps/lib/manifest.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"hidden": true
|
||||
}
|
77
apps/lib/style.css
Normal file
77
apps/lib/style.css
Normal 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%;
|
||||
}
|
66
apps/remote-control/app.js
Normal file
66
apps/remote-control/app.js
Normal 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()
|
36
apps/remote-control/index.html
Normal file
36
apps/remote-control/index.html
Normal 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>
|
10
apps/remote-control/manifest.json
Normal file
10
apps/remote-control/manifest.json
Normal 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
200
apps/screen-sharing/app.js
Normal 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();
|
30
apps/screen-sharing/index.html
Normal file
30
apps/screen-sharing/index.html
Normal 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>
|
10
apps/screen-sharing/manifest.json
Normal file
10
apps/screen-sharing/manifest.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": {
|
||||
"fr": "Partage d'écran",
|
||||
"en": "Screen sharing"
|
||||
},
|
||||
"description": {
|
||||
"fr": "Partager son écran",
|
||||
"en": "Share your screen"
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user