diff --git a/IFRAME.md b/IFRAME.md
index 0b93c64..ce8b529 100644
--- a/IFRAME.md
+++ b/IFRAME.md
@@ -1,217 +1,303 @@
-## OBS.Ninja - iFrame API documentation
+## OBS.Ninja - iFrame API documentation
OBS.Ninja (OBSN) is offers here a simple and free solution to quickly enable real-time video streaming in their websites. OBSN wishes to make live video streaming development accessible to any developer, even novices, yet still remain flexible and powerful.
While OBS.Ninja does offer source-code to customize the application and UI at a low level, this isn't for beginners and it is rather hard to maintain. As well, due to the complexity of video streaming in the web, typical approaches for offering API access isn't quite feasible either.
-The solution decided on isn't an SDK framework, but rather the use of embeddable *IFrames* and a corresponding bi-directional iframe API. An [iframe](https://www.w3schools.com/tags/tag_iframe.ASP) allows us to embed a webpage inside a webpage, including OBS.Ninja into your own website.
+The solution decided on isn't an SDK framework, but rather the use of embeddable _IFrames_ and a corresponding bi-directional iframe API. An [iframe](https://www.w3schools.com/tags/tag_iframe.ASP) allows us to embed a webpage inside a webpage, including OBS.Ninja into your own website.
Modern web browsers allow the parent website to communicate with the child webpage, giving a high-level of control to a developer, while also abstracting the complex code and hosting requirements. Functionality, we can make an OBSN video stream act much like an HTML video element tag, where you can issue commands like play, pause, or change video sources with ease.
Creating an OBSN iframe can be done in HTML or programmatically with Javascript like so:
- var iframe = document.createElement("iframe");
- iframe.allow="autoplay;camera;microphone";
- iframe.allowtransparency="false"
- iframe.src = "https://obs.ninja/?webcam";
+```js
+const iframe = document.createElement("iframe");
+iframe.allow = "autoplay;camera;microphone";
+iframe.allowtransparency = "false";
+iframe.src = "https://obs.ninja/?webcam";
+```
-Adding that iframe to the DOM will reveal a simple page accessing for a user to select and share their webcam. For a developer wishing to access a remote guest's stream, this makes the ingestion of that stream into production software like OBS Studios very easy. The level of customization and control opens up opportunities, such as a pay-to-join audience option for a streaming interactive broadcast experience.
+Adding that iframe to the DOM will reveal a simple page accessing for a user to select and share their webcam. For a developer wishing to access a remote guest's stream, this makes the ingestion of that stream into production software like OBS Studios very easy. The level of customization and control opens up opportunities, such as a pay-to-join audience option for a streaming interactive broadcast experience.
An example of how this API is used by OBS.Ninja is with its Internet Speedtest, which has two OBS.Ninja IFrames on a single page. One iframe feeds video to the other iframe, and the speed at which it does this is a measure of the system's performance. Detailed stats of the connection are made available to the parent window, which displays the results.
https://obs.ninja/speedtest
More community-contributed IFRAME examples can be found here: https://github.com/steveseguin/obsninja/tree/master/examples
-A sandbox of options is available at this page, too: https://obs.ninja/iframe You can enter an OBS.Ninja URL in the input box to start using it. For developers, viewing the source of that page will reveal examples of how all the available functions work, along with a way to test and play with each of them. You can also see here for the source-code on GitHub: https://github.com/steveseguin/obsninja/blob/master/iframe.html
+A sandbox of options is available at this page, too: https://obs.ninja/iframe You can enter an OBS.Ninja URL in the input box to start using it. For developers, viewing the source of that page will reveal examples of how all the available functions work, along with a way to test and play with each of them. You can also see here for the source-code on GitHub: https://github.com/steveseguin/obsninja/blob/master/iframe.html
-One thing to note about this iframe API is that it is a mix of URL parameters given to the iframe *src* URL, but also the postMessage and addEventListener methods of the browser. The later is used to dynamically control OBS.Ninja, while the former is used to initiate the instance to a desired state.
+One thing to note about this iframe API is that it is a mix of URL parameters given to the iframe _src_ URL, but also the postMessage and addEventListener methods of the browser. The later is used to dynamically control OBS.Ninja, while the former is used to initiate the instance to a desired state.
For more information on the URL parameters thare are available, please see: https://github.com/steveseguin/obsninja/wiki/Advanced-Settings
Some of the more interesting ones primarily for iframe users might include:
- - &webcam
- - &screenshare
- - &videodevice=1 or 0
- - &audiodevice=1 or 0
- - &autostart
- - &chroma
- - &transparency
- -
-As for API, allow for dynamic messaging, below are examples of the options available:
+- &webcam
+- &screenshare
+- &videodevice=1 or 0
+- &audiodevice=1 or 0
+- &autostart
+- &chroma
+- &transparency
+- As for API, allow for dynamic messaging, below are examples of the options available:
- - Mute Speaker
- - Mute Mic
- - Disconnect
- - Change Video Bitrate
- - Reload the page
- - Change the volume
- - Request detailed connection stats
- - Access the loudness level of the audio
- - Send/Recieve a chat message to other connected guests
- - Get notified when there is a video connection
+- Mute Speaker
+- Mute Mic
+- Disconnect
+- Change Video Bitrate
+- Reload the page
+- Change the volume
+- Request detailed connection stats
+- Access the loudness level of the audio
+- Send/Recieve a chat message to other connected guests
+- Get notified when there is a video connection
+
+As for the actually details for methods and options available to dynamically control child OBSN iframe, they are primarily kept up to via the iframe.html file that is mentioned previously. see: _iframe.html_. Below is a snippet from that file:
+
+```js
+let button = document.createElement("button");
+button.innerHTML = "Mute Speaker";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "mute": true
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Un-Mute Speaker";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "mute": false
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Toggle Speaker";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "mute": "toggle"
+ }, '*');
+}
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Mute Mic";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "mic": false
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Un-Mute Mic";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "mic": true
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Toggle Mic";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "mic": "toggle"
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Disconnect";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "close": true
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Low Bitrate";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "bitrate": 30
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "High Bitrate";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "bitrate": 5000
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Default Bitrate";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "bitrate": -1
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Reload";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "reload": true
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "50% Volume";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "volume": 0.5
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "100% Volume";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "volume": 1.0
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Request Stats";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "getStats": true
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Request Loudness Levels";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "getLoudness": true
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Stop Sending Loudness Levels";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "getLoudness": false
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "Say Hello";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "sendChat": "Hello!"
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "previewWebcam()";
+button.onclick = () => {
+ iframe.contentWindow.postMessage({
+ "function": "previewWebcam"
+ }, '*');
+};
+iframeContainer.appendChild(button);
+
+button = document.createElement("button");
+button.innerHTML = "CLOSE IFRAME";
+button.onclick = () => {
+ iframeContainer.parentNode.removeChild(iframeContainer);
+};
+iframeContainer.appendChild(button);
+
+// As for listening events, where the parent listens for responses or events from the OBSN child frame:
+
+// ////////// LISTEN FOR EVENTS
+
+const eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
+const eventer = window[eventMethod];
+const messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
+
+eventer(messageEvent, function (e) {
+ if (e.source !== iframe.contentWindow) {
+ return
+ } // reject messages send from other iframes
+
+ if ("stats" in e.data) {
+ const outputWindow = document.createElement("div");
+
+ let out = `
total_inbound_connections:${
+ e.data.stats.total_inbound_connections
+ }`;
+ out += `
total_outbound_connections:${
+ e.data.stats.total_outbound_connections
+ }`;
+
+ for (const streamID in e.data.stats.inbound_stats) {
+ out += `
streamID: ${streamID}
`;
+ out += printValues(e.data.stats.inbound_stats[streamID]);
+ }
+
+ outputWindow.innerHTML = out;
+ iframeContainer.appendChild(outputWindow);
+ }
+
+ if ("gotChat" in e.data) {
+ const outputWindow = document.createElement("div");
+ outputWindow.innerHTML = e.data.gotChat.msg;
+ outputWindow.style.border = "1px dotted black";
+ iframeContainer.appendChild(outputWindow);
+ }
+
+ if ("action" in e.data) {
+ const outputWindow = document.createElement("div");
+ outputWindow.innerHTML = `child-page-action: ${
+ e.data.action
+ }
`;
+ outputWindow.style.border = "1px dotted black";
+ iframeContainer.appendChild(outputWindow);
+ }
+
+ if ("loudness" in e.data) {
+ console.log(e.data);
+ if (document.getElementById("loudness")) {
+ outputWindow = document.getElementById("loudness");
+ } else {
+ const outputWindow = document.createElement("div");
+ outputWindow.style.border = "1px dotted black";
+ iframeContainer.appendChild(outputWindow);
+ outputWindow.id = "loudness";
+ }
+ outputWindow.innerHTML = "child-page-action: loudness
";
+ for (const key in e.data.loudness) {
+ outputWindow.innerHTML += `${key} Loudness: ${
+ e.data.loudness[key]
+ }\n`;
+ }
+ outputWindow.style.border = "1px black";
+
+ }
+});
+```
-As for the actually details for methods and options available to dynamically control child OBSN iframe, they are primarily kept up to via the iframe.html file that is mentioned previously. see: *iframe.html*. Below is a snippet from that file:
-
- var button = document.createElement("button");
- button.innerHTML = "Mute Speaker";
- button.onclick = function(){iframe.contentWindow.postMessage({"mute":true}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Un-Mute Speaker";
- button.onclick = function(){iframe.contentWindow.postMessage({"mute":false}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Toggle Speaker";
- button.onclick = function(){iframe.contentWindow.postMessage({"mute":"toggle"}, '*');}
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Mute Mic";
- button.onclick = function(){iframe.contentWindow.postMessage({"mic":false}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Un-Mute Mic";
- button.onclick = function(){iframe.contentWindow.postMessage({"mic":true}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Toggle Mic";
- button.onclick = function(){iframe.contentWindow.postMessage({"mic":"toggle"}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Disconnect";
- button.onclick = function(){iframe.contentWindow.postMessage({"close":true}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Low Bitrate";
- button.onclick = function(){iframe.contentWindow.postMessage({"bitrate":30}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "High Bitrate";
- button.onclick = function(){iframe.contentWindow.postMessage({"bitrate":5000}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Default Bitrate";
- button.onclick = function(){iframe.contentWindow.postMessage({"bitrate":-1}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Reload";
- button.onclick = function(){iframe.contentWindow.postMessage({"reload":true}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "50% Volume";
- button.onclick = function(){iframe.contentWindow.postMessage({"volume":0.5}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "100% Volume";
- button.onclick = function(){iframe.contentWindow.postMessage({"volume":1.0}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Request Stats";
- button.onclick = function(){iframe.contentWindow.postMessage({"getStats":true}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Request Loudness Levels";
- button.onclick = function(){iframe.contentWindow.postMessage({"getLoudness":true}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Stop Sending Loudness Levels";
- button.onclick = function(){iframe.contentWindow.postMessage({"getLoudness":false}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "Say Hello";
- button.onclick = function(){iframe.contentWindow.postMessage({"sendChat":"Hello!"}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "previewWebcam()";
- button.onclick = function(){iframe.contentWindow.postMessage({"function":"previewWebcam"}, '*');};
- iframeContainer.appendChild(button);
-
- var button = document.createElement("button");
- button.innerHTML = "CLOSE IFRAME";
- button.onclick = function(){iframeContainer.parentNode.removeChild(iframeContainer);};
- iframeContainer.appendChild(button);
-
-As for listening events, where the parent listens for responses or events from the OBSN child frame:
-
- //////////// LISTEN FOR EVENTS
-
- var eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
- var eventer = window[eventMethod];
- var messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message";
-
- eventer(messageEvent, function (e) {
- if (e.source != iframe.contentWindow){return} // reject messages send from other iframes
-
- if ("stats" in e.data){
- var outputWindow = document.createElement("div");
-
- var out = "
total_inbound_connections:"+e.data.stats.total_inbound_connections;
- out += "
total_outbound_connections:"+e.data.stats.total_outbound_connections;
-
- for (var streamID in e.data.stats.inbound_stats){
- out += "
streamID: "+streamID+"
";
- out += printValues(e.data.stats.inbound_stats[streamID]);
- }
-
- outputWindow.innerHTML = out;
- iframeContainer.appendChild(outputWindow);
- }
-
- if ("gotChat" in e.data){
- var outputWindow = document.createElement("div");
- outputWindow.innerHTML = e.data.gotChat.msg;
- outputWindow.style.border="1px dotted black";
- iframeContainer.appendChild(outputWindow);
- }
-
- if ("action" in e.data){
- var outputWindow = document.createElement("div");
- outputWindow.innerHTML = "child-page-action: "+e.data.action+"
";
- outputWindow.style.border="1px dotted black";
- iframeContainer.appendChild(outputWindow);
- }
-
- if ("loudness" in e.data){
- console.log(e.data);
- if (document.getElementById("loudness")){
- outputWindow = document.getElementById("loudness");
- } else {
- var outputWindow = document.createElement("div");
- outputWindow.style.border="1px dotted black";
- iframeContainer.appendChild(outputWindow);
- outputWindow.id = "loudness";
- }
- outputWindow.innerHTML = "child-page-action: loudness
";
- for (var key in e.data.loudness) {
- outputWindow.innerHTML += key + " Loudness: " + e.data.loudness[key] + "\n";
- }
- outputWindow.style.border="1px black";
-
- }
- });
-
This OBS.Ninja API is developed and expanded based on user feedback and requests. It is by no means complete.
Regarding versioning, I currently host past versions of OBS.Ninja, so using those past versions can ensure some level of consistency and expectation. https://obs.ninja/v12/ for example is the version 12 hosted version. The active and main production version of OBSN of course undergoes constant updates, and while I try to maintain backwards compatibility with changes to the API, it is still early days and changes might happen.
Please feel free to follow me in the OBS.Ninja Discord channel, where I post news about updates and listen to requests. The upcoming version of OBS.Ninja is also often hosted at https://obs.ninja/beta, where you can explore new features and help crush any unexpected bugs.
-
-steve
diff --git a/LICENCE.md b/LICENCE.md
index 24b2a39..4fc7ede 100644
--- a/LICENCE.md
+++ b/LICENCE.md
@@ -1,5 +1,5 @@
The OBS.Ninja source repository is governed by the GNU AFFERO GENERAL PUBLIC LICENSE. (AGPL-3.0)
-That AGPL-3.0 licence can be found here: https://raw.githubusercontent.com/steveseguin/obsninja/master/AGPLv3.md
+That AGPL-3.0 licence can be found here: [AGPLv3.md](https://github.com/steveseguin/obsninja/blob/master/AGPLv3.md)
In essence, OBS.Ninja is open-source and free to use, both for commercial and non-commercial use.
Modifications of AGPL-3.0 licenced code must be made publicly accessible. Please refer to that licence.
diff --git a/README.md b/README.md
index 8720087..31dd2ea 100644
--- a/README.md
+++ b/README.md
@@ -20,10 +20,10 @@ And Here is another video series touching on some more advanced settings: https:
Check the subreddit for added use cases, advanced features, and support. Advanced features includes high-quality audio modes, custom video resolutions, and more.
-MacOS users will face some challenges in using OBS 25/26, but there are workarounds. Please see the subreddit or the Wiki.
+MacOS users will face some challenges in using OBS 25/26, but there are workarounds. Please see the subreddit or [the Wiki](https://github.com/steveseguin/obsninja/wiki).
## What's in this repo?
-This repo contains software for OBS.Ninja, including the HTML landing page for its Electron Capture app offering. A sample config file and instructions for setting up a TURN server (video relay server), is also provided. You may also find the Wiki for the project in this repo, which contains added information on how to use the software.
+This repo contains software for OBS.Ninja, including the HTML landing page for its Electron Capture app offering. A sample config file and instructions for setting up a TURN server (video relay server), is also provided. You may also find [the Wiki](https://github.com/steveseguin/obsninja/wiki) for the project in this repo, which contains added information on how to use the software.
## How to Deploy this Repo:
To use, just download and host the files on a HTTPS-enabled webserver. You may want to hide the .html extensions within your HTTP server as well, else the generated links will not work. See [here](https://github.com/steveseguin/obsninja/blob/master/install.md) for added details and alternative install options.
@@ -33,7 +33,7 @@ Directions on how to deploy a TURN server are listed in the turnserver.md file.
## Server side / API software?
Since OBS.Ninja uses peer-2-peer technology, video connections are made directly between viewer and publisher in 90% of cases. Hosting a TURN server yourself may help improve performance, but less than 1% of users will see any benefit of this. Details on how to deploy a TURN server are provided. For those capable of hosting their own TURN server, that would be appreciated if possible, as TURN servers are the only real cost incurred by OBS.Ninja at present. (other than time, of course)
-Other than TURN servers, OBS.Ninja also uses public STUN servers and a hosted handshake server. These are used to facilitate the initial setup of peer connections and are generally not required after a peer connection is established. These servers are free to access and use, even for private deployments.
+Other than TURN servers, OBS.Ninja also uses public STUN servers and a hosted handshake server. These are used to facilitate the initial setup of peer connections and are generally not required after a peer connection is established. These servers are free to access and use, even for private deployments. The handshake server's code is currently not available, so basic access to the Internet is still required to use OBS.Ninja even with a private deployment.
Development builds of OBS.Ninja may include debugging software, but in-production releases have this removed. Double check to ensure "console.re" debugging is disabled before deployment, just to be safe.
@@ -63,7 +63,7 @@ https://steves.app/
A browser-based studio solution and simplified alternative to OBS, with built-in OBS.Ninja functionality. It is a server-based approach to group interactions and live production. Steve Seguin is affiliated with StageTEn, yet StageTEN is not affiliated with OBS.Ninja.
## Privacy
-I try to avoid data collection whenever possible and video streams are generally designed to be private, but use at your own risk. It is best to not share links created with OBS.Ninja with those you do not trust. I've provided instructions on how to deploy a TURN server if IP-address privacy is an issue for you. See: turnserver.md
+I try to avoid data collection whenever possible and video streams are generally designed to be private, but use at your own risk. It is best to not share links created with OBS.Ninja with those you do not trust. I've provided instructions on how to deploy a TURN server if IP-address privacy is an issue for you. See: [turnserver.md](turnserver.md)
https://obs.ninja may unavoidably use cookies that are exempt from EU laws of requiring notice of their use; they are exempt as they are required and necessary for the technical functioning of the web service. Our webserver is cached by Cloudflare and it provides denial of server protection for the users of OBS.Ninja.
diff --git a/devices.css b/devices.css
index db6d72b..79a9458 100644
--- a/devices.css
+++ b/devices.css
@@ -9,6 +9,7 @@ h1 {
padding:10px;
background-color:#457b9d;
color:white;
+ border-bottom: 2px solid #3b6a87;
}
.device {
@@ -18,6 +19,9 @@ h1 {
font-size: 1rem;
padding: 10px;
position: relative;
+ user-select: none;
+ background: #d0d0d0;
+ border-radius: 4px;
}
.device.selected {
@@ -55,12 +59,13 @@ h1 {
}
.notice {
- background-color: orange;
+ background-color: #fff18c;
margin: 10px;
padding: 20px 20px;
font-weight: bold;
font-size: 1.2em;
text-align: center;
+ line-height: 1.4em;
}
.notice a {
@@ -89,7 +94,7 @@ h1 {
left: 10%;
color: white;
overflow-wrap: anywhere;
- background: #141926;
+ background: #2c3754;
padding: 20px;
box-shadow: 0px 0px 10px 5px #00000047;
border: 1px solid #333c52;
diff --git a/devices.html b/devices.html
index bcb0331..49e1126 100644
--- a/devices.html
+++ b/devices.html
@@ -22,7 +22,8 @@