diff --git a/console.js b/console.js index 6fe3fc9..e25bc5b 100644 --- a/console.js +++ b/console.js @@ -18,7 +18,7 @@ const MESSAGE_PREFIX = { // Initialize an array to store logs const logs = []; -const maxLogLines = 100; +const maxLogLines = 250; const logDebug = (...messages) => { if (verboseMode) { diff --git a/datahandler.js b/datahandler.js index 1a23561..d989dcb 100644 --- a/datahandler.js +++ b/datahandler.js @@ -210,7 +210,7 @@ var dataToSend = { st: false, st_forced: false, ps: '', - tp: false, + tp: 0, ta: 0, ms: -1, pty: 0, @@ -231,7 +231,7 @@ var dataToSend = { }, country_name: '', country_iso: 'UN', - users: '', + users: 0, }; var legacyRdsPiBuffer = null; diff --git a/fmdx_list.js b/fmdx_list.js index c3f8409..e73d467 100644 --- a/fmdx_list.js +++ b/fmdx_list.js @@ -31,7 +31,7 @@ function send(request) { } else { - logInfo("FM-DX Server Map update successful."); + logDebug("FM-DX Server Map update successful."); } } else @@ -66,6 +66,7 @@ function sendUpdate() { desc: serverConfig.identification.tunerDesc, audioChannels: serverConfig.audio.audioChannels, audioQuality: serverConfig.audio.audioBitrate, + contact: serverConfig.identification.contact || '' }; if (serverConfig.identification.token) diff --git a/index.js b/index.js index e2bcf3f..579ce9e 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ const httpProxy = require('http-proxy'); const https = require('https'); const app = express(); const httpServer = http.createServer(app); +const process = require("process"); // Websocket handling const WebSocket = require('ws'); @@ -33,7 +34,7 @@ const { logDebug, logError, logInfo, logWarn } = consoleCmd; // Create a WebSocket proxy instance const proxy = httpProxy.createProxyServer({ - target: 'ws://localhost:'+ serverConfig.webserver.audioPort, // WebSocket httpServer's address + target: 'ws://localhost:' + (Number(serverConfig.webserver.webserverPort) + 10), // WebSocket httpServer's address ws: true, // Enable WebSocket proxying changeOrigin: true // Change the origin of the host header to the target URL }); @@ -98,7 +99,6 @@ function connectToXdrd() { const lines = receivedData.split('\n'); for (const line of lines) { - if (!authFlags.receivedPassword) { authFlags.receivedSalt = line.trim(); authenticateWithXdrd(client, authFlags.receivedSalt, serverConfig.xdrd.xdrdPassword); @@ -225,7 +225,6 @@ app.get('/static_data', (req, res) => { res.json({ qthLatitude: serverConfig.identification.lat, qthLongitude: serverConfig.identification.lon, - audioPort: serverConfig.webserver.audioPort, streamEnabled: streamEnabled }); }); @@ -286,8 +285,7 @@ function parseMarkdown(parsed) { var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g; parsed = parsed.replace(linkRegex, '$1'); - var breakLineRegex = /\\n/g; - parsed = parsed.replace(breakLineRegex, '
'); + parsed = parsed.replace(/\n/g, '
'); return parsed; } @@ -307,21 +305,17 @@ function removeMarkdown(parsed) { var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g; parsed = parsed.replace(linkRegex, '$1'); - var breakLineRegex = /\\n/g; - parsed = parsed.replace(breakLineRegex, ''); - return parsed; } app.get('/', (req, res) => { if (!fs.existsSync(configName + '.json')) { parseAudioDevice((result) => { - res.render('setup', { + res.render('wizard', { isAdminAuthenticated: true, videoDevices: result.audioDevices, - audioDevices: result.videoDevices, - consoleOutput: consoleCmd.logs }); - });; + audioDevices: result.videoDevices }); + }); } else { res.render('index', { isAdminAuthenticated: req.session.isAdminAuthenticated, @@ -330,21 +324,65 @@ app.get('/', (req, res) => { tunerDesc: parseMarkdown(serverConfig.identification.tunerDesc), tunerDescMeta: removeMarkdown(serverConfig.identification.tunerDesc), tunerLock: serverConfig.lockToAdmin, - publicTuner: serverConfig.publicTuner + publicTuner: serverConfig.publicTuner, + antennaSwitch: serverConfig.antennaSwitch }) } }); +app.get('/wizard', (req, res) => { + parseAudioDevice((result) => { + res.render('wizard', { + isAdminAuthenticated: req.session.isAdminAuthenticated, + videoDevices: result.audioDevices, + audioDevices: result.videoDevices }); + }); +}) + app.get('/setup', (req, res) => { parseAudioDevice((result) => { - res.render('setup', { - isAdminAuthenticated: req.session.isAdminAuthenticated, - videoDevices: result.audioDevices, - audioDevices: result.videoDevices, - consoleOutput: consoleCmd.logs }); + const processUptimeInSeconds = Math.floor(process.uptime()); + const formattedProcessUptime = formatUptime(processUptimeInSeconds); + + res.render('setup', { + isAdminAuthenticated: req.session.isAdminAuthenticated, + videoDevices: result.audioDevices, + audioDevices: result.videoDevices, + memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB', + processUptime: formattedProcessUptime, + consoleOutput: consoleCmd.logs, + onlineUsers: dataHandler.dataToSend.users + }); }); }); +app.get('/api', (req, res) => { + let data = dataHandler.dataToSend; + delete data.ps_errors; + delete data.rt0_errors; + delete data.rt1_errors; + delete data.ims; + delete data.eq; + delete data.ant; + delete data.st_forced; + delete data.previousFreq; + delete data.txInfo; + res.json(data) +}); + +function formatUptime(uptimeInSeconds) { + const secondsInMinute = 60; + const secondsInHour = secondsInMinute * 60; + const secondsInDay = secondsInHour * 24; + + const days = Math.floor(uptimeInSeconds / secondsInDay); + const hours = Math.floor((uptimeInSeconds % secondsInDay) / secondsInHour); + const minutes = Math.floor((uptimeInSeconds % secondsInHour) / secondsInMinute); + + return `${days}d ${hours}h ${minutes}m`; +} + + // Route for login app.post('/login', authenticate, (req, res) => { diff --git a/server_config.js b/server_config.js index b80dac7..bc78bdf 100644 --- a/server_config.js +++ b/server_config.js @@ -13,12 +13,11 @@ if (index !== -1 && index + 1 < process.argv.length) { let serverConfig = { webserver: { webserverIp: "0.0.0.0", - webserverPort: "8080", - audioPort: "8081" + webserverPort: 8080 }, xdrd: { xdrdIp: "127.0.0.1", - xdrdPort: "7373", + xdrdPort: 7373, xdrdPassword: "" }, audio: { diff --git a/stream/index.js b/stream/index.js index b76521e..1ad10fe 100644 --- a/stream/index.js +++ b/stream/index.js @@ -5,6 +5,7 @@ const { configName, serverConfig, configUpdate, configSave } = require('../serve function enableAudioStream() { var ffmpegCommand; + serverConfig.webserver.webserverPort = Number(serverConfig.webserver.webserverPort); // Specify the command and its arguments const command = 'ffmpeg'; const flags = `-fflags +nobuffer+flush_packets -flags low_delay -rtbufsize 6192 -probesize 32`; @@ -13,14 +14,14 @@ function enableAudioStream() { // Combine all the settings for the ffmpeg command if (process.platform === 'win32') { // Windows - ffmpegCommand = `${flags} -f dshow -i audio="${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.audioPort} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; + ffmpegCommand = `${flags} -f dshow -i audio="${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; } else { // Linux - ffmpegCommand = `${flags} -f alsa -i "${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.audioPort} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; + ffmpegCommand = `${flags} -f alsa -i "${serverConfig.audio.audioDevice}" ${codec} ${output} pipe:1 | node stream/3las.server.js -port ${serverConfig.webserver.webserverPort + 10} -samplerate 48000 -channels ${serverConfig.audio.audioChannels}`; } consoleCmd.logInfo("Using audio device: " + serverConfig.audio.audioDevice); - consoleCmd.logInfo("Launching audio stream on port " + serverConfig.webserver.audioPort + "."); + consoleCmd.logInfo(`Launching audio stream on internal port ${serverConfig.webserver.webserverPort + 10}.`); // Spawn the child process if(serverConfig.audio.audioDevice.length > 2) { diff --git a/tx_search.js b/tx_search.js index df8ea02..b3c0d25 100644 --- a/tx_search.js +++ b/tx_search.js @@ -48,7 +48,7 @@ function processData(data, piCode, rdsPs) { const city = data.locations[cityId]; if (city.stations) { for (const station of city.stations) { - if (station.pi === piCode && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) { + if (station.pi === piCode.toUpperCase() && !station.extra && station.ps && station.ps.toLowerCase().includes(rdsPs.replace(/ /g, '_').replace(/^_*(.*?)_*$/, '$1').toLowerCase())) { const distance = haversine(serverConfig.identification.lat, serverConfig.identification.lon, city.lat, city.lon); const score = (10*Math.log10(station.erp*1000)) / distance.distanceKm; // Calculate score if (score > maxScore) { diff --git a/web/css/breadcrumbs.css b/web/css/breadcrumbs.css index e7f0ab7..ed9d0c0 100644 --- a/web/css/breadcrumbs.css +++ b/web/css/breadcrumbs.css @@ -1,13 +1,22 @@ h1 { color: var(--color-4); - font-size: 52px; - font-weight: 300; + font-size: 32px; + font-weight: 700; + text-transform: uppercase; margin-top: 0; margin-bottom: 0; } +.modal-panel-content h1 { + text-transform: initial; + font-weight: 300; + font-size: 42px; +} + h1#tuner-name { font-size: 32px; + font-weight: 300; + text-transform: initial; } h2 { @@ -18,6 +27,7 @@ h2 { h3 { font-size: 22px; + color: var(--color-4); } h4 { @@ -139,6 +149,9 @@ label { .checkbox label { position: relative; cursor: pointer; + display: flex; + align-items: center; + user-select: none; } .checkbox label:before { @@ -155,14 +168,20 @@ label { margin-right: 5px; } + .form-group input:checked + label:before { + background-color: var(--color-4); + } + .form-group input:checked + label:after { content: '✓'; display: block; position: absolute; - top: 2px; - left: 6px; - width: 16px; - height: 16px; + font-size: 18px; + top: -1px; + left: 5px; + width: 18px; + height: 18px; + color: var(--color-main); } .tuner-info { @@ -170,6 +189,12 @@ label { margin-bottom: 0px !important; } + .settings-heading { + font-size: 32px; + padding-top: 20px; + text-transform: uppercase; + } + @media (max-width: 768px) { canvas, #flags-container { display: none; diff --git a/web/css/buttons.css b/web/css/buttons.css index ea34e92..fe37328 100644 --- a/web/css/buttons.css +++ b/web/css/buttons.css @@ -18,6 +18,57 @@ button:hover { opacity: 0.6; } +.btn-next { + width: 200px; + padding: 10px; + font-weight: bold; + color: var(--color-main); + margin: 30px 5px; + text-transform: uppercase; +} + +.btn-prev { + width: 48px; + padding: 10px; + color: var(--color-main); + background-color: var(--color-3); + margin: 30px 5px; +} + +.btn-rounded-cube { + width: 64px; + height: 64px; + background: var(--color-2); + color: var(--color-main); + border-radius: 30px; + margin-right: 10px; + margin-left: 10px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + font-size: 24px; + font-weight: 300; + cursor: default; +} + +.btn-rounded-cube:not(:first-child)::before { + content: ""; + width: 20px; + height: 2px; + background: var(--color-2); + position: absolute; + right: 64px; +} + +.btn-rounded-cube.activated { + background-color: var(--color-4); +} + +.btn-rounded-cube.activated::before { + background-color: var(--color-4); +} + input[type="text"], textarea, input[type="password"] { width: 300px; min-height: 46px; @@ -267,4 +318,4 @@ select option { select:hover { background: var(--color-5); -} +} \ No newline at end of file diff --git a/web/css/helpers.css b/web/css/helpers.css index fe47ce9..d4bf2ff 100644 --- a/web/css/helpers.css +++ b/web/css/helpers.css @@ -14,6 +14,22 @@ background: transparent !important; } +.bg-color-1 { + background-color: var(--color-1); +} + +.bg-color-2 { + background-color: var(--color-2); +} + +.bg-color-3 { + background-color: var(--color-3); +} + +.bg-color-4 { + background-color: var(--color-4); +} + .color-1 { color: var(--color-1); } @@ -30,6 +46,11 @@ color: var(--color-4); } +.color-5 { + color: var(--color-5); +} + + .br-0 { border-radius: 0px; } @@ -126,7 +147,7 @@ } .text-bold { - font-weight: bold; + font-weight: bold !important; } .text-monospace { @@ -134,7 +155,7 @@ } .text-gray { - color: #666; + opacity: 0.7; } .text-red { @@ -177,6 +198,10 @@ cursor: pointer; } +.hidden { + display: none; +} + @media only screen and (max-width: 960px) { .text-medium-big { font-size: 32px; diff --git a/web/css/setup.css b/web/css/setup.css index 95d4d0d..e6de6cd 100644 --- a/web/css/setup.css +++ b/web/css/setup.css @@ -9,6 +9,13 @@ margin-left: 5px; } +.setup-wrapper h2 { + font-size: 32px; + font-weight: 300; + padding: 10px; + text-transform: uppercase; +} + .setup-wrapper textarea { width: 100%; @@ -19,6 +26,34 @@ padding-top: 10px; } +ul.nav { + list-style-type: none; + padding: 15px 0; + background: var(--color-2); + border-radius: 30px; +} + +ul.nav li { + display: inline; + padding: 15px; + cursor: pointer; + transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; + user-select: none; +} + +ul.nav li:hover { + color: var(--color-main); + background-color: var(--color-4); +} + +li.active { + background-color: var(--color-3); +} + +.tab-content { + display: none; +} + #map { height:400px; width:100%; @@ -33,10 +68,37 @@ margin: 8px; } + +#console-output { + background-color: #111; + height: 300px; + overflow-y:auto; +} +.w-200 { + width: 200px !important +} + .w-150 { width: 150px !important } .w-100 { width: 100px !important; +} + +@media only screen and (max-width: 768px) { + ul.nav { + display: flex; + overflow-y: scroll; + background: transparent; + } + + ul.nav li { + background-color: var(--color-4); + color: var(--color-main); + margin: 0px 10px; + padding: 15px 35px; + border-radius: 30px; + min-width: fit-content; + } } \ No newline at end of file diff --git a/web/images/openradio_logo_neutral.png b/web/images/openradio_logo_neutral.png new file mode 100644 index 0000000..3333b5e Binary files /dev/null and b/web/images/openradio_logo_neutral.png differ diff --git a/web/index.ejs b/web/index.ejs index b1674cd..1a38164 100644 --- a/web/index.ejs +++ b/web/index.ejs @@ -82,7 +82,7 @@
- ST + ST MS @@ -115,6 +115,8 @@
+ + <% if (antennaSwitch) { %> + <% } %> +
@@ -211,7 +215,7 @@ @@ -244,6 +248,10 @@ +
+ + +
@@ -273,7 +281,8 @@

FM-DX WebServer
by Noobish, kkonradpl & the OpenRadio community.

- v1.0.9 [23/2/2024] +
+ [Receiver Map]
diff --git a/web/js/3las/3las.js b/web/js/3las/3las.js index 53686a2..022a5d4 100644 --- a/web/js/3las/3las.js +++ b/web/js/3las/3las.js @@ -1,7 +1,6 @@ var _3LAS_Settings = /** @class */ (function () { function _3LAS_Settings() { this.SocketHost = document.location.hostname ? document.location.hostname : "127.0.0.1"; - this.SocketPort = localStorage.getItem('audioPort') ? localStorage.getItem('audioPort') : 8081; this.SocketPath = "/"; this.WebRTC = new WebRTC_Settings(); this.Fallback = new Fallback_Settings(); diff --git a/web/js/confighandler.js b/web/js/confighandler.js new file mode 100644 index 0000000..5a60e02 --- /dev/null +++ b/web/js/confighandler.js @@ -0,0 +1,145 @@ +function submitData() { + const webserverIp = $('#webserver-ip').val() || '0.0.0.0'; + const webserverPort = $('#webserver-port').val() || '8080'; + + const xdrdIp = $('#xdrd-ip').val() || '127.0.0.1'; + const xdrdPort = $('#xdrd-port').val() || '7373'; + const xdrdPassword = $('#xdrd-password').val() || 'password'; + + const audioDevice = $('#audio-devices').val() || 'Microphone (High Definition Audio Device)'; + const audioChannels = ($('.options .option').filter(function() { + return $(this).text() === $('#audio-channels').val(); + }).data('value') || 2); + const audioBitrate = ($('.options .option').filter(function() { + return $(this).text() === $('#audio-quality').val(); + }).data('value') || "192k"); + + const tunerName = $('#webserver-name').val() || 'FM Tuner'; + const tunerDesc = $('#webserver-desc').val() || 'Default FM tuner description'; + const broadcastTuner = $("#broadcast-tuner").is(":checked"); + const contact = $("#owner-contact").val() || ''; + const lat = $('#lat').val(); + const lon = $('#lng').val(); + const proxyIp = $("#broadcast-address").val(); + + const tunePass = $('#tune-pass').val(); + const adminPass = $('#admin-pass').val(); + + const publicTuner = $("#tuner-public").is(":checked"); + const lockToAdmin = $("#tuner-lock").is(":checked"); + const autoShutdown = $("#shutdown-tuner").is(":checked") || false; + const antennaSwitch = $("#antenna-switch").is(":checked") || false; + + const data = { + webserver: { + webserverIp, + webserverPort, + }, + xdrd: { + xdrdIp, + xdrdPort, + xdrdPassword + }, + audio: { + audioDevice, + audioChannels, + audioBitrate, + }, + identification: { + tunerName, + tunerDesc, + broadcastTuner, + contact, + lat, + lon, + proxyIp + }, + password: { + tunePass, + adminPass, + }, + publicTuner, + lockToAdmin, + autoShutdown, + antennaSwitch, + }; + + if(adminPass.length < 1) { + alert('You need to fill in the admin password before continuing further.'); + return; + } + // Send data to the server using jQuery + $.ajax({ + url: './saveData', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(data), + success: function (message) { + alert(message); + }, + error: function (error) { + console.error(error); + } + }); + } + + + function fetchData() { + // Make a GET request to retrieve the data.json file + fetch("./getData") + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + $('#webserver-ip').val(data.webserver.webserverIp); + $('#webserver-port').val(data.webserver.webserverPort); + + $('#xdrd-ip').val(data.xdrd.xdrdIp); + $('#xdrd-port').val(data.xdrd.xdrdPort); + $('#xdrd-password').val(data.xdrd.xdrdPassword); + + $('#audio-devices').val(data.audio.audioDevice); + $('#audio-channels').val(data.audio.audioChannels); + $('#audio-quality').val(data.audio.audioBitrate); + + $('#webserver-name').val(data.identification.tunerName); + $('#webserver-desc').val(data.identification.tunerDesc); + $("#broadcast-tuner").prop("checked", data.identification.broadcastTuner); + $("#broadcast-address").val(data.identification.proxyIp); + $("#owner-contact").val(data.identification.contact); + $('#lat').val(data.identification.lat); + $('#lng').val(data.identification.lon); + + $('#tune-pass').val(data.password.tunePass); + $('#admin-pass').val(data.password.adminPass); + + $("#tuner-public").prop("checked", data.publicTuner); + $("#tuner-lock").prop("checked", data.lockToAdmin); + $("#shutdown-tuner").prop("checked", data.autoShutdown); + $("#antenna-switch").prop("checked", data.antennaSwitch); + + // Check if latitude and longitude are present in the data + if (data.identification.lat && data.identification.lon) { + // Set the map's center to the received coordinates + map.setView([data.identification.lat, data.identification.lon], 13); + + // Add a pin to the map + if (typeof pin == "object") { + pin.setLatLng([data.identification.lat, data.identification.lon]); + } else { + pin = L.marker([data.identification.lat, data.identification.lon], { riseOnHover:true, draggable:true }); + pin.addTo(map); + pin.on('drag',function(ev) { + $('#lat').val((ev.latlng.lat).toFixed(6)); + $('#lng').val((ev.latlng.lng).toFixed(6)); + }); + } + } + }) + .catch(error => { + console.error('Error fetching data:', error.message); + }); +} diff --git a/web/js/main.js b/web/js/main.js index 8d2dc4f..db2459b 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -3,6 +3,7 @@ url.protocol = url.protocol.replace('http', 'ws'); var socketAddress = url.href; var socket = new WebSocket(socketAddress); var parsedData, signalChart, previousFreq; +var signalData = []; var data = []; let updateCounter = 0; @@ -118,6 +119,7 @@ $(document).ready(function () { var piCodeContainer = $('#pi-code-container')[0]; var freqContainer = $('#freq-container')[0]; var txContainer = $('#data-station-container')[0]; + var stereoContainer = $('#stereo-container')[0]; $("#data-eq").click(function () { toggleButtonState("eq"); @@ -133,6 +135,7 @@ $(document).ready(function () { $(rtContainer).on("click", copyRt); $(txContainer).on("click", copyTx); $(piCodeContainer).on("click", findOnMaps); + $(stereoContainer).on("click", toggleForcedStereo); $(freqContainer).on("click", function () { textInput.focus(); }); @@ -514,24 +517,31 @@ function findOnMaps() { window.open(url, "_blank"); } -function updateSignalUnits(parsedData) { +function updateSignalUnits(parsedData, averageSignal) { const signalUnit = localStorage.getItem('signalUnit'); + let currentSignal; + + if(localStorage.getItem("smoothSignal") == 'true') { + currentSignal = averageSignal + } else { + currentSignal = parsedData.signal; + } let signalText = $('#signal-units'); let signalValue; switch (signalUnit) { case 'dbuv': - signalValue = parsedData.signal - 11.25; + signalValue = currentSignal - 11.25; signalText.text('dBµV'); break; case 'dbm': - signalValue = parsedData.signal - 120; + signalValue = currentSignal - 120; signalText.text('dBm'); break; default: - signalValue = parsedData.signal; + signalValue = currentSignal; signalText.text('dBf'); break; } @@ -551,15 +561,6 @@ function updateDataElements(parsedData) { parsedData.ps = parsedData.ps.replace(/\s/g, '_'); } $('#data-ps').html(parsedData.ps === '?' ? "?" : processString(parsedData.ps, parsedData.ps_errors)); - $('.data-tp').html(parsedData.tp === 0 ? "TP" : "TP"); - $('.data-ta').html(parsedData.ta === 0 ? "TA" : "TA"); - $('.data-ms').html(parsedData.ms === 0 - ? "MS" - : (parsedData.ms === -1 - ? "MS" - : "MS" - ) - ); $('.data-pty').html(europe_programmes[parsedData.pty]); @@ -580,6 +581,7 @@ function updateDataElements(parsedData) { $('#data-rt0').html(processString(parsedData.rt0, parsedData.rt0_errors)); $('#data-rt1').html(processString(parsedData.rt1, parsedData.rt1_errors)); $('.data-flag').html(``); + $('.data-flag-big').html(``); $('#data-ant input').val($('#data-ant li[data-value="' + parsedData.ant + '"]').text()); if (parsedData.txInfo.station.length > 1) { @@ -596,6 +598,18 @@ function updateDataElements(parsedData) { } updateCounter++; + if(updateCounter % 8 === 0) { + $('.data-tp').html(parsedData.tp === 0 ? "TP" : "TP"); + $('.data-ta').html(parsedData.ta === 0 ? "TA" : "TA"); + $('.data-ms').html(parsedData.ms === 0 + ? "MS" + : (parsedData.ms === -1 + ? "MS" + : "MS" + ) + ); + } + if (updateCounter % 30 === 0) { $('#data-ps').attr('aria-label', parsedData.ps); $('#data-rt0').attr('aria-label', parsedData.rt0); @@ -608,6 +622,13 @@ let isEventListenerAdded = false; function updatePanels(parsedData) { updateCounter++; + signalData.push(parsedData.signal); + if (signalData.length > 8) { + signalData.shift(); // Remove the oldest element + } + const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0); + const averageSignal = sum / signalData.length; + const sortedAf = parsedData.af.sort(compareNumbers); const scaledArray = sortedAf.map(element => element / 1000); @@ -642,9 +663,8 @@ function updatePanels(parsedData) { listContainer.scrollTop(scrollTop); } - // Update other elements every time updateDataElements(parsedData); - updateSignalUnits(parsedData); + updateSignalUnits(parsedData, averageSignal); $('.users-online').text(parsedData.users); } @@ -669,3 +689,9 @@ function toggleButtonState(buttonId) { message += parsedData.ims ? "1" : "0"; socket.send(message); } + +function toggleForcedStereo() { + var message = "B"; + message += parsedData.st_forced = (parsedData.st_forced == "1") ? "0" : "1"; + socket.send(message); +} \ No newline at end of file diff --git a/web/js/settings.js b/web/js/settings.js index 8bec139..42eb8b4 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -1,11 +1,13 @@ + var currentVersion = 'v1.1.0 [29.2.2024]'; + /** - * Themes - * @param first color - * @param second color - * @param text color - */ + * Themes + * @param first color + * @param second color + * @param text color + */ const themes = { - theme1: [ 'rgba(0, 0, 0, 1)', 'rgba(204, 204, 204, 1)', 'rgba(255, 255, 255, 1)' ], // Monochrome (Default) + theme1: ['rgba(32, 34, 40, 1)', 'rgba(88, 219, 171, 1)', 'rgba(255, 255, 255, 1)' ], // Retro (Default) theme2: [ 'rgba(31, 12, 12, 1)', 'rgba(255, 112, 112, 1)', 'rgba(255, 255, 255, 1)' ], // Red theme3: [ 'rgba(18, 28, 12, 1)', 'rgba(169, 255, 112, 1)', 'rgba(255, 255, 255, 1)' ], // Green theme4: [ 'rgba(12, 28, 27, 1)', 'rgba(104, 247, 238, 1)', 'rgba(255, 255, 255, 1)' ], // Cyan @@ -13,143 +15,154 @@ theme6: [ 'rgba(33, 9, 29, 1)', 'rgba(237, 81, 211, 1)', 'rgba(255, 255, 255, 1)' ], // Pink theme7: [ 'rgba(13, 11, 26, 1)', 'rgba(128, 105, 250, 1)', 'rgba(255, 255, 255, 1)' ], // Blurple theme8: [ 'rgba(252, 186, 3, 1)', 'rgba(0, 0, 0, 1)', 'rgba(0, 0, 0, 1)' ], // Sunny - theme9: ['rgba(32, 34, 40, 1)', 'rgba(88, 219, 171, 1)', 'rgba(255, 255, 255, 1)' ] // Retro - }; - - // Signal Units - const signalUnits = { - dbf: ['dBf'], - dbuv: ['dBµV'], - dbm: ['dBm'], - }; - - $(document).ready(() => { - // Theme Selector - const themeSelector = $('#theme-selector'); - const savedTheme = localStorage.getItem('theme'); - const savedUnit = localStorage.getItem('signalUnit'); - - if (savedTheme && themes[savedTheme]) { - setTheme(savedTheme); - themeSelector.find('input').val(themeSelector.find('.option[data-value="' + savedTheme + '"]').text()); - } - - themeSelector.on('click', '.option', (event) => { - const selectedTheme = $(event.target).data('value'); - setTheme(selectedTheme); - themeSelector.find('input').val($(event.target).text()); // Set the text of the clicked option to the input - localStorage.setItem('theme', selectedTheme); - }); - - // Signal Selector - const signalSelector = $('#signal-selector'); - - if (localStorage.getItem('signalUnit')) { - signalSelector.find('input').val(signalSelector.find('.option[data-value="' + savedUnit + '"]').text()); - } - - signalSelector.on('click', '.option', (event) => { - const selectedSignalUnit = $(event.target).data('value'); - signalSelector.find('input').val($(event.target).text()); // Set the text of the clicked option to the input - localStorage.setItem('signalUnit', selectedSignalUnit); - }); - - $('#login-form').submit(function (event) { - event.preventDefault(); + theme9: [ 'rgba(0, 0, 0, 1)', 'rgba(204, 204, 204, 1)', 'rgba(255, 255, 255, 1)' ], // AMOLED + }; - // Perform an AJAX request to the /login endpoint - $.ajax({ - type: 'POST', - url: './login', - data: $(this).serialize(), - success: function (data) { - // Update the content on the page with the message from the response - $('#login-message').text(data.message); - setTimeout(function () { - location.reload(true); - }, 1750); - }, - error: function (xhr, status, error) { - // Handle error response - if (xhr.status === 403) { - // Update the content on the page with the message from the error response - $('#login-message').text(xhr.responseJSON.message); - } else { - // Handle other types of errors if needed - console.error('Error:', status, error); - } - } - }); - }); - - // Assuming you have an anchor tag with id 'logout-link' - $('.logout-link').click(function (event) { - event.preventDefault(); + // Signal Units + const signalUnits = { + dbf: ['dBf'], + dbuv: ['dBµV'], + dbm: ['dBm'], + }; + + $(document).ready(() => { + // Theme Selector + const themeSelector = $('#theme-selector'); + const savedTheme = localStorage.getItem('theme'); + const savedUnit = localStorage.getItem('signalUnit'); - // Perform an AJAX request to the /logout endpoint - $.ajax({ - type: 'GET', // Assuming the logout is a GET request, adjust accordingly - url: './logout', - success: function (data) { - // Update the content on the page with the message from the response - $('#login-message').text(data.message); - setTimeout(function () { - location.reload(true); - }, 1750); - }, - error: function (xhr, status, error) { - // Handle error response - if (xhr.status === 403) { - // Update the content on the page with the message from the error response - $('#login-message').text(xhr.responseJSON.message); - } else { - // Handle other types of errors if needed - console.error('Error:', status, error); - } - } - }); - }); - - var extendedFreqRange = localStorage.getItem("extendedFreqRange"); - if (extendedFreqRange === "true") { - $("#extended-frequency-range").prop("checked", true); - } - - // Save the value of the checkbox into local storage when its state changes - $("#extended-frequency-range").change(function() { - var isChecked = $(this).is(":checked"); - localStorage.setItem("extendedFreqRange", isChecked); - }); - - var extendedFreqRange = localStorage.getItem("psUnderscores"); - if (extendedFreqRange === "true") { - $("#ps-underscores").prop("checked", true); - } - - // Save the value of the checkbox into local storage when its state changes - $("#ps-underscores").change(function() { - var isChecked = $(this).is(":checked"); - localStorage.setItem("psUnderscores", isChecked); - }); - -}); - - -function setTheme(themeName) { - const themeColors = themes[themeName]; - if (themeColors) { - // Extracting the RGBA components and opacity value - const rgbaComponents = themeColors[2].match(/(\d+(\.\d+)?)/g); - const opacity = parseFloat(rgbaComponents[3]); - // Calculating 80% of the opacity - const newOpacity = opacity * 0.75; - // Constructing the new RGBA string with the adjusted opacity - const textColor2 = `rgba(${rgbaComponents[0]}, ${rgbaComponents[1]}, ${rgbaComponents[2]}, ${newOpacity})`; + if (savedTheme && themes[savedTheme]) { + setTheme(savedTheme); + themeSelector.find('input').val(themeSelector.find('.option[data-value="' + savedTheme + '"]').text()); + } - $(':root').css('--color-main', themeColors[0]); - $(':root').css('--color-main-bright', themeColors[1]); - $(':root').css('--color-text', themeColors[2]); - $(':root').css('--color-text-2', textColor2); - } -} + themeSelector.on('click', '.option', (event) => { + const selectedTheme = $(event.target).data('value'); + setTheme(selectedTheme); + themeSelector.find('input').val($(event.target).text()); // Set the text of the clicked option to the input + localStorage.setItem('theme', selectedTheme); + }); + + // Signal Selector + const signalSelector = $('#signal-selector'); + + if (localStorage.getItem('signalUnit')) { + signalSelector.find('input').val(signalSelector.find('.option[data-value="' + savedUnit + '"]').text()); + } + + signalSelector.on('click', '.option', (event) => { + const selectedSignalUnit = $(event.target).data('value'); + signalSelector.find('input').val($(event.target).text()); // Set the text of the clicked option to the input + localStorage.setItem('signalUnit', selectedSignalUnit); + }); + + $('#login-form').submit(function (event) { + event.preventDefault(); + + // Perform an AJAX request to the /login endpoint + $.ajax({ + type: 'POST', + url: './login', + data: $(this).serialize(), + success: function (data) { + // Update the content on the page with the message from the response + $('#login-message').text(data.message); + setTimeout(function () { + location.reload(true); + }, 1750); + }, + error: function (xhr, status, error) { + // Handle error response + if (xhr.status === 403) { + // Update the content on the page with the message from the error response + $('#login-message').text(xhr.responseJSON.message); + } else { + // Handle other types of errors if needed + console.error('Error:', status, error); + } + } + }); + }); + + // Assuming you have an anchor tag with id 'logout-link' + $('.logout-link').click(function (event) { + event.preventDefault(); + + // Perform an AJAX request to the /logout endpoint + $.ajax({ + type: 'GET', // Assuming the logout is a GET request, adjust accordingly + url: './logout', + success: function (data) { + // Update the content on the page with the message from the response + $('#login-message').text(data.message); + setTimeout(function () { + location.reload(true); + }, 1750); + }, + error: function (xhr, status, error) { + // Handle error response + if (xhr.status === 403) { + // Update the content on the page with the message from the error response + $('#login-message').text(xhr.responseJSON.message); + } else { + // Handle other types of errors if needed + console.error('Error:', status, error); + } + } + }); + }); + + var extendedFreqRange = localStorage.getItem("extendedFreqRange"); + if (extendedFreqRange === "true") { + $("#extended-frequency-range").prop("checked", true); + } + + // Save the value of the checkbox into local storage when its state changes + $("#extended-frequency-range").change(function() { + var isChecked = $(this).is(":checked"); + localStorage.setItem("extendedFreqRange", isChecked); + }); + + var extendedFreqRange = localStorage.getItem("psUnderscores"); + if (extendedFreqRange === "true") { + $("#ps-underscores").prop("checked", true); + } + var smoothSignal = localStorage.getItem("smoothSignal"); + if (smoothSignal === "true") { + $("#smooth-signal").prop("checked", true); + } + + // Save the value of the checkbox into local storage when its state changes + $("#ps-underscores").change(function() { + var isChecked = $(this).is(":checked"); + localStorage.setItem("psUnderscores", isChecked); + }); + + $("#smooth-signal").change(function() { + var isChecked = $(this).is(":checked"); + localStorage.setItem("smoothSignal", isChecked); + }); + + $('.version-string').text(currentVersion); + }); + + + function setTheme(themeName) { + const themeColors = themes[themeName]; + if (themeColors) { + // Extracting the RGBA components and opacity value + const rgbaComponents = themeColors[2].match(/(\d+(\.\d+)?)/g); + const opacity = parseFloat(rgbaComponents[3]); + // Calculating 80% of the opacity + const newOpacity = opacity * 0.75; + // Constructing the new RGBA string with the adjusted opacity + const textColor2 = `rgba(${rgbaComponents[0]}, ${rgbaComponents[1]}, ${rgbaComponents[2]}, ${newOpacity})`; + + $(':root').css('--color-main', themeColors[0]); + $(':root').css('--color-main-bright', themeColors[1]); + $(':root').css('--color-text', themeColors[2]); + $(':root').css('--color-text-2', textColor2); + } + } + diff --git a/web/js/setup.js b/web/js/setup.js index 14dece7..e6646e8 100644 --- a/web/js/setup.js +++ b/web/js/setup.js @@ -9,7 +9,12 @@ $(document).ready(function() { MapCreate(); fetchData(); - + setTimeout( function() { + if ($('.nav li.active[data-panel="status"]').length > 0) { + $('#submit-config').hide(); + } + }, 50 ) + map.on('click', function(ev) { $('#lat').val((ev.latlng.lat).toFixed(6)); $('#lng').val((ev.latlng.lng).toFixed(6)); @@ -25,7 +30,39 @@ $(document).ready(function() { }); } }); - + + $('#status').show(); + showPanelFromHash(); + $('.nav li').click(function() { + // Remove background color from all li elements + $('.nav li').removeClass('active'); + + // Add background color to the clicked li element + $(this).addClass('active'); + + // Get the data-panel attribute value + var panelId = $(this).data('panel'); + window.location.hash = panelId; + // Hide all panels + $('.tab-content').hide(); + + // Show the corresponding panel + $('#' + panelId).show(); + + if(panelId == 'identification') { + setTimeout(function () { + map.invalidateSize(); + }, 200); + } + + if(panelId == 'status') { + $('#submit-config').hide(); + } else { + $('#submit-config').show(); + } + }); + + $('#login-form').submit(function (event) { event.preventDefault(); @@ -105,7 +142,9 @@ $(document).ready(function() { }); }); - $("#console-output").scrollTop($("#console-output")[0].scrollHeight); + if($("#console-output").length > 0) { + $("#console-output").scrollTop($("#console-output")[0].scrollHeight); + } }); function MapCreate() { @@ -126,149 +165,19 @@ function MapCreate() { }).addTo(map); } -function fetchData() { - // Make a GET request to retrieve the data.json file - fetch("./getData") - .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - return response.json(); - }) - .then(data => { - $('#webserver-ip').val(data.webserver.webserverIp); - $('#webserver-port').val(data.webserver.webserverPort); - $('#audio-port').val(data.webserver.audioPort); - - $('#xdrd-ip').val(data.xdrd.xdrdIp); - $('#xdrd-port').val(data.xdrd.xdrdPort); - $('#xdrd-password').val(data.xdrd.xdrdPassword); - - $('#audio-devices').val(data.audio.audioDevice); - $('#audio-channels').val(data.audio.audioChannels); - $('#audio-quality').val(data.audio.audioBitrate); - - $('#webserver-name').val(data.identification.tunerName); - $('#webserver-desc').val(data.identification.tunerDesc); - $('#lat').val(data.identification.lat); - $('#lng').val(data.identification.lon); - $("#broadcast-tuner").prop("checked", data.identification.broadcastTuner); - $("#broadcast-address").val(data.identification.proxyIp); - - $('#tune-pass').val(data.password.tunePass); - $('#admin-pass').val(data.password.adminPass); - - $("#tuner-public").prop("checked", data.publicTuner); - $("#tuner-lock").prop("checked", data.lockToAdmin); - $("#shutdown-tuner").prop("checked", data.autoShutdown); - - // Check if latitude and longitude are present in the data - if (data.identification.lat && data.identification.lon) { - // Set the map's center to the received coordinates - map.setView([data.identification.lat, data.identification.lon], 13); - - // Add a pin to the map - if (typeof pin == "object") { - pin.setLatLng([data.identification.lat, data.identification.lon]); - } else { - pin = L.marker([data.identification.lat, data.identification.lon], { riseOnHover:true, draggable:true }); - pin.addTo(map); - pin.on('drag',function(ev) { - $('#lat').val((ev.latlng.lat).toFixed(6)); - $('#lng').val((ev.latlng.lng).toFixed(6)); - }); - } - } - }) - .catch(error => { - console.error('Error fetching data:', error.message); - }); -} - - -function submitData() { - const webserverIp = $('#webserver-ip').val() || '0.0.0.0'; - const webserverPort = $('#webserver-port').val() || '8080'; - const audioPort = $('#audio-port').val() || '8081'; - - const xdrdIp = $('#xdrd-ip').val() || '127.0.0.1'; - const xdrdPort = $('#xdrd-port').val() || '7373'; - const xdrdPassword = $('#xdrd-password').val() || 'password'; - - const audioDevice = $('#audio-devices').val() || 'Microphone (High Definition Audio Device)'; - const audioChannels = ($('.options .option').filter(function() { - return $(this).text() === $('#audio-channels').val(); - }).data('value') || 2); - const audioBitrate = ($('.options .option').filter(function() { - return $(this).text() === $('#audio-quality').val(); - }).data('value') || "192k"); - - const tunerName = $('#webserver-name').val() || 'FM Tuner'; - const tunerDesc = $('#webserver-desc').val() || 'Default FM tuner description'; - const lat = $('#lat').val(); - const lon = $('#lng').val(); - const broadcastTuner = $("#broadcast-tuner").is(":checked"); - const proxyIp = $("#broadcast-address").val(); - - const tunePass = $('#tune-pass').val(); - const adminPass = $('#admin-pass').val(); - - const publicTuner = $("#tuner-public").is(":checked"); - const lockToAdmin = $("#tuner-lock").is(":checked"); - const autoShutdown = $("#shutdown-tuner").is(":checked"); - - const data = { - webserver: { - webserverIp, - webserverPort, - audioPort - }, - xdrd: { - xdrdIp, - xdrdPort, - xdrdPassword - }, - audio: { - audioDevice, - audioChannels, - audioBitrate, - }, - identification: { - tunerName, - tunerDesc, - lat, - lon, - broadcastTuner, - proxyIp - }, - password: { - tunePass, - adminPass, - }, - publicTuner, - lockToAdmin, - autoShutdown - }; - - - if(adminPass.length < 1) { - alert('You need to fill in the admin password before continuing further.'); - return; + function showPanelFromHash() { + var panelId = window.location.hash.substring(1); + if (panelId) { + // Hide all panels + $('.tab-content').hide(); + + // Show the panel corresponding to the hash fragment + $('#' + panelId).show(); + + // Remove active class from all li elements + $('.nav li').removeClass('active'); + + // Add active class to the corresponding li element + $('.nav li[data-panel="' + panelId + '"]').addClass('active'); } - // Send data to the server using jQuery - $.ajax({ - url: './saveData', - type: 'POST', - contentType: 'application/json', - data: JSON.stringify(data), - success: function (message) { - alert(message); - }, - error: function (error) { - console.error(error); - } - }); - } - - - \ No newline at end of file +} \ No newline at end of file diff --git a/web/js/wizard.js b/web/js/wizard.js new file mode 100644 index 0000000..07da06f --- /dev/null +++ b/web/js/wizard.js @@ -0,0 +1,62 @@ +$(document).ready(function() { + if($('.step:visible').index() == 0) { + $('.btn-prev').hide(); + } + + $('.btn-next').click(function() { + var currentStep = $('.step:visible'); + var nextStep = currentStep.next('.step'); + + if (nextStep.length !== 0) { + currentStep.hide(); + nextStep.show(); + updateProgressBar(nextStep); + } else { + submitData(); + } + + updateWizardContent(); + }); + + $('.btn-prev').click(function() { + var currentStep = $('.step:visible'); + var nextStep = currentStep.prev('.step'); + + if (nextStep.length !== 0) { + currentStep.hide(); + nextStep.show(); + updateProgressBar(nextStep); + } else { + alert('You have reached the beginning of the wizard.'); + } + + updateWizardContent(); + }); +}); + +// Function to update the progress bar buttons +function updateProgressBar(currentStep) { + var stepIndex = $('.step').index(currentStep) + 1; + $('.btn-rounded-cube').removeClass('activated'); + $('.btn-rounded-cube:lt(' + stepIndex + ')').addClass('activated'); +} + +function updateWizardContent() { + if($('.step:visible').index() == 0) { + $('.btn-prev').hide(); + } else { + $('.btn-prev').show(); + } + + if($('.step:visible').index() == 2) { + setTimeout(function () { + map.invalidateSize(); + }, 200); + } + + if($('.step:visible').index() == 3) { + $('.btn-next').text('Save'); + } else { + $('.btn-next').text('Next') + } +} \ No newline at end of file diff --git a/web/setup.ejs b/web/setup.ejs index 5c64ef6..3d522ee 100644 --- a/web/setup.ejs +++ b/web/setup.ejs @@ -15,15 +15,63 @@
<% if (isAdminAuthenticated) { %>
-

FM-DX WebServer

+

[ADMIN PANEL]

-

This web setup allows you to set up your entire tuner.
Some settings will only change after a server restart.

-

In case you are setting up the webserver for the first time, we already filled fail-safe defaults for you.

-
-
-

BASIC SETTINGS

-

Connection to xdrd:

+
+ +
+ +
+
+
+

STATUS

+ +
+
+ <%= onlineUsers %> +

Online users

+
+ +
+ <%= memoryUsage %> +

Memory usage

+
+ +
+ <%= processUptime %> +

Uptime

+
+
+ +

Console output

+ <% if (consoleOutput && consoleOutput.length > 0) { %> +
+ <% consoleOutput.forEach(function(log) { %> +
<%= log %>
+ <% }); %> +
+ <% } else { %> +

No console output available.

+ <% } %> + +

Version:

+

Check for the latest source codeSupport the developer

+
+ +
+

Connection settings

+

You can set up your connection settings here. Changing these settings requires a server restart.

+

Tuner connection:

+

If you are connecting your tuner wirelessly, enter the tuner IP.
If you use xdrd, use 127.0.0.1 as your IP.

@@ -38,6 +86,7 @@

Webserver connection:

+

Leave the IP at 0.0.0.0 unless you explicitly know you have to change it.
Don't enter your public IP here.

@@ -46,17 +95,18 @@
-
- - -

-
-

AUDIO SETTINGS

+ +
+

Audio settings

+

You can set up your audio settings here. Changing these settings requires a server restart.

+

Your audio device port.
+ This is where your tuner is plugged in. +

+

Audio channel count.
+ 1: Mono • 2: Stereo +

+

The bitrate of the mp3 audio.
+ Minimum: 64 Kbps • Maximum: 256 Kbps +

-
- -
-
-

TUNER IDENTIFICATION INFO

- + +
+

Tuner Identification info

+ +

Set your tuner name and description here. This info will be visible to anyone who tunes in.

- +
- -
- -

Map broadcast:

-

If your tuner is set to public and ID information is filled, you can add your tuner to a public list.

-

The list is available at list.fmdx.pl.

-

-
- - -

-
- - +
-

Tuner location:

- +

Location:

+

Location info is useful for automatic identification of stations using RDS.

@@ -137,49 +179,61 @@
+
- -
-

MAINTENANCE

-
+ +
+

Map broadcast

+

If your location information is filled, you can add your tuner to a public list.

+

+
+ + +

+
+ + + + + +
+ +

Check your tuner at list.fmdx.pl.

+
+ +
+

Maintenance

+
- +

- -

+ +



+
+ + +

- +
-
+
- +

- - -
+ +
-
-

CONSOLE OUTPUT

- <% if (consoleOutput && consoleOutput.length > 0) { %> -
- <% consoleOutput.forEach(function(log) { %> -
<%= log %>
- <% }); %> -
- <% } else { %> -

No console output available.

- <% } %> -
+

Feel free to contact us on Discord for community support.

@@ -187,8 +241,7 @@ <% } else { %>
- -

FM-DX WebServer

+

[ADMIN PANEL]

You are currently not logged in as an administrator and therefore can't change the settings.

Please login below.

@@ -206,5 +259,6 @@ + diff --git a/web/wizard.ejs b/web/wizard.ejs new file mode 100644 index 0000000..a2754c4 --- /dev/null +++ b/web/wizard.ejs @@ -0,0 +1,208 @@ + + + + FM-DX Webserver + + + + + + + + + + +
+ <% if (isAdminAuthenticated) { %> +
+ +

FM-DX WebServer

+

[SETUP WIZARD]

+
+
+
1
+
2
+
3
+
4
+
+ +
+ + +
+

BASIC SETTINGS

+

Welcome to the setup wizard!
Let's set up some basic things.

+

Tuner connection:

+

If you are connecting your tuner wirelessly, enter the tuner IP.
If you use xdrd, use 127.0.0.1 as your IP.

+
+
+ + +
+
+ + +
+
+ + +
+
+

Webserver connection:

+

Leave the IP at 0.0.0.0 unless you explicitly know you have to change it.
Don't enter your public IP here.

+
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + +
+
+

Feel free to contact us on Discord for community support.

+
+ <% } else { %> +
+ +

[ADMIN PANEL]

+

You are currently not logged in as an administrator and therefore can't change the settings.

+

Please login below.

+
+
+

LOGIN

+
+ + +
+
+
+ <% } %> +
+ + + + + + +