diff --git a/images/ScreamRouter.png b/images/ScreamRouter.png index 46cd5f4..cee02db 100644 Binary files a/images/ScreamRouter.png and b/images/ScreamRouter.png differ diff --git a/readme.md b/readme.md index 5a7727f..c0316c9 100644 --- a/readme.md +++ b/readme.md @@ -22,6 +22,7 @@ The simplicity of Scream allows it to be very easy to work with while retaining * Can use ffmpeg to delay any sink, route, source, or group so sinks line up better * Can adjust equalization for any sink, route, source, or group * Contains a plugin system to easily allow additional sources to be added +* Milkdrop Visualizations thanks to the browser-based [Butterchurn](https://github.com/jberg/butterchurn) project * There are playback sinks available for common OSes such as [Windows](https://github.com/duncanthrax/scream/tree/master/Receivers/dotnet-windows/ScreamReader), [Linux](https://github.com/duncanthrax/scream/tree/master/Receivers/unix), and [Android](https://github.com/martinellimarco/scream-android/tree/90d1364ee36dd12ec9d7d2798926150b370030f3), as well as some embedded devices such as [the ESP32](http://tomeko.net/projects/esp32_rtp_pager/). ![Screenshot of ScreamRouter Equalizer](/images/Equalizer.png) diff --git a/site/imports/selects.html.jinja b/site/imports/selects.html.jinja index f32abbc..85630de 100644 --- a/site/imports/selects.html.jinja +++ b/site/imports/selects.html.jinja @@ -1,5 +1,5 @@ {% macro option(value, label, class="", data="{}", checked=False) %} - 🎚️⚙️{{ "🔊" if data["enabled"] else "🔇"}}{% if (data.__class__.__name__ == "SinkDescription" and not data.is_group) %}📻{% endif %}{{label}} + 🎚️⚙️{{ "🔊" if data["enabled"] else "🔇"}}{% if (data.__class__.__name__ == "SinkDescription" and not data.is_group) %}📻{% endif %}{{label}} {%- endmacro %} {% macro make_multiple_options(options, data="{}") %} diff --git a/site/index.html.jinja b/site/index.html.jinja index 23efc3a..c178261 100644 --- a/site/index.html.jinja +++ b/site/index.html.jinja @@ -2,97 +2,57 @@ {%- import "imports/buttons.html.jinja" as buttons -%} - - - ScreamRouter - - - - + + + + ScreamRouter + + + + + + + + + + + + + + + +
ScreamRouter - + +
-
-
-
- Sources -
-
- - {{- buttons.button("add_source_button", "➕", "section-button")}} - {{- buttons.button("add_source_group_button", "Add Group", "section-button")}} -
- {%- set options = namespace(options=[]) %} - {%- for source in sources %} - {{- source.type }} - {%- set enabled_class_str = "enabled" if source.enabled else "disabled" %} - {%- set _option = {"value": source.name, - "label": source.name, - "class": "option option-%s" % enabled_class_str, - "data": source} %} - {%- set options.options = options.options + [_option] %} - {%- endfor %} - {{- selects.make_multiple_options(options.options, sources) }} -
-
-
-
-
-
-
- Routes -
-
- - {{- buttons.button("add_route_button", "➕", "section-button")}} -
- {%- set options = namespace(options=[]) %} - {%- for route in routes %} - {{- route.type }} - {%- set enabled_class_str = "enabled" if route.enabled else "disabled" %} - {%- set _option = {"value": route.name, - "label": "%s [%s->%s]" % (route.name, - route.source, - route.sink), - "class": "option option-%s" % enabled_class_str, - "data": route} %} - {%- set options.options = options.options + [_option] %} - {%- endfor %} - {{- selects.make_multiple_options(options.options, routes) }} -
-
-
-
-
+
+ {%- import "index_body.html.jinja" as index_body %} + {{ index_body.main_body(sources, sinks, routes) }} +
+
+
+
+
+
+
-
- Sinks -
-
- - {{- buttons.button("add_sink_button", "➕", "section-button")}} - {{- buttons.button("add_sink_group_button", "Add Group", "section-button")}} -
- {%- set options = namespace(options=[]) %} - {%- for sink in sinks %} - {%- set enabled_class_str = "enabled" if sink.enabled else "disabled" %} - {%- set _option = {"value": sink.name, - "label": sink.name, - "class": "option option-%s" % enabled_class_str, - "data": sink} %} - {%- set options.options = options.options + [_option] %} - {%- endfor %} - {{- selects.make_multiple_options(options.options, sinks) }} -
-
-
-
-
+
+
Preset:
+
Cycle: + + +
+
Random:
+
+ + +
-
-
- - +
+ + + \ No newline at end of file diff --git a/site/screamrouter.css.jinja b/site/screamrouter.css.jinja index a8d98e8..3e7122a 100644 --- a/site/screamrouter.css.jinja +++ b/site/screamrouter.css.jinja @@ -7,7 +7,7 @@ DIV#dialog { height: auto; top: 12.5%; margin-left: -16.5%; - background: linear-gradient(-45deg, rgba(0,10,0,.35), rgba(0,200,0,.7)); + background: linear-gradient(-45deg, rgba(0,10,0,.15), rgba(0,200,0,.4)); border-radius: 30px; display: none; backdrop-filter: blur(5px); @@ -34,7 +34,7 @@ DIV.title { width: 100%; overflow: hidden; border-collapse: collapse; - background-color: #004400; + background-color: rgba(00,120,00,.2); border-radius: 25px; background-size: 60vmin 60vmin; display: inline-block; @@ -44,7 +44,7 @@ DIV.title { DIV.section { overflow: hidden; border-collapse: collapse; - background-color: #004400; + background-color: rgba(00,120,00,.2); border-radius: 25px; background-size: 60vmin 60vmin; width: 33%; @@ -81,7 +81,7 @@ SELECT.main-select { margin-left: -1%; background:none; width: 101%; - background-image: linear-gradient(rgba(60,221,90,.75),rgba(153,153,255,.75)); + background-image: linear-gradient(rgba(60,221,90,.45),rgba(153,153,255,.45)); overflow: hidden; padding-left: 2%; font-size: 1.25vw; @@ -96,7 +96,7 @@ DIV.main-select { margin-left: -1%; background:none; width: 101%; - background-image: linear-gradient(rgba(60,221,90,.75),rgba(153,153,255,.75)); + background-image: linear-gradient(rgba(60,221,90,.45),rgba(153,153,255,.45)); overflow: hidden; padding-left: 2%; font-size: 1.25vw; @@ -128,12 +128,14 @@ option:active, option:focus, option:hover, option:checked:after { HTML, BODY { - height: 99%; + height: 100%; background-color: #000100; font-family: "Arial"; user-select: none; /* Standard */ overflow: auto; text-align: center; + position: relative; + z-index: -500; } @@ -163,8 +165,16 @@ DIV#randomcontainer4 { border-radius: 25px; } +DIV#randomcontainer5 { + background-image: {% for i in range(0, 30) %}radial-gradient(rgba({{ range(0, 10) | random}}, {{ range(40, 255) | random}}, {{ range(0, 10) | random}}, {{ range(0, 250) | random}}) {{ range(0, 20) | random}}%, transparent 0), {% endfor %}radial-gradient(#{{ "%02X" | format(range(0,45) | random | int) }}{{ "%02X" | format(range(0,150) | random | int) }}{{ "%02X" | format(range(0,45) | random | int) }} {{ range(0, 30) | random}}%, transparent 0); + background-position: {% for i in range(0, 30) %}{{ range(0, 250) | random}}% {{ range(0, 250) | random}}%, {% endfor %}{{ range(0, 250) | random}}% {{ range(0, 250) | random}}%; + overflow: hidden; + border-radius: 25px; + float: left; +} + DIV.blur { - backdrop-filter: blur(30px); + backdrop-filter: blur(15px); height: 100%; width: 100%; border-collapse: collapse; @@ -325,4 +335,26 @@ SELECT.main-select OPTION[DATA-TYPE="SinkDescription"].option-disabled::before { SPAN.option-label { overflow: visible; +} + +DIV#presetControls { + padding-top: 15px; + color: #000000; + font-size: 14pt; + text-shadow: 0 0 20px #fff, 0 0 30px #AA7777, 0 0 40px #AA7777, 0 0 50px #AA7777, 0 0 60px #AA7777, 0 0 70px #AA7777, 0 0 80px #AA7777; + text-align: left; +} + +DIV#mainWrapper { + display: none; + left: 0px; + top: 0px; + width: 100%; +} + +canvas#canvas { + bottom: 0px; + margin-bottom: -5px; + width: 720px; + height: 480px; } \ No newline at end of file diff --git a/site/screamrouter.js.jinja b/site/screamrouter.js.jinja index bbb1f0e..5af95aa 100644 --- a/site/screamrouter.js.jinja +++ b/site/screamrouter.js.jinja @@ -1,24 +1,23 @@ -function call_api(endpoint, method, data={}, callback=null_callback) { +function call_api(endpoint, method, data = {}, callback = null_callback) { const xhr = new XMLHttpRequest(); xhr.open(method, endpoint, true); xhr.getResponseHeader("Content-type", "application/json"); - data=JSON.stringify(data) + data = JSON.stringify(data) if (method.toLowerCase() == "post" || method.toLowerCase() == "put") xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); xhr.send(data) - xhr.onload = function() { + xhr.onload = function () { console.log(this.responseText) try { data = JSON.parse(this.responseText); a = data - if (data.error != undefined) - { + if (data.error != undefined) { alert(data.error); } - } catch(error) {} + } catch (error) { } callback(this.responseText); } @@ -28,7 +27,14 @@ function null_callback(response_text) { } function restart_callback(response_text) { - location.reload(); + call_api("/body", "get", {}, restart_callback_2) +} + +function restart_callback_2(response_text) { + var reload_div = document.getElementById("reload"); + console.log(response_text); + reload_div.innerHTML = ""; + reload_div.innerHTML = response_text; } function show_dialog_callback(response_text) { @@ -175,15 +181,14 @@ window.addEventListener("load", (event) => { audio = {}; audio_playing = false; +visual_playing = false; function listen_to_sink_button(option) { - if (audio_playing) - { + if (audio_playing) { stop_audio(); } else { console.log(option) - if (option.dataset["is_group"] != "True") - { + if (option.dataset["is_group"] != "True") { start_audio(option.dataset["ip"]); } else @@ -191,20 +196,40 @@ function listen_to_sink_button(option) { } } +function visualizer_icon_onclick(e) { + option = e.target.parentNode; + if (visual_playing) { + stop_visualizer(); + } else { + console.log(option) + if (option.dataset["is_group"] != "True") { + start_visualizer(option.dataset["ip"]); + } + else + alert("Can't listen to group, must listen to sink endpoint"); + } +} + +already_connected = false; +visual_already_connected = false; + + function start_audio(sink_ip) { audiotag = document.getElementById("audio") audiotag.pause(); - audiotag.src = ""; + audiotag.src = "" audiotag.src = 'http://192.168.3.114:8080/stream/' + sink_ip + '/'; audiotag.play(); audiotag.style.display = "inline"; audio_playing = true; + } function stop_audio() { audiotag = document.getElementById("audio") audiotag.pause(); audiotag.style.display = "none"; + document.getElementById("mainWrapper").style.display = "none"; audio_playing = false; } @@ -223,22 +248,19 @@ function option_onclick(e) { var node = e.target; console.log(node); console.log(node.dataset["volume"]); - if (node.dataset["type"] == "SourceDescription") - { + if (node.dataset["type"] == "SourceDescription") { selected_source = node; var volume_element = document.getElementById("source_volume"); volume_element.value = parseFloat(selected_source.dataset["volume"]) * 100 volume_element.disabled = false; - } - else if (node.dataset["type"] == "SinkDescription") - { + } + else if (node.dataset["type"] == "SinkDescription") { selected_sink = node; var volume_element = document.getElementById("sink_volume"); volume_element.value = parseFloat(selected_sink.dataset["volume"]) * 100 volume_element.disabled = false; } - else if (node.dataset["type"] == "RouteDescription") - { + else if (node.dataset["type"] == "RouteDescription") { selected_route = node; var volume_element = document.getElementById("route_volume"); volume_element.value = parseFloat(selected_route.dataset["volume"]) * 100 @@ -254,8 +276,8 @@ function enable_disable_source_button() { var enable_disable = "/enable"; var classname = selected_sources[0]; if (classname.indexOf("enabled") >= 0) - var enable_disable = "/disable"; - call_api("/sources/" + selected_sources[0].dataset["name"] + enable_disable, "get", 0, restart_callback) + var enable_disable = "/disable"; + call_api("/sources/" + selected_sources[0].dataset["name"] + enable_disable, "get", 0, restart_callback) } function equalizer_icon_onclick(e) { @@ -282,31 +304,28 @@ function volume_icon_onclick(e) { var parent_node = e.target.parentNode; var endpoint = ""; var enabledisable = ""; - if (parent_node.dataset["type"] == "SourceDescription") - { + if (parent_node.dataset["type"] == "SourceDescription") { endpoint = "/sources/"; if (parent_node.dataset["enabled"] == "True") enabledisable = "/disable"; else enabledisable = "/enable"; } - else if (parent_node.dataset["type"] == "SinkDescription") - { + else if (parent_node.dataset["type"] == "SinkDescription") { endpoint = "/sinks/"; if (parent_node.dataset["enabled"] == "True") enabledisable = "/disable"; else enabledisable = "/enable"; } - else if (parent_node.dataset["type"] == "RouteDescription") - { + else if (parent_node.dataset["type"] == "RouteDescription") { endpoint = "/routes/"; if (parent_node.dataset["enabled"] == "True") enabledisable = "/disable"; else enabledisable = "/enable"; } - + call_api(endpoint + parent_node.dataset["name"] + enabledisable, "get", 0, restart_callback) } @@ -314,22 +333,19 @@ function remove_icon_onclick(e) { var parent_node = e.target.parentNode; var endpoint = ""; var type = ""; - if (parent_node.dataset["type"] == "SourceDescription") - { + if (parent_node.dataset["type"] == "SourceDescription") { endpoint = "/sources/"; type = "source"; } - else if (parent_node.dataset["type"] == "SinkDescription") - { + else if (parent_node.dataset["type"] == "SinkDescription") { endpoint = "/sinks/"; type = "sink"; } - else if (parent_node.dataset["type"] == "RouteDescription") - { + else if (parent_node.dataset["type"] == "RouteDescription") { endpoint = "/routes/"; type = "route"; } - + var do_it = confirm("Are you sure you want to remove the " + type + " '" + parent_node.dataset["name"] + "'?"); if (do_it) { call_api(endpoint + parent_node.dataset["name"], "delete", 0, restart_callback) @@ -339,3 +355,221 @@ function remove_icon_onclick(e) { function listen_icon_onclick(e) { listen_to_sink_button(e.target.parentNode); } + +canvas_mode = 0; + +function canvas_click() { + if (canvas_mode == 0) { + var canvas_holder = document.getElementById("mainWrapper"); + var canvas = document.getElementById("canvas"); + canvas_holder.style.width = "100%"; + canvas_holder.style.height = "100%"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.top = 0; + canvas.style.left = 0; + canvas_holder.style.position = "absolute"; + canvas.style.position = "absolute"; + document.getElementsByTagName("body")[0].requestFullscreen() + //canvas.width = window.innerWidth; + //canvas.height = window.innerHeight; + canvas_holder.style.zIndex = 50; + } + else if (canvas_mode == 1) { + var canvas = document.getElementById("canvas"); + var canvas_holder = document.getElementById("mainWrapper"); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.top = 0; + canvas.style.left = 0; + canvas.style.position = "absolute"; + + canvas_holder.style.zIndex = -50; + if (document.fullscreen) + document.exitFullscreen(); + } + else if (canvas_mode == 2) { + var canvas_holder = document.getElementById("mainWrapper"); + var canvas = document.getElementById("canvas"); + canvas.style.position = "relative"; + canvas_holder.style.position = "relative"; + canvas.top = 300; + canvas.style.width = "720px"; + canvas.style.height = "480px"; + canvas_holder.style.width = ""; + canvas_holder.style.height = ""; + //canvas.width = 720; + //canvas.height = 480; + canvas_holder.style.zIndex = 0; + } + + canvas_mode = canvas_mode + 1 + if (canvas_mode > 2) + canvas_mode = 0; + console.log(canvas_mode); + + +} + + +visualizer = null; +rendering = false; +audioContext = null; +sourceNode = null; +delayedAudible = null; +cycleInterval = null; +presets = {}; +presetKeys = []; +presetIndexHist = []; +presetIndex = 0; +presetCycle = true; +presetCycleLength = 15000; +presetRandom = true; +canvas = document.getElementById('canvas'); + +function connectToAudioAnalyzer(sourceNode) { + if (delayedAudible) { + delayedAudible.disconnect(); + } + + delayedAudible = audioContext.createDelay(); + delayedAudible.delayTime.value = 0.26; + + sourceNode.connect(delayedAudible) + //delayedAudible.connect(audioContext.destination); + + visualizer.connectAudio(delayedAudible); +} + +function startRenderer() { + requestAnimationFrame(() => startRenderer()); + visualizer.render(); +} + +function nextPreset(blendTime = 5.7) { + presetIndexHist.push(presetIndex); + + var numPresets = presetKeys.length; + if (presetRandom) { + presetIndex = Math.floor(Math.random() * presetKeys.length); + } else { + presetIndex = (presetIndex + 1) % numPresets; + } + + visualizer.loadPreset(presets[presetKeys[presetIndex]], blendTime); + document.getElementById('presetSelect').children[presetIndex].value; +} + +function prevPreset(blendTime = 5.7) { + var numPresets = presetKeys.length; + if (presetIndexHist.length > 0) { + presetIndex = presetIndexHist.pop(); + } else { + presetIndex = ((presetIndex - 1) + numPresets) % numPresets; + } + + visualizer.loadPreset(presets[presetKeys[presetIndex]], blendTime); + document.getElementById('presetSelect').children[presetIndex].value; +} + +function restartCycleInterval() { + if (cycleInterval) { + clearInterval(cycleInterval); + cycleInterval = null; + } + + if (presetCycle) { + cycleInterval = setInterval(() => nextPreset(2.7), presetCycleLength); + } +} + +function canvas_onkeydown(e) { + if (e.keyCode === 32 || e.keyCode === 39) { + nextPreset(); + } else if (e.keyCode === 8 || e.keyCode === 37) { + prevPreset(); + } else if (e.keyCode === 72) { + nextPreset(0); + } else if (e.keyCode === 70) { + canvas_click(); + } +} + + +function preSelect_change(e) { + presetIndexHist.push(presetIndex); + presetIndex = parseInt(e.target.value); + visualizer.loadPreset(presets[presetKeys[presetIndex]], 5.7); +} + +function presetCycle_change(e) { + presetCycle = e.target.checked; + restartCycleInterval(); +} +function presetCycleLength_change(e) { + presetCycleLength = parseInt(e.target.value * 1000); + restartCycleInterval(); +} + +function presetRandom_change(e) { + presetRandom = e.target.checked; +} + +function initPlayer() { + audioContext = new AudioContext(); + + presets = {}; + if (window.butterchurnPresets) { + Object.assign(presets, butterchurnPresets.getPresets()); + } + if (window.butterchurnPresetsExtra) { + Object.assign(presets, butterchurnPresetsExtra.getPresets()); + } + presets = _(presets).toPairs().sortBy(([k, v]) => k.toLowerCase()).fromPairs().value(); + presetKeys = _.keys(presets); + presetIndex = Math.floor(Math.random() * presetKeys.length); + + presetSelect = document.getElementById('presetSelect'); + for (var i = 0; i < presetKeys.length; i++) { + var opt = document.createElement('option'); + opt.innerHTML = presetKeys[i].substring(0, 60) + (presetKeys[i].length > 60 ? '...' : ''); + opt.value = i; + presetSelect.appendChild(opt); + } + canvas = document.getElementById('canvas'); + visualizer = butterchurn.default.createVisualizer(audioContext, canvas, { + width: 3840, + height: 2160, + pixelRatio: window.devicePixelRatio || 1, + textureRatio: 1, + }); + nextPreset(0); + cycleInterval = setInterval(() => nextPreset(2.7), presetCycleLength); +} + +function start_visualizer(sink_ip) { + visualtag = document.getElementById("audio_visualizer") + visualtag.pause(); + visualtag.src = ""; + visualtag.src = 'http://192.168.3.114:8080/stream/' + sink_ip + '/'; + visualtag.play(); + visual_playing = true; + if (!visual_already_connected) { + initPlayer(); + var source = audioContext.createMediaElementSource(visualtag); + visual_already_connected = true; + source.disconnect(audioContext); + startRenderer() + connectToAudioAnalyzer(source); + } + document.getElementById("mainWrapper").style.display = "inherit"; +} + +function stop_visualizer() { + visualtag = document.getElementById("audio_visualizer") + visualtag.pause(); + + document.getElementById("mainWrapper").style.display = "none"; + var source = audioContext.createMediaElementSource(visualtag); + visual_playing = false; +} diff --git a/src/api/api_website.py b/src/api/api_website.py index 56f174a..94f4f39 100644 --- a/src/api/api_website.py +++ b/src/api/api_website.py @@ -30,6 +30,7 @@ def __init__(self, main_api: FastAPI, screamrouter_configuration: ConfigurationM self.screamrouter_configuration:ConfigurationManager = screamrouter_configuration """ScreamRouter Configuration Manager""" self.main_api.get("/", tags=["Site"])(self.site_index) + self.main_api.get("/body", tags=["Site"])(self.site_index_body) self.main_api.get(f"{SITE_PREFIX}/screamrouter.js", tags=["Site Resources"],)(self.site_javascript) self.main_api.get(f"{SITE_PREFIX}/screamrouter.css", @@ -80,6 +81,11 @@ def site_index(self, request: Request): """Index page""" return self.return_template(request, "index.html.jinja") + def site_index_body(self, request: Request): + """Index page""" + return self.return_template(request, "index_body.html.jinja") + + def site_javascript(self, request: Request): """Javascript page""" return self.return_template(request, "screamrouter.js.jinja",