diff --git a/src/page/sample-mixer-pages/singular-live/.gitignore b/src/page/sample-mixer-pages/singular-live/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/src/page/sample-mixer-pages/singular-live/README.md b/src/page/sample-mixer-pages/singular-live/README.md
new file mode 100644
index 00000000..d2a42749
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/README.md
@@ -0,0 +1,62 @@
+# Red5 CEF Mixer Page with Singular.live Integration
+
+This is an example page which can be loaded by a Red5 CEF Mixer to integrate with `Singular.live` overlays in order to broadcast out a single mixed stream.
+
+# Requirements
+
+Before using this example, you will first need to have a [Singular.live account](https://www.singular.live/) and at least one `App` in your account dashboard that can be used.
+
+## App Token
+
+From your `Singular.live` account dashboard, you will see the available `App`s listed. Each `App` has an associated **Public App Token** associated and accessed by first selecting the target `App` from the dashboard, then selecting the `i` icon.
+
+This will show you the associated URLs and Token(s) of the target `App`.
+
+**You will need to provide this token to the CEF Mixer page in order to integrate with your Singular.live App.**
+
+> More information about `Singular.live` and Apps can be found at: [https://support.singular.live/hc/en-us/articles/360034417991-Beginner-s-Guide-to-Singular](https://support.singular.live/hc/en-us/articles/360034417991-Beginner-s-Guide-to-Singular).
+
+# Usage
+
+The Red5 CEF Mixer is instructed to load a specified page at a URL and broadcast a live stream to a target Red5 Server. This is quickly and easily done using the `controller` html provided from the `mixer` webapp.
+
+In order to have a CEF Mixer load this example page to integrate with `Singular.live`, you will first need to deploy the files contained in this repository to a remote location which can be loaded over SSL.
+
+> For the purposes of this document, we will say that the files contained in this repo and the `index.html` page is accessible at: `https://red5testing.com`.
+
+When instructing the CEF Mixer to load this example from `https://red5testing.com` it is important to provided some query parameters in order to integrate with `Singular.live`.
+
+## Query Parameters
+
+The following query parameters are recognized by the page:
+
+### Required
+
+- `sl_token` - The App Token from `Singular.live`. Please read the [Requirements](#requirements) section for accessing the token.
+- `host` - The Red5 Server endpoint that has the live stream you wish to consume and overlay the content from `Singular.live`.
+- `name` - The name of the stream on the `host` Red5 Server to consume.
+
+### Optional
+
+- `app` - The target webapp context that the stream resides in on the `host`. _Default is `live`._
+- `fit` - The `object-fit` style to apply to the playback stream. _Default is `cover`._
+- `vod` - A Flag to denote whether the stream to playback is VOD or not. _Default is `false`._
+- `vodURL` - The full URL to a VOD file to playback. _Default is `undefined`._
+
+When specifying `vod=true`, the page will utilize [HLS.js](https://github.com/video-dev/hls.js/) to playback the HLS file in browsers that do not support native HLS.
+
+> If you provde the full URL for a VOD file using the `vodURL` query parameter, you do not need to define `vod=true` as well. Additionally, any `vodURL` provided is recommended to be url-encoded so as to avoid breaking the parsing of the query parameters.
+
+### Example URL
+
+The following example URL is one to provide to the CEF Mixer with a live playback of a stream named `stream1` accessible from the `live` webapp context on `https://myred5.com` with an overlay App token of `abcd123` from a Singular.live account:
+
+```sh
+https://red5testing.com?sl_token=abcd1234&host=myred5.com&app=live&name=stream1
+```
+
+The following example URL is one to provide to the CEF Mixer if the above stream is a VOD file residing on the same server:
+
+```sh
+https://red5testing.com?sl_token=abcd1234&host=myred5.com&app=live&name=stream1&vod=true
+```
diff --git a/src/page/sample-mixer-pages/singular-live/index.html b/src/page/sample-mixer-pages/singular-live/index.html
new file mode 100644
index 00000000..8138fada
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Red5 & Singular.live
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/page/sample-mixer-pages/singular-live/script/main.js b/src/page/sample-mixer-pages/singular-live/script/main.js
new file mode 100644
index 00000000..d5f7af39
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/script/main.js
@@ -0,0 +1,180 @@
+/*
+Copyright © 2015 Infrared5, Inc. All rights reserved.
+
+The accompanying code comprising examples for use solely in conjunction with Red5 Pro (the "Example Code")
+is licensed to you by Infrared5 Inc. in consideration of your agreement to the following
+license terms and conditions. Access, use, modification, or redistribution of the accompanying
+code constitutes your acceptance of the following license terms and conditions.
+
+Permission is hereby granted, free of charge, to you to use the Example Code and associated documentation
+files (collectively, the "Software") without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The Software shall be used solely in conjunction with Red5 Pro. Red5 Pro is licensed under a separate end
+user license agreement (the "EULA"), which must be executed with Infrared5, Inc.
+An example of the EULA can be found on our website at: https://account.red5pro.com/assets/LICENSE.txt.
+
+The above copyright notice and this license shall be included in all copies or portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL INFRARED5, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+import { query, isHostAnIPAddress } from './url-util.js'
+
+const { host, app, name, fit, vod, overlayToken, vodURL, pageURL } = query()
+const container = document.querySelector('#app')
+
+const red5pro = window.red5prosdk
+const singular = window.SingularGraphics
+red5pro.setLogLevel('debug')
+
+let retryTimeout = 0
+const RETRY_DELAY = 1000
+
+const options = {
+ class: 'overlay',
+ endpoint: 'http://app.singular.live',
+ interactive: false,
+ syncGraphics: true,
+ showPreloader: true,
+ aspect: '',
+}
+
+const overlay = singular('SingularOverlay', options)
+overlay.addListener('message', (type, params) => {
+ console.log('message', type, params)
+})
+overlay.addListener('error', (type, params) => {
+ console.error('error', type, params)
+ showError(
+ `${type}: ${
+ params
+ ? typeof params !== 'string'
+ ? JSON.stringify(params)
+ : params
+ : ''
+ }`
+ )
+})
+overlay.renderAppOutput(overlayToken, null, (success) => {
+ if (success) {
+ if (vod || vodURL) {
+ loadVOD()
+ } else if (pageURL) {
+ loadPage(pageURL)
+ } else {
+ loadLive()
+ }
+ } else {
+ showError("Couldn't load composition")
+ }
+})
+
+const loadLive = async () => {
+ clearTimeout(retryTimeout)
+ try {
+ document.querySelector('#red5pro-subscriber').style['object-fit'] = fit
+ const isSecure = !isHostAnIPAddress(host)
+ // CEF Mixer does not support WHIP/WHEP at the moment...
+ // const subscriber = new red5pro.WHEPClient()
+ const subscriber = new red5pro.RTCSubscriber()
+ subscriber.on('*', (event) => {
+ const { type } = event
+ if (type !== 'Subscribe.Time.Update') {
+ console.log(type)
+ if (
+ type === 'Subscribe.Connection.Closed' ||
+ type === 'Subscribe.Play.Unpublish'
+ ) {
+ subscriber.unsubscribe()
+ retryTimeout = setTimeout(loadLive, RETRY_DELAY)
+ }
+ }
+ })
+ await subscriber.init({
+ host,
+ protocol: isSecure ? 'wss' : 'ws',
+ port: isSecure ? 443 : 5080,
+ app: app || 'live',
+ streamName: name,
+ })
+ await subscriber.subscribe()
+ } catch (e) {
+ console.error(e)
+ if (!vod) {
+ retryTimeout = setTimeout(loadLive, RETRY_DELAY)
+ }
+ }
+}
+
+const loadVOD = async () => {
+ const element = document.getElementById('red5pro-subscriber')
+ const isSecure = !isHostAnIPAddress(host)
+ const protocol = isSecure ? 'https' : 'http'
+ const port = isSecure ? 443 : 5080
+ const url =
+ vodURL || `${protocol}://${host}:${port}/${app || 'live'}/${name}.m3u8`
+ if (Hls.isSupported()) {
+ const hls = new Hls()
+ hls.attachMedia(element)
+ hls.on(Hls.Events.MEDIA_ATTACHED, () => {
+ hls.loadSource(url)
+ })
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ try {
+ element.play()
+ } catch (e) {
+ console.error(e)
+ }
+ })
+ hls.on(Hls.Events.ERROR, function (err, info) {
+ showError(`[Error]:: ${err}, ${JSON.stringify(info)}`)
+ })
+ // Hook in and call overlay SDK methods here:
+ // videoPlaying
+ // videoBuffering
+ // videoFinished
+ // videoTime
+ element.onplay = () => overlay.videoPlaying()
+ element.onwaiting = () => overlay.videoBuffering()
+ element.onended = () => overlay.videoFinished()
+ element.ontimeupdate = () => overlay.videoTime(element.currentTime)
+ } else if (element.canPlayType('application/vnd.apple.mpegurl')) {
+ element.src = url
+ element.addEventListener('loadedmetadata', function () {
+ try {
+ element.play()
+ } catch (e) {
+ console.error(e)
+ }
+ })
+ } else {
+ showError('Your browser does not support HLS video playback.')
+ }
+}
+
+const loadPage = (url) => {
+ const videoContainer = document.querySelector('#video-container')
+ const iframe = document.createElement('iframe')
+ iframe.classList.add('overlay')
+ iframe.style['z-index'] = 0
+ iframe.setAttribute('src', url)
+ iframe.setAttribute('width', '100%')
+ iframe.setAttribute('height', '100%')
+ iframe.setAttribute('frameborder', '0')
+ iframe.setAttribute('scrolling', 'no')
+ iframe.setAttribute('allowfullscreen', 'true')
+ container.insertBefore(iframe, videoContainer)
+ videoContainer.parentNode.removeChild(videoContainer)
+}
+
+const showError = (message) => {
+ const container = document.querySelector('#error-container')
+ const error = document.querySelector('#error-message')
+ container.classList.remove('hidden')
+ error.textContent = message
+}
diff --git a/src/page/sample-mixer-pages/singular-live/script/simple.js b/src/page/sample-mixer-pages/singular-live/script/simple.js
new file mode 100644
index 00000000..70c2c299
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/script/simple.js
@@ -0,0 +1,163 @@
+/*
+Copyright © 2015 Infrared5, Inc. All rights reserved.
+
+The accompanying code comprising examples for use solely in conjunction with Red5 Pro (the "Example Code")
+is licensed to you by Infrared5 Inc. in consideration of your agreement to the following
+license terms and conditions. Access, use, modification, or redistribution of the accompanying
+code constitutes your acceptance of the following license terms and conditions.
+
+Permission is hereby granted, free of charge, to you to use the Example Code and associated documentation
+files (collectively, the "Software") without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The Software shall be used solely in conjunction with Red5 Pro. Red5 Pro is licensed under a separate end
+user license agreement (the "EULA"), which must be executed with Infrared5, Inc.
+An example of the EULA can be found on our website at: https://account.red5pro.com/assets/LICENSE.txt.
+
+The above copyright notice and this license shall be included in all copies or portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL INFRARED5, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+/* global Hls, SingularGraphics, red5prosdk */
+import { query, isHostAnIPAddress } from './url-util.js'
+
+const { host, app, name, fit, vod, overlayToken, vodURL, pageURL } = query()
+const container = document.querySelector('#app')
+
+const red5pro = window.red5prosdk
+const singular = window.SingularGraphics
+red5pro.setLogLevel('debug')
+
+let retryTimeout = 0
+const RETRY_DELAY = 1000
+
+const options = {
+ class: 'overlay',
+ endpoint: 'http://app.singular.live',
+ interactive: false,
+ syncGraphics: true,
+ showPreloader: true,
+ aspect: '',
+}
+
+// > Quick and Simple
+const loadLive = async () => {
+ clearTimeout(retryTimeout)
+ try {
+ document.querySelector('#red5pro-subscriber').style['object-fit'] = fit
+ const isSecure = !isHostAnIPAddress(host)
+ // CEF Mixer does not support WHIP/WHEP at the moment...
+ // const subscriber = new red5pro.WHEPClient()
+ const subscriber = new red5pro.RTCSubscriber()
+ subscriber.on('*', (event) => {
+ const { type } = event
+ if (type !== 'Subscribe.Time.Update') {
+ console.log(type)
+ if (
+ type === 'Subscribe.Connection.Closed' ||
+ type === 'Subscribe.Play.Unpublish'
+ ) {
+ subscriber.unsubscribe()
+ retryTimeout = setTimeout(loadLive, RETRY_DELAY)
+ }
+ }
+ })
+ await subscriber.init({
+ host,
+ protocol: isSecure ? 'wss' : 'ws',
+ port: isSecure ? 443 : 5080,
+ app: app || 'live',
+ streamName: name,
+ })
+ await subscriber.subscribe()
+ } catch (e) {
+ console.error(e)
+ if (!vod) {
+ retryTimeout = setTimeout(loadLive, RETRY_DELAY)
+ }
+ }
+}
+
+const loadVOD = async () => {
+ const element = document.getElementById('red5pro-subscriber')
+ const isSecure = !isHostAnIPAddress(host)
+ const protocol = isSecure ? 'https' : 'http'
+ const port = isSecure ? 443 : 5080
+ const url =
+ vodURL || `${protocol}://${host}:${port}/${app || 'live'}/${name}.m3u8`
+ if (Hls.isSupported()) {
+ const hls = new Hls()
+ hls.attachMedia(element)
+ hls.on(Hls.Events.MEDIA_ATTACHED, () => {
+ hls.loadSource(url)
+ })
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ try {
+ element.play()
+ } catch (e) {
+ console.error(e)
+ }
+ })
+ hls.on(Hls.Events.ERROR, function (err, info) {
+ showError(`[Error]:: ${err}, ${JSON.stringify(info)}`)
+ })
+ } else if (element.canPlayType('application/vnd.apple.mpegurl')) {
+ element.src = url
+ element.addEventListener('loadedmetadata', function () {
+ try {
+ element.play()
+ } catch (e) {
+ console.error(e)
+ }
+ })
+ } else {
+ showError('Your browser does not support HLS video playback.')
+ }
+}
+
+const loadPage = (url) => {
+ const videoContainer = document.querySelector('#video-container')
+ const iframe = document.createElement('iframe')
+ iframe.classList.add('overlay')
+ iframe.style['z-index'] = 0
+ iframe.setAttribute('src', url)
+ iframe.setAttribute('width', '100%')
+ iframe.setAttribute('height', '100%')
+ iframe.setAttribute('frameborder', '0')
+ iframe.setAttribute('scrolling', 'no')
+ iframe.setAttribute('allowfullscreen', 'true')
+ container.insertBefore(iframe, videoContainer)
+ videoContainer.parentNode.removeChild(videoContainer)
+}
+
+const showError = (message) => {
+ const container = document.querySelector('#error-container')
+ const error = document.querySelector('#error-message')
+ container.classList.remove('hidden')
+ error.textContent = message
+}
+
+const load = async () => {
+ try {
+ const url = `https://app.singular.live/apiv2/controlapps/${overlayToken}`
+ const response = await fetch(url, {
+ method: 'GET',
+ })
+ const json = await response.json()
+ const contentURL = json.outputUrl
+ const iframe = document.querySelector('#SingularOverlay')
+ iframe.onload =
+ vod || vodURL ? loadVOD : pageURL ? () => loadPage(pageURL) : loadLive
+ iframe.setAttribute('src', contentURL)
+ } catch (e) {
+ console.error(e)
+ showError(e.message)
+ }
+}
+
+load()
diff --git a/src/page/sample-mixer-pages/singular-live/script/url-util.js b/src/page/sample-mixer-pages/singular-live/script/url-util.js
new file mode 100644
index 00000000..f0fc93f7
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/script/url-util.js
@@ -0,0 +1,81 @@
+/*
+Copyright © 2015 Infrared5, Inc. All rights reserved.
+
+The accompanying code comprising examples for use solely in conjunction with Red5 Pro (the "Example Code")
+is licensed to you by Infrared5 Inc. in consideration of your agreement to the following
+license terms and conditions. Access, use, modification, or redistribution of the accompanying
+code constitutes your acceptance of the following license terms and conditions.
+
+Permission is hereby granted, free of charge, to you to use the Example Code and associated documentation
+files (collectively, the "Software") without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The Software shall be used solely in conjunction with Red5 Pro. Red5 Pro is licensed under a separate end
+user license agreement (the "EULA"), which must be executed with Infrared5, Inc.
+An example of the EULA can be found on our website at: https://account.red5pro.com/assets/LICENSE.txt.
+
+The above copyright notice and this license shall be included in all copies or portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL INFRARED5, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+/**
+ * Utility for parsing query params.
+ * The following query parameters are supported:
+ * - host: The Red5 Pro Server hostname on which the live streams are located.
+ * - app: The app context on the Red5 Pro Server on which the live streams are located.
+ * - sm: A boolean value indicating whether the Red5 Pro Server is a Stream Manager.
+ * - overlay: The Singular.live overlay URL to load.
+ *
+ * @returns {object} Object containing the query params.
+ */
+export const query = () => {
+ const searchParams = new URLSearchParams(window.location.search)
+ const smOpt = searchParams.get('sm')
+ const vodOpt = searchParams.get('vod')
+ let host = searchParams.get('host')
+ ? decodeURIComponent(searchParams.get('host'))
+ : window.location.hostname
+ let app = searchParams.get('app')
+ ? decodeURIComponent(searchParams.get('app'))
+ : 'live'
+ let name = searchParams.get('name')
+ ? decodeURIComponent(searchParams.get('name'))
+ : 'stream1'
+ let fit = searchParams.get('fit') || 'cover'
+ let overlayToken = searchParams.get('sl_token')
+ ? decodeURIComponent(searchParams.get('sl_token'))
+ : undefined
+ let streamManager = smOpt ? smOpt.toLowerCase() === 'true' : false
+ let vod = vodOpt ? vodOpt.toLowerCase() === 'true' : false
+ let vodURL = searchParams.get('vod_url')
+ ? decodeURIComponent(searchParams.get('vod_url'))
+ : undefined
+ let pageURL = searchParams.get('page_url')
+ ? decodeURIComponent(searchParams.get('page_url'))
+ : undefined
+ return {
+ host,
+ app,
+ name,
+ fit,
+ vod,
+ overlayToken,
+ streamManager,
+ vodURL,
+ pageURL,
+ get: (key) => {
+ return searchParams.get(key)
+ },
+ }
+}
+
+const ipRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/
+export const isHostAnIPAddress = (host) => {
+ return ipRegex.test(host)
+}
diff --git a/src/page/sample-mixer-pages/singular-live/style/style.css b/src/page/sample-mixer-pages/singular-live/style/style.css
new file mode 100644
index 00000000..6a695598
--- /dev/null
+++ b/src/page/sample-mixer-pages/singular-live/style/style.css
@@ -0,0 +1,104 @@
+/*
+Copyright © 2015 Infrared5, Inc. All rights reserved.
+
+The accompanying code comprising examples for use solely in conjunction with Red5 Pro (the "Example Code")
+is licensed to you by Infrared5 Inc. in consideration of your agreement to the following
+license terms and conditions. Access, use, modification, or redistribution of the accompanying
+code constitutes your acceptance of the following license terms and conditions.
+
+Permission is hereby granted, free of charge, to you to use the Example Code and associated documentation
+files (collectively, the "Software") without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
+persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The Software shall be used solely in conjunction with Red5 Pro. Red5 Pro is licensed under a separate end
+user license agreement (the "EULA"), which must be executed with Infrared5, Inc.
+An example of the EULA can be found on our website at: https://account.red5pro.com/assets/LICENSE.txt.
+
+The above copyright notice and this license shall be included in all copies or portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL INFRARED5, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 100vw;
+ min-height: 100vh;
+ position: relative;
+ overflow: hidden;
+}
+
+.hidden {
+ display: none!important;
+}
+
+#video-container, .overlay {
+ width: 100vw;
+ height: 100vh;
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ z-index: 0;
+}
+
+#video-container {
+ background-color: black;
+}
+
+.overlay {
+ z-index: 1;
+}
+
+#red5pro-subscriber {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+#error-container {
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.35);
+ z-index: 1000;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+#error-message {
+ background-color: #ddd;
+ width: 45%;
+ height: fit-content;
+ padding: 1rem;
+ font-size: 1.6rem;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+}
diff --git a/src/page/testbed-menu-mixer-sample-pages.html b/src/page/testbed-menu-mixer-sample-pages.html
index f6bb688c..e606fa18 100644
--- a/src/page/testbed-menu-mixer-sample-pages.html
+++ b/src/page/testbed-menu-mixer-sample-pages.html
@@ -32,6 +32,7 @@ Red5 Pro HTML Sample Mixer Composition Pages
7x7 Grid Layout
Dynamic NxN Grid Layout
Conference Layout
+ Singular.live Overlay