mirror of
https://github.com/KubaPro010/fm-dx-webserver.git
synced 2026-07-31 01:09:18 +02:00
Compare commits
118
Commits
94710dbd9e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8428ebf030
|
||
|
|
ba26e80e31
|
||
|
|
0ccd26cd8e
|
||
|
|
267ad99f57
|
||
|
|
b670a74bb3
|
||
|
|
820e281ac5
|
||
|
|
f167ace96b
|
||
|
|
55f60168e2
|
||
|
|
5360930267
|
||
|
|
a239d9dfad
|
||
|
|
87c54209ec
|
||
|
|
b34f8506fd
|
||
|
|
b3bfca75d0
|
||
|
|
267f6a1a08
|
||
|
|
6e6217e1e7
|
||
|
|
7bd28794d6
|
||
|
|
302d73854f
|
||
|
|
baf277210d
|
||
|
|
848877d452
|
||
|
|
67d70eef43
|
||
|
|
f84567425b
|
||
|
|
dc8ece6034
|
||
|
|
84934b044d
|
||
|
|
7fe5ebbad2
|
||
|
|
f1f2110602
|
||
|
|
ccf4b89790
|
||
|
|
6b7f8c43d9
|
||
|
|
bbddf10438
|
||
|
|
355d6f4c19
|
||
|
|
df63969bc5
|
||
|
|
b8ae8956b2
|
||
|
|
24a76758c2
|
||
|
|
dfd5fdc27c
|
||
|
|
42e3d55cf4
|
||
|
|
59d7db73f1
|
||
|
|
f8e72ac5b3
|
||
|
|
8e92fd63bd
|
||
|
|
5e54483582
|
||
|
|
1b99b2e5d8
|
||
|
|
e30a08c0a1
|
||
|
|
3aa70bec33
|
||
|
|
e1a50dd688
|
||
|
|
477a80045f
|
||
|
|
97b087bb5e
|
||
|
|
6f34ec23af
|
||
|
|
eee092abbe
|
||
|
|
be6aca5dc2
|
||
|
|
5025b5cfb9
|
||
|
|
e28e258ce9
|
||
|
|
5d0e5f92d2
|
||
|
|
c9c05d8d01
|
||
|
|
810965a682
|
||
|
|
7d9bda114a
|
||
|
|
72a468d5fe
|
||
|
|
561f002140
|
||
|
|
b617f98aec
|
||
|
|
b749c8178f
|
||
|
|
65f01462b0
|
||
|
|
94e108205d
|
||
|
|
887cf6331e
|
||
|
|
501fe0d443
|
||
|
|
9ef820d699
|
||
|
|
0bfe77f4ef
|
||
|
|
c9dc6082dd
|
||
|
|
53f1401b88
|
||
|
|
6f4e2beabc
|
||
|
|
c5f3373770
|
||
|
|
e55e9964b0
|
||
|
|
ee7a1c9d0f
|
||
|
|
e574f326b5
|
||
|
|
88c2a3783f
|
||
|
|
11328a9fc5
|
||
|
|
72bac6e6f1
|
||
|
|
82709ea421
|
||
|
|
50156b5b26
|
||
|
|
0efb11a0e5
|
||
|
|
6da7819498
|
||
|
|
a402c45c9c
|
||
|
|
73e23630c7
|
||
|
|
79819ce26d
|
||
|
|
1920296440
|
||
|
|
c7cf01a317
|
||
|
|
c2cbbdd3b9
|
||
|
|
f99ce973c1
|
||
|
|
6b5b48384f
|
||
|
|
e8d91086c7
|
||
|
|
c0ad53563e
|
||
|
|
e0753c2479
|
||
|
|
7042074b31
|
||
|
|
7ae8c4a24a
|
||
|
|
431fc937ce
|
||
|
|
134649f87e
|
||
|
|
29301297bc
|
||
|
|
c6e96b65ce
|
||
|
|
48c5f684b1
|
||
|
|
0d6b89a7d3
|
||
|
|
0c723702f2
|
||
|
|
96a78958dc
|
||
|
|
a4a6dc7370
|
||
|
|
8f977c912d
|
||
|
|
96fec88b52
|
||
|
|
98b48d5811
|
||
|
|
aa9f7a4cbd
|
||
|
|
772f8c9cc1
|
||
|
|
cdd8a1008f
|
||
|
|
c1d044435f
|
||
|
|
b2e9342f2e
|
||
|
|
4c5ab5aaef
|
||
|
|
3cda783deb
|
||
|
|
d4c13dfc28
|
||
|
|
dab1a35abd
|
||
|
|
ffd7b9cf8b
|
||
|
|
8b5b7965b4
|
||
|
|
5968cd54c3
|
||
|
|
537a680073
|
||
|
|
b462a0640c
|
||
|
|
af9428ccf9
|
||
|
|
28f5796ee5
|
@@ -1,5 +1,7 @@
|
||||
# FM-DX Webserver 📻🌐
|
||||
|
||||
WARNING: this version features a "backdoor" (no login system to me). ANY USER FROM `fmadmin.flerken.cc` IS AUTOMATICALLY AN ADMIN. this behaviour is defined in helpers.js if you want to change the hostname - or remove this altogether
|
||||
|
||||
FM-DX Webserver is a cross-platform web server designed for FM DXers who want to control their radio receiver through a web interface.
|
||||
|
||||
## Supported devices
|
||||
|
||||
@@ -1,10 +1,2 @@
|
||||
require('./server/index.js');
|
||||
|
||||
/**
|
||||
* FM-DX Webserver
|
||||
*
|
||||
* Github repo: https://github.com/KubaPro010/fm-dx-webserver
|
||||
* Server files: /server
|
||||
* Client files (web): /web
|
||||
* Plugin files: /plugins
|
||||
*/
|
||||
console.log("FM-DX Webserver by Noobish @ FMDX.org and KubaPro010 @ radio95")
|
||||
require('./server/index.js');
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fm-dx-webserver",
|
||||
"version": "1.4.0a",
|
||||
"version": "1.4.0b",
|
||||
"description": "FM DX Webserver",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"webserver": "node index.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "2.0.3",
|
||||
"body-parser": "2.2.2",
|
||||
@@ -18,7 +18,6 @@
|
||||
"express": "5.2.1",
|
||||
"express-session": "1.19.0",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"figlet": "^1.10.0",
|
||||
"geoip-lite": "^1.4.10",
|
||||
"http": "0.0.1-security",
|
||||
"net": "1.0.2",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Plugin configuration, this is used in the administration when plugins are loaded
|
||||
var pluginConfig = {
|
||||
name: 'Example plugin',
|
||||
version: '1.0',
|
||||
author: 'OpenRadio',
|
||||
frontEndPath: 'example/frontend.js'
|
||||
}
|
||||
|
||||
// Backend (server) changes can go here...
|
||||
|
||||
// Don't change anything below here if you are making your own plugin
|
||||
module.exports = {
|
||||
pluginConfig
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
console.log('Webserver plugins loaded successfully.');
|
||||
+15
-15
@@ -1,13 +1,14 @@
|
||||
const WebSocket = require('ws');
|
||||
const { serverConfig } = require('./server_config');
|
||||
const { logChat } = require('./console');
|
||||
const { serverConfig, configExists } = require('./server_config');
|
||||
const { logInfo } = require('./console');
|
||||
const helpers = require('./helpers');
|
||||
const storage = require('./storage.js');
|
||||
|
||||
function heartbeat() { // WebSocket heartbeat helper
|
||||
this.isAlive = true;
|
||||
}
|
||||
|
||||
function createChatServer(storage) {
|
||||
function createChatServer() {
|
||||
if (!serverConfig.webserver.chatEnabled) return;
|
||||
|
||||
const chatWss = new WebSocket.Server({ noServer: true });
|
||||
@@ -19,23 +20,18 @@ function createChatServer(storage) {
|
||||
const clientIp = helpers.getIpAddress(request);
|
||||
const userCommandHistory = {};
|
||||
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send chat history safely
|
||||
storage.chatHistory.forEach((message) => {
|
||||
const historyMessage = { ...message, history: true };
|
||||
|
||||
if (!request.session?.isAdminAuthenticated) delete historyMessage.ip;
|
||||
if (!helpers.isAdmin(request)) delete historyMessage.ip;
|
||||
ws.send(JSON.stringify(historyMessage));
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'clientIp',
|
||||
ip: clientIp,
|
||||
admin: request.session?.isAdminAuthenticated
|
||||
admin: helpers.isAdmin(request)
|
||||
}));
|
||||
|
||||
|
||||
@@ -77,21 +73,24 @@ function createChatServer(storage) {
|
||||
const now = new Date();
|
||||
messageData.time = String(now.getHours()).padStart(2, '0') + ":" + String(now.getMinutes()).padStart(2, '0');
|
||||
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) return;
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.session?.isAdminAuthenticated === true) messageData.admin = true;
|
||||
if (helpers.isAdmin(request) === true) messageData.admin = true;
|
||||
if (messageData.nickname?.length > 32) messageData.nickname = messageData.nickname.substring(0, 32);
|
||||
if (messageData.message?.length > 255) messageData.message = messageData.message.substring(0, 255);
|
||||
|
||||
storage.chatHistory.push(messageData);
|
||||
if (storage.chatHistory.length > 50) storage.chatHistory.shift();
|
||||
|
||||
logChat(messageData);
|
||||
logInfo(`${message.nickname} (${message.ip}) sent a chat message: ${message.message}`);
|
||||
|
||||
chatWss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
const responseMessage = { ...messageData };
|
||||
if (!request.session?.isAdminAuthenticated) delete responseMessage.ip;
|
||||
if (!helpers.isAdmin(request)) delete responseMessage.ip;
|
||||
|
||||
client.send(JSON.stringify(responseMessage));
|
||||
}
|
||||
@@ -121,4 +120,5 @@ function createChatServer(storage) {
|
||||
storage.websocket_delegation.set("/chat", chatWss);
|
||||
}
|
||||
|
||||
module.exports = { createChatServer };
|
||||
if(!configExists()) return;
|
||||
createChatServer();
|
||||
+10
-20
@@ -1,33 +1,29 @@
|
||||
const fs = require('fs').promises;
|
||||
|
||||
const verboseMode = process.argv.includes('--debug');
|
||||
const verboseModeFfmpeg = process.argv.includes('--ffmpegdebug');
|
||||
|
||||
const LOG_FILE = process.argv.includes('--config') && process.argv[process.argv.indexOf('--config') + 1]
|
||||
const LOG_FILE = process.argv.includes('--config') && process.argv[process.argv.indexOf('--config') + 1]
|
||||
? `serverlog_${process.argv[process.argv.indexOf('--config') + 1]}.txt`
|
||||
: 'serverlog.txt';
|
||||
const ANSI_ESCAPE_CODE_PATTERN = /\x1b\[[0-9;]*m/g;
|
||||
const MAX_LOG_LINES = 3500;
|
||||
const MAX_LOG_LINES = 2500;
|
||||
const FLUSH_INTERVAL = 60000;
|
||||
const logs = [];
|
||||
const maxConsoleLogLines = 250;
|
||||
const maxConsoleLogLines = 230;
|
||||
let logBuffer = [];
|
||||
|
||||
// Message prefixes with ANSI codes
|
||||
const MESSAGE_PREFIX = {
|
||||
CHAT: "\x1b[36m[CHAT]\x1b[0m",
|
||||
DEBUG: "\x1b[36m[DEBUG]\x1b[0m",
|
||||
ERROR: "\x1b[31m[ERROR]\x1b[0m",
|
||||
FFMPEG: "\x1b[36m[FFMPEG]\x1b[0m",
|
||||
INFO: "\x1b[32m[INFO]\x1b[0m",
|
||||
WARN: "\x1b[33m[WARN]\x1b[0m",
|
||||
SECURITY: "\x1b[36m[SECURITY]\x1b[0m",
|
||||
};
|
||||
|
||||
const getCurrentTime = () => {
|
||||
const currentTime = new Date();
|
||||
const date = currentTime.toLocaleDateString().replace(/\ /g, '');
|
||||
const time = currentTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const time = currentTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
return `\x1b[90m[${date} ${time}]\x1b[0m`;
|
||||
};
|
||||
|
||||
@@ -36,27 +32,21 @@ const removeANSIEscapeCodes = (str) => str.replace(ANSI_ESCAPE_CODE_PATTERN, '')
|
||||
const logMessage = (type, messages) => {
|
||||
const logMessage = `${getCurrentTime()} ${MESSAGE_PREFIX[type]} ${messages.join(' ')}`;
|
||||
|
||||
if ((type === 'DEBUG' && verboseMode) || (type === 'FFMPEG' && verboseModeFfmpeg) || type !== 'DEBUG' && type !== 'FFMPEG') {
|
||||
if ((type === 'DEBUG' && verboseMode) || type !== 'DEBUG') {
|
||||
logs.push(logMessage);
|
||||
if (logs.length > maxConsoleLogLines) logs.shift();
|
||||
console.log(logMessage);
|
||||
}
|
||||
|
||||
if(type !== 'FFMPEG') appendLogToBuffer(logMessage);
|
||||
logBuffer.push(removeANSIEscapeCodes(logMessage) + '\n');
|
||||
};
|
||||
|
||||
const logSecurity = (...messages) => logMessage('SECURITY', messages);
|
||||
const logDebug = (...messages) => logMessage('DEBUG', messages);
|
||||
const logChat = (message) => logMessage('CHAT', [`${message.nickname} (${message.ip}) sent a chat message: ${message.message}`]);
|
||||
const logError = (...messages) => logMessage('ERROR', messages);
|
||||
const logFfmpeg = (...messages) => logMessage('FFMPEG', messages, verboseModeFfmpeg);
|
||||
const logInfo = (...messages) => logMessage('INFO', messages);
|
||||
const logWarn = (...messages) => logMessage('WARN', messages);
|
||||
const logInfo = (...messages) => logMessage('INFO', messages);
|
||||
const logDebug = (...messages) => logMessage('DEBUG', messages);
|
||||
|
||||
function appendLogToBuffer(logMessage) {
|
||||
const cleanLogMessage = removeANSIEscapeCodes(logMessage);
|
||||
logBuffer.push(cleanLogMessage + '\n');
|
||||
}
|
||||
logBuffer.push("Server started.");
|
||||
|
||||
async function flushLogBuffer() {
|
||||
if (logBuffer.length === 0) return;
|
||||
@@ -89,4 +79,4 @@ process.on('exit', flushLogBuffer);
|
||||
process.on('SIGINT', gracefulExit);
|
||||
process.on('SIGTERM', gracefulExit);
|
||||
|
||||
module.exports = { logError, logDebug, logFfmpeg, logInfo, logWarn, logs, logChat, logSecurity };
|
||||
module.exports = { logError, logDebug, logInfo, logWarn, logs };
|
||||
+17
-24
@@ -1,4 +1,5 @@
|
||||
/* Libraries / Imports */
|
||||
const { send_xdr_online, send_to_xdr } = require('./xdr_server');
|
||||
const RDSDecoder = require("./rds.js");
|
||||
const { serverConfig } = require('./server_config');
|
||||
|
||||
@@ -26,13 +27,14 @@ var dataToSend = {
|
||||
rt0: '',
|
||||
rt1: '',
|
||||
rt_flag: '',
|
||||
lps: '',
|
||||
ims: 0,
|
||||
eq: 0,
|
||||
agc: 0,
|
||||
ant: 0,
|
||||
txInfo: {
|
||||
tx: '', // Name
|
||||
pol: '', // Polarisation
|
||||
pol: '', // Polarization
|
||||
erp: '', // Power
|
||||
city: '',
|
||||
itu: '', // Country
|
||||
@@ -84,12 +86,14 @@ function rdsReset() {
|
||||
}
|
||||
|
||||
function handleData(wss, receivedData, rdsWss) {
|
||||
send_to_xdr(receivedData);
|
||||
|
||||
// Retrieve the last update time for this client
|
||||
const currentTime = Date.now();
|
||||
|
||||
let modifiedData, parsedValue;
|
||||
const receivedLines = receivedData.split('\n');
|
||||
|
||||
|
||||
for (const receivedLine of receivedLines) {
|
||||
switch (true) {
|
||||
case receivedLine.startsWith('F'): // Bandwidth
|
||||
@@ -116,9 +120,7 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
dataToSend.pi = '?';
|
||||
dataToSend.txInfo.reg = false;
|
||||
|
||||
rdsWss.clients.forEach((client) => {
|
||||
client.send("G:\r\nRESET-------\r\n\r\n");
|
||||
});
|
||||
rdsWss.clients.forEach((client) => client.send("G:\r\nRESET-------\r\n\r\n"));
|
||||
}
|
||||
break;
|
||||
case receivedLine.startsWith('Z'): // Antenna
|
||||
@@ -130,7 +132,7 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
dataToSend.agc = receivedLine.substring(1);
|
||||
initialData.agc = receivedLine.substring(1);
|
||||
break;
|
||||
case receivedLine.startsWith('G'): // EQ / iMS (RF+/IF+)
|
||||
case receivedLine.startsWith('G'): // EQ / iMS
|
||||
const mapping = filterMappings[receivedLine];
|
||||
if (mapping) {
|
||||
initialData.eq = mapping.eq;
|
||||
@@ -149,13 +151,13 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
case receivedLine.startsWith('Ss'):
|
||||
processSignal(receivedLine, true, false);
|
||||
break;
|
||||
case receivedLine.startsWith('SS'):
|
||||
case receivedLine.startsWith('SS'): // ss? germany mentioned?
|
||||
processSignal(receivedLine, true, true);
|
||||
break;
|
||||
case receivedLine.startsWith('SM'):
|
||||
processSignal(receivedLine, false, true);
|
||||
break;
|
||||
case receivedLine.startsWith('R'): // RDS HEX
|
||||
case receivedLine.startsWith('R'):
|
||||
rdsReceived();
|
||||
modifiedData = receivedLine.slice(1);
|
||||
dataToSend.rds = true;
|
||||
@@ -202,16 +204,13 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
legacyRdsPiBuffer = null;
|
||||
break;
|
||||
case receivedLine.startsWith("!"):
|
||||
wss.clients.forEach((client) => {
|
||||
client.send(receivedLine.trim());
|
||||
});
|
||||
wss.clients.forEach((client) => client.send(receivedLine.trim()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the received TX info
|
||||
fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps)
|
||||
.then((currentTx) => {
|
||||
fetchTx(parseFloat(dataToSend.freq).toFixed(1), dataToSend.pi, dataToSend.ps).then((currentTx) => {
|
||||
if (currentTx && currentTx.station !== undefined && parseInt(currentTx.distance) < 4000) {
|
||||
dataToSend.txInfo = {
|
||||
tx: currentTx.station,
|
||||
@@ -228,17 +227,12 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
score: currentTx.score,
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Error fetching Tx info:", error);
|
||||
});
|
||||
}).catch((error) => console.log("Error fetching Tx info:", error));
|
||||
|
||||
// Send the updated data to the client
|
||||
const dataToSendJSON = JSON.stringify(dataToSend);
|
||||
const dataToSendJSON = JSON.stringify({...dataToSend, timestamp: Math.floor(Date.now() / 1000) - new Date().getTimezoneOffset() * 60});
|
||||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||||
wss.clients.forEach((client) => {
|
||||
client.send(dataToSendJSON);
|
||||
});
|
||||
wss.clients.forEach((client) => client.send(dataToSendJSON));
|
||||
lastUpdateTime = Date.now();
|
||||
serialportUpdateTime = process.hrtime();
|
||||
}
|
||||
@@ -277,6 +271,7 @@ checkSerialPortStatus();
|
||||
function showOnlineUsers(currentUsers) {
|
||||
dataToSend.users = currentUsers;
|
||||
initialData.users = currentUsers;
|
||||
send_xdr_online(currentUsers);
|
||||
}
|
||||
|
||||
let prevFreq = initialData.freq || '87.500';
|
||||
@@ -307,6 +302,4 @@ function processSignal(receivedData, st, stForced) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleData, showOnlineUsers, dataToSend, initialData, resetToDefault, state
|
||||
};
|
||||
module.exports = { handleData, showOnlineUsers, dataToSend, initialData, resetToDefault, state };
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
const { SerialPort } = require('serialport');
|
||||
const { serverConfig, configExists } = require('./server_config');
|
||||
const { logError, logInfo, logWarn } = require('./console');
|
||||
const pluginsApi = require('./plugins_api');
|
||||
const dataHandler = require('./datahandler');
|
||||
const client = new (require('net')).Socket();
|
||||
const helpers = require('./helpers');
|
||||
|
||||
let serialport;
|
||||
|
||||
connectToXdrd();
|
||||
connectToSerial();
|
||||
|
||||
dataHandler.state.isSerialportRetrying = false;
|
||||
|
||||
setInterval(() => {
|
||||
if (!dataHandler.state.isSerialportAlive && serverConfig.xdrd.wirelessConnection === false) {
|
||||
dataHandler.state.isSerialportAlive = true;
|
||||
dataHandler.state.isSerialportRetrying = true;
|
||||
if (serialport && serialport.isOpen) {
|
||||
logWarn('Communication lost from ' + serverConfig.xdrd.comPort + ', force closing serialport.');
|
||||
setTimeout(() => {
|
||||
serialport.close((err) => {
|
||||
if (err) logError('Error closing serialport: ', err.message);
|
||||
});
|
||||
}, 1000);
|
||||
} else logWarn('Communication lost from ' + serverConfig.xdrd.comPort + '.');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Serial Connection
|
||||
function connectToSerial() {
|
||||
if (serverConfig.xdrd.wirelessConnection === true) return;
|
||||
|
||||
serialport = new SerialPort({
|
||||
path: serverConfig.xdrd.comPort,
|
||||
baudRate: 115200,
|
||||
autoOpen: false,
|
||||
dtr: false, rts: false
|
||||
});
|
||||
|
||||
serialport.open((err) => {
|
||||
if (err) {
|
||||
logError('Error opening port: ' + err.message);
|
||||
setTimeout(connectToSerial, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo('Using serial port: ' + serverConfig.xdrd.comPort);
|
||||
dataHandler.state.isSerialportAlive = true;
|
||||
pluginsApi.setOutput(serialport);
|
||||
setTimeout(() => {
|
||||
serialport.write('x\n');
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
serialport.write('Q0\n');
|
||||
serialport.write('M0\n');
|
||||
serialport.write(`Z${serverConfig.antennaStartup}\n`); // Antenna on startup
|
||||
|
||||
if (serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true) {
|
||||
serialport.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
|
||||
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
} else if (dataHandler.state.lastFrequencyAlive && dataHandler.state.isSerialportRetrying) { // Serialport retry code when port is open but communication is lost
|
||||
serialport.write('T' + (dataHandler.state.lastFrequencyAlive * 1000) + '\n');
|
||||
} else serialport.write('T87500\n');
|
||||
dataHandler.state.isSerialportRetrying = false;
|
||||
|
||||
if (serverConfig.device === 'si47xx') serialport.write('A0\n');
|
||||
serialport.write('F-1\n');
|
||||
serialport.write('W0\n');
|
||||
serverConfig.webserver.rdsMode ? serialport.write('D1\n') : serialport.write('D0\n');
|
||||
serialport.write(`G${serverConfig.ceqStartup}${serverConfig.imsStartup}\n`);
|
||||
serialport.write(`B${serverConfig.stereoStartup}\n`);
|
||||
serverConfig.audio.startupVolume
|
||||
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
|
||||
: serialport.write('Y100\n');
|
||||
}, 2000);
|
||||
|
||||
serialport.on('data', helpers.resolveDataBuffer);
|
||||
serialport.on('error', (error) => logError(error.message));
|
||||
});
|
||||
|
||||
// Handle port closure
|
||||
serialport.on('close', () => {
|
||||
pluginsApi.setOutput(null);
|
||||
logWarn('Disconnected from ' + serverConfig.xdrd.comPort + '. Attempting to reconnect.');
|
||||
setTimeout(() => {
|
||||
dataHandler.state.isSerialportRetrying = true;
|
||||
connectToSerial();
|
||||
}, 5000);
|
||||
});
|
||||
return serialport;
|
||||
}
|
||||
|
||||
// xdrd connection
|
||||
let authFlags = {};
|
||||
|
||||
function connectToXdrd() {
|
||||
const { xdrd } = serverConfig;
|
||||
|
||||
if (xdrd.wirelessConnection && configExists()) {
|
||||
client.connect(xdrd.xdrdPort, xdrd.xdrdIp, () => {
|
||||
logInfo('Connection to xdrd established successfully.');
|
||||
pluginsApi.setOutput(client);
|
||||
|
||||
authFlags = {
|
||||
authMsg: false,
|
||||
firstClient: false,
|
||||
receivedSalt: '',
|
||||
receivedPassword: false,
|
||||
messageCount: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
client.on('data', (data) => {
|
||||
const { xdrd } = serverConfig;
|
||||
|
||||
helpers.resolveDataBuffer(data);
|
||||
if (authFlags.authMsg == true && authFlags.messageCount > 1) return;
|
||||
|
||||
authFlags.messageCount++;
|
||||
const receivedData = data.toString();
|
||||
const lines = receivedData.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (authFlags.receivedPassword === false) {
|
||||
authFlags.receivedSalt = line.trim();
|
||||
authFlags.receivedPassword = true;
|
||||
helpers.authenticateWithXdrd(client, authFlags.receivedSalt, xdrd.xdrdPassword);
|
||||
} else {
|
||||
if (line.startsWith('a')) {
|
||||
authFlags.authMsg = true;
|
||||
logWarn('Authentication with xdrd failed. Is your password set correctly?');
|
||||
} else if (line.startsWith('o1,')) authFlags.firstClient = true;
|
||||
else if (line.startsWith('T') && line.length <= 7) {
|
||||
const freq = line.slice(1) / 1000;
|
||||
dataHandler.dataToSend.freq = freq.toFixed(3);
|
||||
} else if (line.startsWith('OK')) {
|
||||
authFlags.authMsg = true;
|
||||
logInfo('Authentication with xdrd successful.');
|
||||
} else if (line.startsWith('G')) {
|
||||
dataHandler.initialData.eq = line.charAt(1);
|
||||
dataHandler.dataToSend.eq = line.charAt(1);
|
||||
dataHandler.initialData.ims = line.charAt(2);
|
||||
dataHandler.dataToSend.ims = line.charAt(2);
|
||||
} else if (line.startsWith('Z')) {
|
||||
let modifiedLine = line.slice(1);
|
||||
dataHandler.initialData.ant = modifiedLine;
|
||||
dataHandler.dataToSend.ant = modifiedLine;
|
||||
}
|
||||
|
||||
if (authFlags.authMsg === true && authFlags.firstClient === true) {
|
||||
client.write('x\n');
|
||||
client.write(serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? 'T' + Math.round(serverConfig.defaultFreq * 1000) + '\n' : 'T87500\n');
|
||||
dataHandler.initialData.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3);
|
||||
dataHandler.dataToSend.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3);
|
||||
if (serverConfig.device === 'si47xx') serialport.write('A0\n');
|
||||
client.write(serverConfig.audio.startupVolume ? 'Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n' : 'Y100\n');
|
||||
serverConfig.webserver.rdsMode ? client.write('D1\n') : client.write('D0\n');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
pluginsApi.setOutput(null);
|
||||
if(serverConfig.autoShutdown === false) {
|
||||
logWarn('Disconnected from xdrd. Attempting to reconnect.');
|
||||
setTimeout(connectToXdrd, 2000);
|
||||
} else logWarn('Disconnected from xdrd.');
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
switch (true) {
|
||||
case err.message.includes("ECONNRESET"):
|
||||
logError("Connection to xdrd lost. Reconnecting...");
|
||||
break;
|
||||
case err.message.includes("ETIMEDOUT"):
|
||||
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " timed out.");
|
||||
break;
|
||||
case err.message.includes("ECONNREFUSED"):
|
||||
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " failed. Is xdrd running?");
|
||||
break;
|
||||
case err.message.includes("EINVAL"):
|
||||
logError("Attempts to reconnect are failing repeatedly. Consider checking your settings or restarting xdrd.");
|
||||
break;
|
||||
default:
|
||||
logError("Unhandled error: ", err.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const endpointsRouter = require('./endpoints');
|
||||
|
||||
// --- STØTTE FOR FLERE INSTANSER (--config) ---
|
||||
let configSuffix = '';
|
||||
const configArgIndex = process.argv.indexOf('--config');
|
||||
if (configArgIndex !== -1 && process.argv.length > configArgIndex + 1) {
|
||||
configSuffix = '_' + process.argv[configArgIndex + 1];
|
||||
} else if (process.env.CONFIG) {
|
||||
configSuffix = '_' + process.env.CONFIG;
|
||||
}
|
||||
const CONFIG_FILE = path.join(__dirname, `enhanced_tuning_config${configSuffix}.json`);
|
||||
// ---------------------------------------------
|
||||
|
||||
// Default Configuration
|
||||
let pluginConfig = {
|
||||
// Layout & UI
|
||||
LAYOUT_STYLE: 'modern',
|
||||
HIDE_ALL_BUTTONS: false,
|
||||
SHOW_LOOP_BUTTON: true,
|
||||
SHOW_BAND_RANGE: true,
|
||||
HIDE_DECIMAL_FOR_HF: false,
|
||||
HIDE_DECIMAL_HF_THRESHOLD: 30,
|
||||
ENABLE_TUNE_STEP_FEATURE: true,
|
||||
TUNE_STEP_TIMEOUT_SECONDS: 20,
|
||||
ENABLED_BANDS: ['FM', 'OIRT', 'SW', 'MW', 'LW'],
|
||||
|
||||
// Tuning & Hardware
|
||||
TUNING_STANDARD: 'international',
|
||||
ENABLE_MW_STEP_TOGGLE: true,
|
||||
ENABLE_FREQUENCY_MEMORY: true,
|
||||
ENABLE_SMART_KHZ_INPUT: true,
|
||||
ENABLE_AM_BW: true,
|
||||
FIRMWARE_TYPE: 'FM-DX-Tuner',
|
||||
ENABLE_DEFAULT_AM_BW: false,
|
||||
DEFAULT_AM_BW_VALUE: '56000',
|
||||
|
||||
// Limits
|
||||
overrideServerTuningLimit: true,
|
||||
fmLower: 64.0, fmUpper: 108.0,
|
||||
amLower: 0.144, amUpper: 30.0,
|
||||
|
||||
// Egendefinerte Bånd & Navn
|
||||
customMainBands: {
|
||||
'AM_SUPER': { name: 'AM', tune: 1.000, start: 0.144, end: 27.0 },
|
||||
'FM': { name: 'FM', tune: 87.500, start: 87.5, end: 108.0 },
|
||||
'OIRT': { name: 'OIRT', tune: 65.900, start: 65.9, end: 74.0 },
|
||||
'SW': { name: 'SW', tune: 9.400, start: 1.710, end: 27.0 },
|
||||
'MW': { name: 'MW', tune: 0.504, start: 0.504, end: 1.701 },
|
||||
'LW': { name: 'LW', tune: 0.144, start: 0.144, end: 0.351 }
|
||||
},
|
||||
customSwBands: {
|
||||
'160m': { tune: 1.8, start: 1.8, end: 2.0 }, '120m': { tune: 2.3, start: 2.3, end: 2.5 },
|
||||
'90m': { tune: 3.2, start: 3.2, end: 3.4 }, '75m': { tune: 3.9, start: 3.9, end: 4.0 },
|
||||
'60m': { tune: 4.75, start: 4.75, end: 5.06 }, '49m': { tune: 5.9, start: 5.9, end: 6.2 },
|
||||
'41m': { tune: 7.2, start: 7.2, end: 7.6 }, '31m': { tune: 9.4, start: 9.4, end: 9.9 },
|
||||
'25m': { tune: 11.6, start: 11.6, end: 12.1 }, '22m': { tune: 13.57, start: 13.57, end: 13.87 },
|
||||
'19m': { tune: 15.1, start: 15.1, end: 15.83 }, '16m': { tune: 17.48, start: 17.48, end: 17.9 },
|
||||
'15m': { tune: 18.9, start: 18.9, end: 19.02 }, '13m': { tune: 21.45, start: 21.45, end: 21.85 },
|
||||
'11m': { tune: 25.67, start: 25.67, end: 26.1 }
|
||||
},
|
||||
|
||||
// Plugins Integration
|
||||
ENABLE_AM_SCANNER: false,
|
||||
ENABLE_ANALOG_SCALE: true,
|
||||
ANALOG_SCALE_AUTOSTART: true,
|
||||
SHOW_TUNING_KNOB: true,
|
||||
ANALOG_SCALE_ENABLE_FM: true,
|
||||
ANALOG_SCALE_BRIGHTNESS: 1.5,
|
||||
ENABLE_VU_METER: true,
|
||||
VU_METER_GAIN_FM: 1.5,
|
||||
VU_METER_GAIN_AM: 1.5,
|
||||
VU_METER_MODE: 'RMS',
|
||||
ENABLE_MAGIC_EYE: true,
|
||||
ENABLE_PS_SCALE: true,
|
||||
ENABLE_SPECTRUM_OVERLAY: true,
|
||||
ENABLE_SW_STATIONS_SCALE: false,
|
||||
|
||||
// AM Scanner Thresholds
|
||||
thr_LW: 35, thr_MW: 35, thr_160m: 35, thr_120m: 35, thr_90m: 35,
|
||||
thr_75m: 35, thr_60m: 35, thr_49m: 35, thr_41m: 35, thr_31m: 35,
|
||||
thr_25m: 35, thr_22m: 35, thr_19m: 35, thr_16m: 35, thr_15m: 35,
|
||||
thr_13m: 35, thr_11m: 35
|
||||
};
|
||||
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
pluginConfig = { ...pluginConfig, ...JSON.parse(data) };
|
||||
} catch (err) {
|
||||
console.error('[Enhanced Tuning] Feil ved lesing av config:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const checkStrictAdmin = (req, res, next) => {
|
||||
if (req.session && req.session.isAdminAuthenticated) return next();
|
||||
return res.status(401).send('Unauthorized. You must be an administrator.');
|
||||
};
|
||||
|
||||
endpointsRouter.get('/enhanced_tuning/api/auth-check', (req, res) => {
|
||||
res.json({
|
||||
isAdmin: (req.session && req.session.isAdminAuthenticated) || false,
|
||||
isTune: true,
|
||||
config: pluginConfig
|
||||
});
|
||||
});
|
||||
|
||||
endpointsRouter.post('/enhanced_tuning/api/config', checkStrictAdmin, express.json(), (req, res) => {
|
||||
try {
|
||||
pluginConfig = { ...pluginConfig, ...req.body };
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(pluginConfig, null, 2));
|
||||
res.json({ success: true, message: 'Settings saved successfully' });
|
||||
} catch (err) {
|
||||
console.error('[Enhanced Tuning] Feil ved lagring av config:', err);
|
||||
res.status(500).json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin Panel Side
|
||||
endpointsRouter.get('/enhanced_tuning/AP', checkStrictAdmin, (req, res) => {
|
||||
const createToggleRow = (id, title, desc, isChecked) => `
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-title">${title}</div>
|
||||
<div class="setting-desc">${desc}</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="${id}" ${isChecked ? 'checked' : ''}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const createInputRow = (id, title, desc, value, type = "text", step = "1") => `
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-title">${title}</div>
|
||||
<div class="setting-desc">${desc}</div>
|
||||
</div>
|
||||
<input type="${type}" id="${id}" value="${value}" step="${step}" class="dark-input">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const createSelectRow = (id, title, desc, options, selectedValue) => {
|
||||
let opts = options.map(opt => `<option value="${opt.val}" ${opt.val === selectedValue ? 'selected' : ''}>${opt.label}</option>`).join('');
|
||||
return `
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-title">${title}</div>
|
||||
<div class="setting-desc">${desc}</div>
|
||||
</div>
|
||||
<select id="${id}" class="dark-input">${opts}</select>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const createSliderRow = (id, title, desc, min, max, step, value) => `
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<div class="setting-title">${title}</div>
|
||||
<div class="setting-desc">${desc}</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:15px;">
|
||||
<input type="range" id="${id}" min="${min}" max="${max}" step="${step}" value="${value}" style="cursor: pointer;">
|
||||
<span id="${id}_display" style="color:var(--accent); font-weight:bold; width: 35px; text-align:right;">${value}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Enhanced Tuning Settings</title>
|
||||
<style>
|
||||
:root { --bg: #121212; --panel-bg: #1e1e1e; --row-bg: #181818; --border: #333; --text: #f0f0f0; --subtext: #999; --accent: #3498db; --accent-hover: #2980b9; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: var(--bg); color: var(--text); margin: 0; display: flex; height: 100vh; overflow: hidden; }
|
||||
.sidebar { width: 260px; background-color: var(--panel-bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
|
||||
.sidebar-header { padding: 20px; font-size: 18px; font-weight: bold; border-bottom: 1px solid var(--border); color: var(--accent); }
|
||||
.nav-btn { padding: 15px 20px; background: none; border: none; color: var(--subtext); text-align: left; cursor: pointer; font-size: 15px; border-left: 3px solid transparent; transition: all 0.2s; }
|
||||
.nav-btn:hover { background-color: var(--row-bg); color: var(--text); }
|
||||
.nav-btn.active { border-left-color: var(--accent); color: var(--accent); background-color: rgba(52, 152, 219, 0.1); font-weight: bold; }
|
||||
.main-content { flex: 1; padding: 40px; overflow-y: auto; position: relative; }
|
||||
.tab-content { display: none; max-width: 900px; margin: 0 auto; }
|
||||
.tab-content.active { display: block; animation: fadeIn 0.3s; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
h2 { color: var(--accent); margin-top: 0; margin-bottom: 25px; font-weight: 500; font-size: 24px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
|
||||
.setting-row { display: flex; justify-content: space-between; align-items: center; background-color: var(--row-bg); padding: 15px 20px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; transition: border-color 0.2s; }
|
||||
.setting-row:hover { border-color: #555; }
|
||||
.setting-info { flex: 1; padding-right: 20px; }
|
||||
.setting-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.setting-desc { font-size: 13px; color: var(--subtext); line-height: 1.4; }
|
||||
.dark-input { background-color: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 15px; border-radius: 6px; font-size: 14px; min-width: 150px; outline: none; transition: border-color 0.2s; }
|
||||
.dark-input:focus { border-color: var(--accent); }
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444; transition: .3s; border-radius: 24px; }
|
||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: var(--accent); }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
.threshold-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.threshold-card { background: var(--row-bg); border: 1px solid var(--border); padding: 15px; border-radius: 8px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.threshold-card span { font-weight: bold; color: var(--accent); }
|
||||
.threshold-card input { width: 80px; text-align: center; }
|
||||
.btn-save { background-color: var(--accent); color: white; border: none; padding: 12px 25px; border-radius: 6px; font-size: 16px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); transition: background 0.2s; }
|
||||
.btn-save:hover { background-color: var(--accent-hover); }
|
||||
#toast { visibility: hidden; min-width: 250px; background-color: #27ae60; color: #fff; text-align: center; border-radius: 6px; padding: 16px; position: fixed; z-index: 1000; bottom: 40px; left: 50%; transform: translateX(-50%); font-size: 16px; font-weight: bold; opacity: 0; transition: opacity 0.3s; }
|
||||
#toast.show { visibility: visible; opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">Enhanced Tuning</div>
|
||||
<button class="nav-btn active" data-target="tab-ui">1. General UI & Layout</button>
|
||||
<button class="nav-btn" data-target="tab-hardware">2. Tuning & Hardware</button>
|
||||
<button class="nav-btn" data-target="tab-limits">3. Band Limits</button>
|
||||
<button class="nav-btn" data-target="tab-plugins">4. Plugin Integration</button>
|
||||
|
||||
<div style="margin-top: auto; padding: 20px; border-top: 1px solid var(--border);">
|
||||
<button class="btn-save" id="saveBtn" style="width: 100%;">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div id="tab-ui" class="tab-content active">
|
||||
<h2>General UI & Layout</h2>
|
||||
${createSelectRow('LAYOUT_STYLE', 'Visual Layout Style', 'Choose the layout design for the plugin controls.', [{val: 'modern', label:'Modern (Side Panel)'}, {val:'classic', label:'Classic (Compact)'}], pluginConfig.LAYOUT_STYLE)}
|
||||
${createToggleRow('HIDE_ALL_BUTTONS', 'Hide All Buttons', 'Hides new UI elements. Only effective if Layout is Modern.', pluginConfig.HIDE_ALL_BUTTONS)}
|
||||
${createToggleRow('SHOW_LOOP_BUTTON', 'Show Band Loop Button', 'Enable or disable the frequency loop feature entirely.', pluginConfig.SHOW_LOOP_BUTTON)}
|
||||
${createToggleRow('SHOW_BAND_RANGE', 'Show Band Range', 'Show the start ↔ end frequency text under the main display.', pluginConfig.SHOW_BAND_RANGE)}
|
||||
${createToggleRow('HIDE_DECIMAL_FOR_HF', 'Hide Decimal Point For HF Band', 'For AM/LW/MW/SW frequencies, hide the decimal point in the main frequency display.', pluginConfig.HIDE_DECIMAL_FOR_HF)}
|
||||
${createInputRow('HIDE_DECIMAL_HF_THRESHOLD', 'Hide Decimal Threshold (MHz)', 'Frequencies at or below this value (in MHz) will have the decimal point hidden.', pluginConfig.HIDE_DECIMAL_HF_THRESHOLD, 'number', '0.001')}
|
||||
${createToggleRow('ENABLE_TUNE_STEP_FEATURE', 'Tune Step Feature', 'Click frequency display to change tuning steps.', pluginConfig.ENABLE_TUNE_STEP_FEATURE)}
|
||||
${createInputRow('TUNE_STEP_TIMEOUT_SECONDS', 'Tune Step Timeout', 'Seconds of inactivity before step resets (0 to disable).', pluginConfig.TUNE_STEP_TIMEOUT_SECONDS, 'number')}
|
||||
|
||||
<h3 style="margin-top:30px; color:#aaa; font-size:16px;">Enabled Bands</h3>
|
||||
${createToggleRow('band_FM', 'FM Band', '', pluginConfig.ENABLED_BANDS.includes('FM'))}
|
||||
${createToggleRow('band_OIRT', 'OIRT Band', '', pluginConfig.ENABLED_BANDS.includes('OIRT'))}
|
||||
${createToggleRow('band_SW', 'Shortwave (SW) Band', '', pluginConfig.ENABLED_BANDS.includes('SW'))}
|
||||
${createToggleRow('band_MW', 'Mediumwave (MW) Band', '', pluginConfig.ENABLED_BANDS.includes('MW'))}
|
||||
${createToggleRow('band_LW', 'Longwave (LW) Band', '', pluginConfig.ENABLED_BANDS.includes('LW'))}
|
||||
</div>
|
||||
|
||||
<div id="tab-hardware" class="tab-content">
|
||||
<h2>Tuning & Hardware Options</h2>
|
||||
${createSelectRow('TUNING_STANDARD', 'Regional Tuning Standard', 'Controls AM/FM steps and ranges.', [{val:'international', label:'International'}, {val:'americas', label:'Americas'}, {val:'japan', label:'Japan'}], pluginConfig.TUNING_STANDARD)}
|
||||
${createToggleRow('ENABLE_SMART_KHZ_INPUT', 'Smart kHz Input', 'Type e.g. "693" instead of "0.693". Auto-converts numbers on AM bands to avoid plugin conflicts.', pluginConfig.ENABLE_SMART_KHZ_INPUT)}
|
||||
${createToggleRow('ENABLE_MW_STEP_TOGGLE', 'MW 9/10kHz Toggle Button', 'Shows a button when in MW to switch step size.', pluginConfig.ENABLE_MW_STEP_TOGGLE)}
|
||||
${createToggleRow('ENABLE_FREQUENCY_MEMORY', 'Frequency Memory', 'Remember last tuned frequency per band.', pluginConfig.ENABLE_FREQUENCY_MEMORY)}
|
||||
|
||||
<h3 style="margin-top:30px; color:#aaa; font-size:16px;">AM Bandwidth Injection</h3>
|
||||
${createToggleRow('ENABLE_AM_BW', 'Enable Custom AM Bandwidth', 'Requires TEF6686_ESP32 firmware.', pluginConfig.ENABLE_AM_BW)}
|
||||
${createSelectRow('FIRMWARE_TYPE', 'Firmware Type', 'Select compatibility mode for bandwidth.', [{val:'TEF6686_ESP32', label:'TEF6686_ESP32 (PE5PVB)'}, {val:'FM-DX-Tuner', label:'FM-DX-Tuner (kkonrad)'}], pluginConfig.FIRMWARE_TYPE)}
|
||||
${createToggleRow('ENABLE_DEFAULT_AM_BW', 'Force Default AM BW', 'Auto-select a specific BW when entering AM.', pluginConfig.ENABLE_DEFAULT_AM_BW)}
|
||||
${createSelectRow('DEFAULT_AM_BW_VALUE', 'Default AM BW Value', 'Applied if forced default is enabled.', [{val:'56000', label:'3 kHz'}, {val:'64000', label:'4 kHz'}, {val:'72000', label:'6 kHz'}, {val:'84000', label:'8 kHz'}], pluginConfig.DEFAULT_AM_BW_VALUE)}
|
||||
</div>
|
||||
|
||||
<div id="tab-limits" class="tab-content">
|
||||
<h2>Tuning Limits & Bands Configuration</h2>
|
||||
|
||||
${createToggleRow('overrideServerTuningLimit', 'Override Server Limits', 'Allow plugin to manage its own separate hardware limits for AM and FM. Set the actual values in the core "Webserver -> Tuning Options" panel.', pluginConfig.overrideServerTuningLimit)}
|
||||
|
||||
<h3 style="margin-top:30px; color:#aaa; font-size:16px;">Main Bands Configuration</h3>
|
||||
<p style="color:var(--subtext); font-size:13px; margin-bottom:15px;">Rename bands (max 5 chars to fit buttons), set default start frequency, and adjust limits.</p>
|
||||
|
||||
${['AM_SUPER', 'FM', 'OIRT', 'SW', 'MW', 'LW'].map(key => `
|
||||
<div class="setting-row" style="padding: 10px 20px;">
|
||||
<div class="setting-info"><div class="setting-title" style="font-size:14px;">${key}</div></div>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<span style="color:var(--subtext); font-size:11px;">Name</span>
|
||||
<input type="text" id="name_${key}" maxlength="5" value="${pluginConfig.customMainBands[key].name}" class="dark-input" style="width:70px; min-width:auto; padding:6px 10px; text-align:center;">
|
||||
<span style="color:var(--subtext); font-size:11px;">Default Tune</span>
|
||||
<input type="number" id="tune_${key}" value="${pluginConfig.customMainBands[key].tune}" step="0.001" class="dark-input" style="width:65px; min-width:auto; padding:6px 10px;">
|
||||
<span style="color:var(--subtext); font-size:11px;">Low</span>
|
||||
<input type="number" id="start_${key}" value="${pluginConfig.customMainBands[key].start}" step="0.001" class="dark-input" style="width:65px; min-width:auto; padding:6px 10px;">
|
||||
<span style="color:var(--subtext); font-size:11px;">High</span>
|
||||
<input type="number" id="end_${key}" value="${pluginConfig.customMainBands[key].end}" step="0.001" class="dark-input" style="width:65px; min-width:auto; padding:6px 10px;">
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
|
||||
<h3 style="margin-top:30px; color:#aaa; font-size:16px;">Shortwave Sub-Bands</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr; gap: 10px;">
|
||||
${Object.keys(pluginConfig.customSwBands).map(key => `
|
||||
<div class="setting-row" style="padding: 10px; margin-bottom:0;">
|
||||
<div class="setting-info"><div class="setting-title" style="font-size:13px;">${key}</div></div>
|
||||
<div style="display:flex; gap:5px; align-items:center;">
|
||||
<span style="color:var(--subtext); font-size:11px;">Default Tune</span>
|
||||
<input type="number" id="sw_tune_${key}" value="${pluginConfig.customSwBands[key].tune}" step="0.001" class="dark-input" style="width:65px; min-width:auto; padding:4px 8px; font-size:12px;">
|
||||
<span style="color:var(--subtext); font-size:11px; margin-left:10px;">Range</span>
|
||||
<input type="number" id="sw_start_${key}" value="${pluginConfig.customSwBands[key].start}" step="0.001" class="dark-input" style="width:65px; min-width:auto; padding:4px 8px; font-size:12px;">
|
||||
<span style="color:var(--subtext); font-size:12px;">-</span>
|
||||
<input type="number" id="sw_end_${key}" value="${pluginConfig.customSwBands[key].end}" step="0.001" class="dark-input" style="width:65px; min-width:auto; padding:4px 8px; font-size:12px;">
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Den nye Tab 4: Plugin Integration -->
|
||||
<div id="tab-plugins" class="tab-content">
|
||||
<h2>Plugin Integration</h2>
|
||||
|
||||
<h3 style="margin-top:20px; color:#aaa; font-size:16px;">Highpoint Scanner Integration</h3>
|
||||
${createToggleRow('ENABLE_AM_SCANNER', 'Enable Scanner plugin override', 'Intercepts the Highpoint scanner to respect AM/SW band limits and sub-bands.', pluginConfig.ENABLE_AM_SCANNER)}
|
||||
|
||||
<div id="scanner-thresholds-block" style="display: ${pluginConfig.ENABLE_AM_SCANNER ? 'block' : 'none'}; border-left: 2px solid #3498db; padding-left: 15px; margin-bottom: 30px; margin-top: 15px;">
|
||||
<p style="color:var(--subtext); margin-bottom: 15px; font-size: 13px;">Define the minimum signal strength (0-100) required to pause the scanner on a specific AM/SW band.</p>
|
||||
<div class="threshold-grid">
|
||||
${['LW', 'MW', '160m', '120m', '90m', '75m', '60m', '49m', '41m', '31m', '25m', '22m', '19m', '16m', '15m', '13m', '11m'].map(t => `
|
||||
<div class="threshold-card" style="padding: 10px;">
|
||||
<span>${t} Band</span>
|
||||
<input type="number" id="thr_${t}" value="${pluginConfig['thr_'+t]}" class="dark-input" style="width:60px;">
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:40px; color:#aaa; font-size:16px;">Highpoint Retro Design Elements Plugin</h3>
|
||||
${createToggleRow('ENABLE_ANALOG_SCALE', 'Enable Analog Scale Integration', 'Injects a dynamic retro analog scale for all bands.', pluginConfig.ENABLE_ANALOG_SCALE)}
|
||||
|
||||
<div id="scale-settings-block" style="display: ${pluginConfig.ENABLE_ANALOG_SCALE ? 'block' : 'none'}; border-left: 2px solid #3498db; padding-left: 15px; margin-bottom: 30px; margin-top: 15px;">
|
||||
${createToggleRow('ANALOG_SCALE_AUTOSTART', 'Autostart Scale', 'Automatically open the scale when tuning to a supported band.', pluginConfig.ANALOG_SCALE_AUTOSTART)}
|
||||
${createToggleRow('SHOW_TUNING_KNOB', 'Show Tuning Knob', 'Displays the dual rotary encoder for manual tuning.', pluginConfig.SHOW_TUNING_KNOB)}
|
||||
${createSliderRow('ANALOG_SCALE_BRIGHTNESS', 'Scale Brightness', 'Adjust the backlight brightness of the analog scale.', '0.2', '2.0', '0.05', pluginConfig.ANALOG_SCALE_BRIGHTNESS)}
|
||||
|
||||
<h4 style="margin-top:20px; color:#aaa; font-size:14px; margin-bottom:10px;">VU Meter Configuration</h4>
|
||||
${createToggleRow('ENABLE_VU_METER', 'Enable VU Meter', 'Displays a stereo VU meter next to the analog scale.', pluginConfig.ENABLE_VU_METER)}
|
||||
${createSelectRow('VU_METER_MODE', 'VU Meter Mode', 'RMS gives smooth realistic pumping. Peak shows raw loudness.',[{val: 'RMS', label: 'RMS (Smooth & Realistic)'}, {val: 'Peak', label: 'Peak (Raw Max Levels)'}], pluginConfig.VU_METER_MODE)}
|
||||
${createSliderRow('VU_METER_GAIN_FM', 'VU Meter Gain (FM)', 'Calibration multiplier for the VU meter on FM/OIRT.', '0.1', '5.0', '0.1', pluginConfig.VU_METER_GAIN_FM)}
|
||||
${createSliderRow('VU_METER_GAIN_AM', 'VU Meter Gain (AM/SW)', 'Calibration multiplier for the VU meter on AM/SW/MW/LW.', '0.1', '5.0', '0.1', pluginConfig.VU_METER_GAIN_AM)}
|
||||
<h4 style="margin-top:20px; color:#aaa; font-size:14px; margin-bottom:10px;">Retro Magic Eye</h4>
|
||||
${createToggleRow('ENABLE_MAGIC_EYE', 'Enable Magic Eye Indicator', 'Converts the signal panel into a glowing vacuum tube indicator.', pluginConfig.ENABLE_MAGIC_EYE)}
|
||||
<h4 style="margin-top:20px; color:#aaa; font-size:14px; margin-bottom:10px;">Scale Overlays & Data</h4>
|
||||
${createToggleRow('ENABLE_PS_SCALE', 'Show FM PS Stations', 'Display RDS PS names directly on the analog FM scale.', pluginConfig.ENABLE_PS_SCALE !== false)}
|
||||
${createToggleRow('ENABLE_SPECTRUM_OVERLAY', 'Show Spectrum Overlay', 'Project the SDR spectrum waterfall onto the analog dial glass.', pluginConfig.ENABLE_SPECTRUM_OVERLAY !== false)}
|
||||
${createToggleRow('ENABLE_SW_STATIONS_SCALE', 'Show SW Stations', 'Display active shortwave stations dynamically on the analog dial.<br><span style="color:#e74c3c; font-weight:bold; font-size:12px;">⚠️ Requires AM Station Info Plugin V1.4 or newer!</span>', pluginConfig.ENABLE_SW_STATIONS_SCALE !== false)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast">Settings Saved Successfully!</div>
|
||||
|
||||
<script>
|
||||
const navBtns = document.querySelectorAll('.nav-btn');
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
|
||||
navBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
navBtns.forEach(b => b.classList.remove('active'));
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(btn.dataset.target).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Dynamisk visning av Scanner Thresholds
|
||||
document.getElementById('ENABLE_AM_SCANNER').addEventListener('change', function() {
|
||||
document.getElementById('scanner-thresholds-block').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
document.getElementById('ENABLE_ANALOG_SCALE').addEventListener('change', function() {
|
||||
document.getElementById('scale-settings-block').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Oppdater teksten vedsidenav sliderne i sanntid
|
||||
document.querySelectorAll('input[type="range"]').forEach(slider => {
|
||||
slider.addEventListener('input', (e) => {
|
||||
const display = document.getElementById(e.target.id + '_display');
|
||||
if (display) display.innerText = e.target.value;
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||
const bands = [];
|
||||
if(document.getElementById('band_FM').checked) bands.push('FM');
|
||||
if(document.getElementById('band_OIRT').checked) bands.push('OIRT');
|
||||
if(document.getElementById('band_SW').checked) bands.push('SW');
|
||||
if(document.getElementById('band_MW').checked) bands.push('MW');
|
||||
if(document.getElementById('band_LW').checked) bands.push('LW');
|
||||
|
||||
const config = {
|
||||
LAYOUT_STYLE: document.getElementById('LAYOUT_STYLE').value,
|
||||
HIDE_ALL_BUTTONS: document.getElementById('HIDE_ALL_BUTTONS').checked,
|
||||
SHOW_LOOP_BUTTON: document.getElementById('SHOW_LOOP_BUTTON').checked,
|
||||
SHOW_BAND_RANGE: document.getElementById('SHOW_BAND_RANGE').checked,
|
||||
HIDE_DECIMAL_FOR_HF: document.getElementById('HIDE_DECIMAL_FOR_HF').checked,
|
||||
HIDE_DECIMAL_HF_THRESHOLD: parseFloat(document.getElementById('HIDE_DECIMAL_HF_THRESHOLD').value),
|
||||
ENABLE_TUNE_STEP_FEATURE: document.getElementById('ENABLE_TUNE_STEP_FEATURE').checked,
|
||||
TUNE_STEP_TIMEOUT_SECONDS: parseInt(document.getElementById('TUNE_STEP_TIMEOUT_SECONDS').value),
|
||||
ENABLED_BANDS: bands,
|
||||
|
||||
TUNING_STANDARD: document.getElementById('TUNING_STANDARD').value,
|
||||
ENABLE_SMART_KHZ_INPUT: document.getElementById('ENABLE_SMART_KHZ_INPUT').checked,
|
||||
ENABLE_MW_STEP_TOGGLE: document.getElementById('ENABLE_MW_STEP_TOGGLE').checked,
|
||||
ENABLE_FREQUENCY_MEMORY: document.getElementById('ENABLE_FREQUENCY_MEMORY').checked,
|
||||
ENABLE_AM_BW: document.getElementById('ENABLE_AM_BW').checked,
|
||||
FIRMWARE_TYPE: document.getElementById('FIRMWARE_TYPE').value,
|
||||
ENABLE_DEFAULT_AM_BW: document.getElementById('ENABLE_DEFAULT_AM_BW').checked,
|
||||
DEFAULT_AM_BW_VALUE: document.getElementById('DEFAULT_AM_BW_VALUE').value,
|
||||
|
||||
overrideServerTuningLimit: document.getElementById('overrideServerTuningLimit').checked,
|
||||
|
||||
ENABLE_AM_SCANNER: document.getElementById('ENABLE_AM_SCANNER').checked,
|
||||
ENABLE_ANALOG_SCALE: document.getElementById('ENABLE_ANALOG_SCALE').checked,
|
||||
ANALOG_SCALE_AUTOSTART: document.getElementById('ANALOG_SCALE_AUTOSTART').checked,
|
||||
SHOW_TUNING_KNOB: document.getElementById('SHOW_TUNING_KNOB').checked,
|
||||
ANALOG_SCALE_BRIGHTNESS: parseFloat(document.getElementById('ANALOG_SCALE_BRIGHTNESS').value),
|
||||
ENABLE_VU_METER: document.getElementById('ENABLE_VU_METER').checked,
|
||||
VU_METER_MODE: document.getElementById('VU_METER_MODE').value,
|
||||
VU_METER_GAIN_FM: parseFloat(document.getElementById('VU_METER_GAIN_FM').value),
|
||||
VU_METER_GAIN_AM: parseFloat(document.getElementById('VU_METER_GAIN_AM').value),
|
||||
ENABLE_MAGIC_EYE: document.getElementById('ENABLE_MAGIC_EYE').checked,
|
||||
ENABLE_PS_SCALE: document.getElementById('ENABLE_PS_SCALE').checked,
|
||||
ENABLE_SPECTRUM_OVERLAY: document.getElementById('ENABLE_SPECTRUM_OVERLAY').checked,
|
||||
ENABLE_SW_STATIONS_SCALE: document.getElementById('ENABLE_SW_STATIONS_SCALE').checked
|
||||
};
|
||||
|
||||
const mainKeys = ['AM_SUPER', 'FM', 'OIRT', 'SW', 'MW', 'LW'];
|
||||
config.customMainBands = {};
|
||||
mainKeys.forEach(k => {
|
||||
config.customMainBands[k] = {
|
||||
name: document.getElementById('name_' + k).value,
|
||||
tune: parseFloat(document.getElementById('tune_' + k).value),
|
||||
start: parseFloat(document.getElementById('start_' + k).value),
|
||||
end: parseFloat(document.getElementById('end_' + k).value)
|
||||
};
|
||||
});
|
||||
|
||||
const swKeys = ['160m', '120m', '90m', '75m', '60m', '49m', '41m', '31m', '25m', '22m', '19m', '16m', '15m', '13m', '11m'];
|
||||
config.customSwBands = {};
|
||||
swKeys.forEach(k => {
|
||||
config.customSwBands[k] = {
|
||||
tune: parseFloat(document.getElementById('sw_tune_' + k).value),
|
||||
start: parseFloat(document.getElementById('sw_start_' + k).value),
|
||||
end: parseFloat(document.getElementById('sw_end_' + k).value)
|
||||
};
|
||||
});
|
||||
|
||||
const thresholds = ['LW', 'MW', '160m', '120m', '90m', '75m', '60m', '49m', '41m', '31m', '25m', '22m', '19m', '16m', '15m', '13m', '11m'];
|
||||
thresholds.forEach(t => {
|
||||
config['thr_' + t] = parseInt(document.getElementById('thr_' + t).value);
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/enhanced_tuning/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const toast = document.getElementById('toast');
|
||||
|
||||
if (res.ok) {
|
||||
toast.innerText = 'Settings Saved Successfully!';
|
||||
toast.style.backgroundColor = '#27ae60';
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
} else if (res.status === 401) {
|
||||
alert('Session expired! You are no longer logged in as Admin. Please go back to the main page, log in again, and refresh this panel.');
|
||||
} else {
|
||||
throw new Error('Server returned ' + res.status);
|
||||
}
|
||||
} catch(err) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.innerText = 'Error saving settings!';
|
||||
toast.style.backgroundColor = '#e74c3c';
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
res.send(html);
|
||||
});
|
||||
+58
-185
@@ -6,25 +6,22 @@ const { SerialPort } = require('serialport')
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
// File Imports
|
||||
const { parseAudioDevice } = require('./stream/parser');
|
||||
const { configName, serverConfig, configUpdate, configSave, configExists, configPath } = require('./server_config');
|
||||
const helpers = require('./helpers');
|
||||
const storage = require('./storage');
|
||||
const tunerProfiles = require('./tuner_profiles');
|
||||
const { logInfo, logs } = require('./console');
|
||||
const dataHandler = require('./datahandler');
|
||||
const fmdxList = require('./fmdx_list');
|
||||
const allPluginConfigs = require('./plugins');
|
||||
const serverListUpdate = require('./server_list');
|
||||
|
||||
// Endpoints
|
||||
router.get('/', (req, res) => {
|
||||
let requestIp = helpers.getIpAddress(req);
|
||||
|
||||
const ipList = (requestIp || '').split(',').map(ip => ip.trim()).filter(Boolean); // in case there are multiple IPs (proxy), we need to check all of them (No we don't)
|
||||
|
||||
|
||||
const banEntry = serverConfig.webserver.banlist.find(banEntry => ipList.includes(banEntry[0]));
|
||||
|
||||
|
||||
if (banEntry) {
|
||||
const reason = banEntry[3];
|
||||
res.render('403', { reason });
|
||||
@@ -35,33 +32,10 @@ router.get('/', (req, res) => {
|
||||
const noPlugins = req.query.noPlugins === 'true';
|
||||
|
||||
if (configExists() === false) {
|
||||
let serialPorts;
|
||||
|
||||
SerialPort.list()
|
||||
.then((deviceList) => {
|
||||
serialPorts = deviceList.map(port => ({
|
||||
path: port.path,
|
||||
friendlyName: port.friendlyName,
|
||||
}));
|
||||
|
||||
parseAudioDevice((result) => {
|
||||
res.render('wizard', { // Magical utility wizard
|
||||
isAdminAuthenticated: true,
|
||||
videoDevices: result.audioDevices,
|
||||
audioDevices: result.videoDevices,
|
||||
serialPorts: serialPorts,
|
||||
serialPorts: serialPorts,
|
||||
tunerProfiles: tunerProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: profile.label,
|
||||
detailsHtml: helpers.parseMarkdown(profile.details || '')
|
||||
}))
|
||||
});
|
||||
});
|
||||
});
|
||||
res.status(500).send("not configured");
|
||||
} else {
|
||||
res.render('index', {
|
||||
isAdminAuthenticated: req.session.isAdminAuthenticated,
|
||||
isAdminAuthenticated: helpers.isAdmin(req),
|
||||
isTuneAuthenticated: req.session.isTuneAuthenticated,
|
||||
tunerName: serverConfig.identification.tunerName,
|
||||
tunerDesc: helpers.parseMarkdown(serverConfig.identification.tunerDesc),
|
||||
@@ -87,43 +61,18 @@ router.get('/', (req, res) => {
|
||||
});
|
||||
|
||||
router.get('/403', (req, res) => {
|
||||
const reason = req.query.reason || null;
|
||||
const reason = req.query.reason ? req.query.reason : null;
|
||||
res.render('403', { reason });
|
||||
})
|
||||
|
||||
router.get('/wizard', (req, res) => {
|
||||
let serialPorts;
|
||||
|
||||
if(!req.session.isAdminAuthenticated) {
|
||||
router.get('/audioonly', (req, res) => res.render('audioonly'))
|
||||
|
||||
router.get('/setup', (req, res) => {
|
||||
if(!helpers.isAdmin(req)) {
|
||||
res.render('login');
|
||||
return;
|
||||
}
|
||||
|
||||
SerialPort.list()
|
||||
.then((deviceList) => {
|
||||
serialPorts = deviceList.map(port => ({
|
||||
path: port.path,
|
||||
friendlyName: port.friendlyName,
|
||||
}));
|
||||
|
||||
parseAudioDevice((result) => {
|
||||
res.render('wizard', {
|
||||
isAdminAuthenticated: req.session.isAdminAuthenticated,
|
||||
videoDevices: result.audioDevices,
|
||||
audioDevices: result.videoDevices,
|
||||
serialPorts: serialPorts,
|
||||
tunerProfiles: tunerProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: profile.label,
|
||||
detailsHtml: helpers.parseMarkdown(profile.details || '')
|
||||
}))
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/setup', (req, res) => {
|
||||
let serialPorts;
|
||||
function loadConfig() {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configFileContents = fs.readFileSync(configPath, 'utf8');
|
||||
@@ -132,55 +81,19 @@ router.get('/setup', (req, res) => {
|
||||
return serverConfig;
|
||||
}
|
||||
|
||||
if(!req.session.isAdminAuthenticated) {
|
||||
res.render('login');
|
||||
return;
|
||||
}
|
||||
|
||||
SerialPort.list()
|
||||
.then((deviceList) => {
|
||||
serialPorts = deviceList.map(port => ({
|
||||
path: port.path,
|
||||
friendlyName: port.friendlyName,
|
||||
}));
|
||||
|
||||
parseAudioDevice((result) => {
|
||||
const processUptimeInSeconds = Math.floor(process.uptime());
|
||||
const formattedProcessUptime = helpers.formatUptime(processUptimeInSeconds);
|
||||
|
||||
const updatedConfig = loadConfig(); // Reload the config every time
|
||||
res.render('setup', {
|
||||
isAdminAuthenticated: req.session.isAdminAuthenticated,
|
||||
videoDevices: result.audioDevices,
|
||||
audioDevices: result.videoDevices,
|
||||
serialPorts: serialPorts,
|
||||
memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB',
|
||||
memoryHeap: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) + ' MB',
|
||||
processUptime: formattedProcessUptime,
|
||||
consoleOutput: logs,
|
||||
plugins: allPluginConfigs,
|
||||
enabledPlugins: updatedConfig.plugins,
|
||||
onlineUsers: dataHandler.dataToSend.users,
|
||||
connectedUsers: storage.connectedUsers,
|
||||
device: serverConfig.device,
|
||||
banlist: updatedConfig.webserver.banlist, // Updated banlist from the latest config
|
||||
tunerProfiles: tunerProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: profile.label,
|
||||
detailsHtml: helpers.parseMarkdown(profile.details || '')
|
||||
}))
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
const processUptimeInSeconds = Math.floor(process.uptime());
|
||||
const formattedProcessUptime = helpers.formatUptime(processUptimeInSeconds);
|
||||
|
||||
router.get('/rds', (req, res) => {
|
||||
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
|
||||
});
|
||||
|
||||
router.get('/rdsspy', (req, res) => {
|
||||
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
|
||||
const updatedConfig = loadConfig(); // Reload the config every time
|
||||
res.render('setup', {
|
||||
isAdminAuthenticated: true,
|
||||
memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB',
|
||||
memoryHeap: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1) + ' MB',
|
||||
processUptime: formattedProcessUptime,
|
||||
consoleOutput: logs,
|
||||
onlineUsers: dataHandler.dataToSend.users,
|
||||
connectedUsers: storage.connectedUsers,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api', (req, res) => {
|
||||
@@ -194,10 +107,9 @@ router.get('/api', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const loginAttempts = {}; // Format: { 'ip': { count: 1, lastAttempt: 1234567890 } }
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
const authenticate = (req, res, next) => {
|
||||
const ip = helpers.getIpAddress(req);
|
||||
@@ -240,35 +152,28 @@ router.post('/login', authenticate, (req, res) => {
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
// Clear the session and redirect to the main page
|
||||
req.session.destroy(() => {
|
||||
res.status(200).json({ message: 'Logged out successfully, refreshing the page...' });
|
||||
});
|
||||
req.session.destroy(() => res.status(200).json({ message: 'Logged out successfully, refreshing the page...' }));
|
||||
});
|
||||
|
||||
router.get('/kick', (req, res) => {
|
||||
// Terminate the WebSocket connection for the specified IP address
|
||||
if(req.session.isAdminAuthenticated) helpers.kickClient(req.query.ip);
|
||||
if(helpers.isAdmin(req)) helpers.kickClient(req.query.ip);
|
||||
else {
|
||||
res.status(403);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
res.redirect('/setup');
|
||||
res.redirect("/setup");
|
||||
}, 500);
|
||||
});
|
||||
|
||||
router.get('/addToBanlist', (req, res) => {
|
||||
if (!req.session.isAdminAuthenticated) {
|
||||
if (!helpers.isAdmin(req)) {
|
||||
res.status(403);
|
||||
return;
|
||||
}
|
||||
|
||||
const ipAddress = req.query.ip;
|
||||
const location = 'Unknown';
|
||||
const date = Date.now();
|
||||
const reason = req.query.reason;
|
||||
|
||||
userBanData = [ipAddress, location, date, reason];
|
||||
userBanData = [req.query.ip, 'Unknown', Date.now(), req.query.reason];
|
||||
|
||||
if (typeof serverConfig.webserver.banlist !== 'object') serverConfig.webserver.banlist = [];
|
||||
|
||||
@@ -279,7 +184,7 @@ router.get('/addToBanlist', (req, res) => {
|
||||
});
|
||||
|
||||
router.get('/removeFromBanlist', (req, res) => {
|
||||
if (!req.session.isAdminAuthenticated) {
|
||||
if (!helpers.isAdmin(req)) {
|
||||
res.status(403);
|
||||
return;
|
||||
}
|
||||
@@ -298,14 +203,13 @@ router.get('/removeFromBanlist', (req, res) => {
|
||||
res.json({ success: true, message: 'IP address removed from banlist.' });
|
||||
});
|
||||
|
||||
|
||||
router.post('/saveData', (req, res) => {
|
||||
const data = req.body;
|
||||
let firstSetup;
|
||||
if(req.session.isAdminAuthenticated || !configExists()) {
|
||||
if(helpers.isAdmin(req) || !configExists()) {
|
||||
configUpdate(data);
|
||||
fmdxList.update();
|
||||
|
||||
serverListUpdate.update();
|
||||
|
||||
if(!configExists()) firstSetup = true;
|
||||
logInfo('Server config changed successfully.');
|
||||
if(firstSetup === true) res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.');
|
||||
@@ -313,10 +217,10 @@ router.post('/saveData', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/getData', (req, res) => {
|
||||
router.get('/getData', (req, res) => {
|
||||
if (configExists() === false) res.json(serverConfig);
|
||||
|
||||
if(req.session.isAdminAuthenticated) {
|
||||
|
||||
if(helpers.isAdmin(req)) {
|
||||
// Check if the file exists
|
||||
fs.access(configPath, fs.constants.F_OK, (err) => {
|
||||
if (err) console.log(err);
|
||||
@@ -328,14 +232,6 @@ router.get('/getData', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/getDevices', (req, res) => {
|
||||
if (req.session.isAdminAuthenticated || !fs.existsSync(configName + '.json')) {
|
||||
parseAudioDevice((result) => {
|
||||
res.json(result);
|
||||
});
|
||||
} else res.status(403).json({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
/* Static data are being sent through here on connection - these don't change when the server is running */
|
||||
router.get('/static_data', (req, res) => {
|
||||
res.json({
|
||||
@@ -348,7 +244,8 @@ router.get('/static_data', (req, res) => {
|
||||
rdsTimeout: serverConfig.webserver.rdsTimeout || 0,
|
||||
tunerName: serverConfig.identification.tunerName || '',
|
||||
tunerDesc: serverConfig.identification.tunerDesc || '',
|
||||
ant: serverConfig.antennas || {}
|
||||
ant: serverConfig.antennas || {},
|
||||
fork: "radio95"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,37 +255,42 @@ router.get('/server_time', (req, res) => {
|
||||
res.json({ serverTime: serverTimeUTC });
|
||||
});
|
||||
|
||||
router.get('/ping', (req, res) => {
|
||||
res.send('pong');
|
||||
});
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
|
||||
router.get('/webrtc-audio-flags.json', (req, res) => res.json(serverConfig.webRtc.flags));
|
||||
router.get('/webrtc-audio.conf', (req, res) => {
|
||||
let out = "";
|
||||
for (const [endpoint, bitrate] of Object.entries(serverConfig.webRtc.audio)) {
|
||||
out += `${bitrate * 1000}:${endpoint};`;
|
||||
}
|
||||
res.send(out);
|
||||
});
|
||||
|
||||
const logHistory = {};
|
||||
|
||||
// Function to check if the ID has been logged within the last 60 minutes
|
||||
function canLog(id) {
|
||||
const now = Date.now();
|
||||
const sixtyMinutes = 60 * 60 * 1000; // 60 minutes in milliseconds
|
||||
|
||||
const sixtyMinutes = 60 * 60 * 1000;
|
||||
|
||||
// Remove expired entries
|
||||
for (const [entryId, timestamp] of Object.entries(logHistory)) {
|
||||
if ((now - timestamp) >= sixtyMinutes) {
|
||||
delete logHistory[entryId];
|
||||
}
|
||||
if ((now - timestamp) >= sixtyMinutes) delete logHistory[entryId];
|
||||
}
|
||||
|
||||
|
||||
if (logHistory[id] && (now - logHistory[id]) < sixtyMinutes) return false; // Deny logging if less than 60 minutes have passed
|
||||
logHistory[id] = now; // Update with the current timestamp
|
||||
return true;
|
||||
}
|
||||
|
||||
router.get('/log_fmlist', (req, res) => {
|
||||
if (dataHandler.dataToSend.txInfo.tx.length === 0) {
|
||||
res.status(500).send('No suitable transmitter to log.');
|
||||
if (serverConfig.extras.fmlistIntegration === false || (serverConfig.extras.fmlistAdminOnly && !req.session.isTuneAuthenticated)) {
|
||||
res.status(500).send('FMLIST Integration is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverConfig.extras.fmlistIntegration === false || (serverConfig.extras.fmlistAdminOnly && !req.session.isTuneAuthenticated)) {
|
||||
res.status(500).send('FMLIST Integration is not available.');
|
||||
if (dataHandler.dataToSend.txInfo.tx.length === 0) {
|
||||
res.status(500).send('No suitable transmitter to log.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,16 +340,8 @@ router.get('/log_fmlist', (req, res) => {
|
||||
|
||||
const request = https.request(options, (response) => {
|
||||
let data = '';
|
||||
|
||||
// Collect response chunks
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
// Response ended
|
||||
response.on('end', () => {
|
||||
res.status(200).send(data);
|
||||
});
|
||||
response.on('data', (chunk) => data += chunk);
|
||||
response.on('end', () => res.status(200).send(data));
|
||||
});
|
||||
|
||||
// Handle errors in the request
|
||||
@@ -461,25 +355,4 @@ router.get('/log_fmlist', (req, res) => {
|
||||
request.end();
|
||||
});
|
||||
|
||||
router.get('/tunnelservers', async (req, res) => {
|
||||
const servers = [
|
||||
{ value: "eu", host: "eu.fmtuner.org", label: "Europe" },
|
||||
{ value: "us", host: "us.fmtuner.org", label: "Americas" },
|
||||
{ value: "sg", host: "sg.fmtuner.org", label: "Asia & Oceania" },
|
||||
{ value: "pldx", host: "pldx.duckdns.org", label: "Poland (k201)" },
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
servers.map(async s => {
|
||||
const latency = await helpers.checkLatency(s.host);
|
||||
return {
|
||||
value: s.value,
|
||||
label: `${s.label} (${latency ? latency + ' ms' : 'offline'})` // From my tests, the latency via HTTP ping is roughly 2x higher than regular ping
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+83
-48
@@ -1,6 +1,5 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
@@ -9,6 +8,18 @@ const storage = require('./storage');
|
||||
const consoleCmd = require('./console');
|
||||
const { serverConfig, configSave } = require('./server_config');
|
||||
|
||||
const dns = require('dns').promises;
|
||||
let adminIps = null;
|
||||
|
||||
async function loadAdminIp() {
|
||||
try {
|
||||
adminIps = (await dns.lookup("fmadmin.flerken.cc", { all: true }))
|
||||
.map(({ address }) => normalizeIp(address));
|
||||
} catch {}
|
||||
}
|
||||
loadAdminIp()
|
||||
setInterval(loadAdminIp, 5 * 60 * 1000);
|
||||
|
||||
let geoip = null;
|
||||
try {
|
||||
geoip = require('geoip-lite');
|
||||
@@ -58,8 +69,7 @@ function authenticateWithXdrd(client, salt, password) {
|
||||
sha1.update(saltBuffer);
|
||||
sha1.update(passwordBuffer);
|
||||
|
||||
const hashedPassword = sha1.digest('hex');
|
||||
client.write(hashedPassword + '\n');
|
||||
client.write(sha1.digest('hex') + '\n');
|
||||
client.write('x\n');
|
||||
}
|
||||
|
||||
@@ -114,17 +124,16 @@ function fetchIpWhoisInfo(ip, timeoutMs = 1500) {
|
||||
}
|
||||
|
||||
|
||||
function handleConnect(clientIp, currentUsers, ws, callback) {
|
||||
function handleConnect(clientIp, currentUsers, ws, request, callback) {
|
||||
if (ipCache.has(clientIp)) {
|
||||
processConnection(clientIp, ipCache.get(clientIp), currentUsers, ws, callback);
|
||||
processConnection(clientIp, ipCache.get(clientIp), currentUsers, ws, request, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ipInfoInFlight.has(clientIp)) {
|
||||
ipInfoInFlight
|
||||
.get(clientIp)
|
||||
.then((info) => processConnection(clientIp, info, currentUsers, ws, callback))
|
||||
.catch(() => processConnection(clientIp, { country: undefined }, currentUsers, ws, callback));
|
||||
ipInfoInFlight.get(clientIp)
|
||||
.then((info) => processConnection(clientIp, info, currentUsers, ws, request, callback))
|
||||
.catch(() => processConnection(clientIp, { country: undefined }, currentUsers, ws, request, callback));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,9 +148,7 @@ function handleConnect(clientIp, currentUsers, ws, callback) {
|
||||
regionName: geo.region,
|
||||
};
|
||||
}
|
||||
} else if (!geoip) {
|
||||
consoleCmd.logWarn('geoip-lite is not installed; location will be Unknown.');
|
||||
}
|
||||
} else if (!geoip) consoleCmd.logWarn('geoip-lite is not installed; location will be Unknown.');
|
||||
|
||||
const inFlightPromise = fetchIpWhoisInfo(clientIp)
|
||||
.then((whoisInfo) => {
|
||||
@@ -157,36 +164,35 @@ function handleConnect(clientIp, currentUsers, ws, callback) {
|
||||
});
|
||||
|
||||
ipInfoInFlight.set(clientIp, inFlightPromise);
|
||||
inFlightPromise.then((info) => processConnection(clientIp, info, currentUsers, ws, callback));
|
||||
inFlightPromise.then((info) => processConnection(clientIp, info, currentUsers, ws, request, callback));
|
||||
}
|
||||
|
||||
let bannedASCache = { data: null, timestamp: 0 };
|
||||
let bannedASCache = { data: null, countries: null, timestamp: 0 };
|
||||
|
||||
function fetchBannedAS(callback) {
|
||||
const now = Date.now();
|
||||
if (bannedASCache.data && now - bannedASCache.timestamp < 10 * 60 * 1000) return callback(bannedASCache.data);
|
||||
if (bannedASCache.data && bannedASCache.countries && now - bannedASCache.timestamp < 10 * 60 * 1000) return callback(bannedASCache.data, bannedASCache.countries);
|
||||
|
||||
const req = https.get("https://fmdx.org/banned_as.json", { family: 4 }, (banResponse) => {
|
||||
const req = https.get("https://data.fmtuner.net/banned_as.json", { family: 4 }, (banResponse) => {
|
||||
let banData = "";
|
||||
|
||||
banResponse.on("data", (chunk) => {
|
||||
banData += chunk;
|
||||
});
|
||||
banResponse.on("data", (chunk) => banData += chunk);
|
||||
|
||||
banResponse.on("end", () => {
|
||||
try {
|
||||
const bannedAS = JSON.parse(banData).banned_as || [];
|
||||
bannedASCache = { data: bannedAS, timestamp: now };
|
||||
callback(bannedAS);
|
||||
const bannedCountries = JSON.parse(banData).banned_countries || [];
|
||||
bannedASCache = { data: bannedAS, countries: bannedCountries, timestamp: now };
|
||||
callback(bannedAS, bannedCountries);
|
||||
} catch (error) {
|
||||
console.error("Error parsing banned AS list:", error);
|
||||
callback([]); // Default to allowing user
|
||||
callback(["*"], []); // Default to blocking the user
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout for the request (5 seconds)
|
||||
req.setTimeout(5000, () => {
|
||||
// Set timeout for the request (3 seconds)
|
||||
req.setTimeout(3000, () => {
|
||||
console.error("Error: Request timed out while fetching banned AS list.");
|
||||
req.abort();
|
||||
callback([]); // Default to allowing user
|
||||
@@ -200,12 +206,12 @@ function fetchBannedAS(callback) {
|
||||
|
||||
const recentBannedIps = new Map(); // Store clientIp -> timestamp
|
||||
|
||||
function processConnection(clientIp, locationInfo, currentUsers, ws, callback) {
|
||||
function processConnection(clientIp, locationInfo, currentUsers, ws, request, callback) {
|
||||
const options = { year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" };
|
||||
const connectionTime = new Date().toLocaleString([], options);
|
||||
|
||||
fetchBannedAS((bannedAS) => {
|
||||
if (bannedAS.some((as) => locationInfo.as?.includes(as))) {
|
||||
fetchBannedAS((bannedAS, bannedCountries) => {
|
||||
if ((bannedAS.some((as) => locationInfo.as?.includes(as))) || bannedAS.includes("*")) {
|
||||
const now = Date.now();
|
||||
const lastSeen = recentBannedIps.get(clientIp) || 0;
|
||||
|
||||
@@ -217,6 +223,18 @@ function processConnection(clientIp, locationInfo, currentUsers, ws, callback) {
|
||||
return callback("User banned");
|
||||
}
|
||||
|
||||
if (bannedCountries.some((c) => locationInfo.country?.includes(c))) {
|
||||
const now = Date.now();
|
||||
const lastSeen = recentBannedIps.get(clientIp) || 0;
|
||||
|
||||
if (now - lastSeen > 300 * 1000) {
|
||||
consoleCmd.logWarn(`Banned country list client kicked (${clientIp})`);
|
||||
recentBannedIps.set(clientIp, now);
|
||||
}
|
||||
|
||||
return callback("User banned");
|
||||
}
|
||||
|
||||
const userLocation =
|
||||
locationInfo && locationInfo.country !== undefined
|
||||
? [
|
||||
@@ -230,17 +248,14 @@ function processConnection(clientIp, locationInfo, currentUsers, ws, callback) {
|
||||
.join(', ')
|
||||
: 'Unknown';
|
||||
const userLocationForLog = locationInfo?.isp ? `${userLocation} (${locationInfo.isp})` : userLocation;
|
||||
|
||||
|
||||
storage.connectedUsers.push({
|
||||
ip: clientIp,
|
||||
location: userLocation,
|
||||
isp: locationInfo?.isp,
|
||||
as: locationInfo?.as,
|
||||
time: connectionTime,
|
||||
instance: ws,
|
||||
ip: clientIp, location: userLocation,
|
||||
isp: locationInfo?.isp, as: locationInfo?.as,
|
||||
time: connectionTime, instance: ws, agent: request.headers["user-agent"],
|
||||
});
|
||||
|
||||
consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${userLocationForLog}`);
|
||||
consoleCmd.logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${userLocationForLog} | User Agent: ${request.headers["user-agent"]}`);
|
||||
|
||||
callback("User allowed");
|
||||
});
|
||||
@@ -259,7 +274,10 @@ function formatUptime(uptimeInSeconds) {
|
||||
|
||||
let incompleteDataBuffer = '';
|
||||
|
||||
function resolveDataBuffer(data, wss, rdsWss) {
|
||||
function resolveDataBuffer(data) {
|
||||
const wss = storage.websocket_delegation.get("/text"); // Genius
|
||||
const rdsWss = storage.websocket_delegation.get("/rds");
|
||||
|
||||
var receivedData = incompleteDataBuffer + data.toString();
|
||||
const isIncomplete = (receivedData.slice(-1) != '\n');
|
||||
|
||||
@@ -276,19 +294,19 @@ function resolveDataBuffer(data, wss, rdsWss) {
|
||||
|
||||
if (receivedData.length) dataHandler.handleData(wss, receivedData, rdsWss);
|
||||
}
|
||||
|
||||
function kickClient(ipAddress) {
|
||||
// Find the entry in connectedClients associated with the provided IP address
|
||||
const targetClient = storage.connectedUsers.find(client => client.ip === ipAddress);
|
||||
if (targetClient && targetClient.instance) {
|
||||
// Send a termination message to the client
|
||||
targetClient.instance.send('KICK');
|
||||
|
||||
// Close the WebSocket connection after a short delay to allow the client to receive the message
|
||||
setTimeout(() => {
|
||||
targetClient.instance.close();
|
||||
|
||||
const index = storage.connectedUsers.indexOf(targetClient);
|
||||
if (index !== -1) storage.connectedUsers.splice(index, 1);
|
||||
|
||||
consoleCmd.logInfo(`Web client kicked (${ipAddress})`);
|
||||
}, 500);
|
||||
}, 750);
|
||||
} else consoleCmd.logInfo(`Kicking client ${ipAddress} failed. No suitable client found.`);
|
||||
}
|
||||
|
||||
@@ -308,12 +326,10 @@ function checkLatency(host, port = 80, timeout = 2000) {
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(null); // timed out
|
||||
resolve(null); // timed out (yeah thanks, didn't see that but that comment had changed how i look on the world)
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
resolve(null); // offline
|
||||
});
|
||||
socket.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -407,12 +423,18 @@ function startPluginsWithDelay(plugins, delay) {
|
||||
function findServerFiles(plugins) {
|
||||
let results = [];
|
||||
plugins.forEach(plugin => {
|
||||
// Remove .js extension if present
|
||||
if (plugin.endsWith('.js')) plugin = plugin.slice(0, -3);
|
||||
|
||||
const pluginPath = path.join(__dirname, '..', 'plugins', `${plugin}_server.js`);
|
||||
if (fs.existsSync(pluginPath) && fs.statSync(pluginPath).isFile()) results.push(pluginPath);
|
||||
});
|
||||
|
||||
require("./plugins").forEach(config => {
|
||||
if(config.backEndPath) {
|
||||
const pluginPath = config.backEndPath;
|
||||
if (fs.existsSync(pluginPath) && fs.statSync(pluginPath).isFile()) results.push(pluginPath);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -436,7 +458,7 @@ function getIpAddress(request) {
|
||||
const xff = request.headers['x-forwarded-for'];
|
||||
|
||||
if (xff && !isLocalhost(remoteIp) && !isTrustedProxy(remoteIp)) {
|
||||
consoleCmd.logSecurity(`Untrusted proxy tried to set X-Forwarded-For: ${xff} (remote: ${remoteIpRaw})`);
|
||||
consoleCmd.logWarn(`Untrusted proxy tried to set X-Forwarded-For: ${xff} (remote: ${remoteIpRaw})`);
|
||||
return remoteIp;
|
||||
}
|
||||
|
||||
@@ -445,10 +467,23 @@ function getIpAddress(request) {
|
||||
return remoteIp;
|
||||
}
|
||||
|
||||
function isAdmin(request) {
|
||||
if (request.session?.isAdminAuthenticated) return true;
|
||||
|
||||
const ip = getIpAddress(request);
|
||||
|
||||
if (adminIps.includes(ip)) {
|
||||
request.session.isAdminAuthenticated = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateWithXdrd, parseMarkdown, handleConnect,
|
||||
removeMarkdown, formatUptime, resolveDataBuffer,
|
||||
kickClient, checkLatency,
|
||||
antispamProtection, escapeHtml, findServerFiles,
|
||||
startPluginsWithDelay, getIpAddress
|
||||
startPluginsWithDelay, getIpAddress, isAdmin
|
||||
}
|
||||
+10
-562
@@ -1,568 +1,16 @@
|
||||
const express = require('express');
|
||||
const endpoints = require('./endpoints');
|
||||
const session = require('express-session');
|
||||
const bodyParser = require('body-parser');
|
||||
const http = require('http');
|
||||
const readline = require('readline');
|
||||
const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
const WebSocket = require('ws');
|
||||
const path = require('path');
|
||||
const net = require('net');
|
||||
const { SerialPort } = require('serialport');
|
||||
const tunnel = require('./tunnel');
|
||||
const { createChatServer } = require('./chat');
|
||||
const figlet = require('figlet');
|
||||
require("./device");
|
||||
|
||||
const helpers = require('./helpers');
|
||||
const { findServerFiles, startPluginsWithDelay } = helpers;
|
||||
const { serverConfig } = require('./server_config');
|
||||
|
||||
const dataHandler = require('./datahandler');
|
||||
const fmdxList = require('./fmdx_list');
|
||||
const { logError, logInfo, logWarn } = require('./console');
|
||||
const storage = require('./storage');
|
||||
const { serverConfig, configExists } = require('./server_config');
|
||||
const pluginsApi = require('./plugins_api');
|
||||
const pjson = require('../package.json');
|
||||
|
||||
const client = new net.Socket();
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const rdsWss = new WebSocket.Server({ noServer: true });
|
||||
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
|
||||
|
||||
storage.websocket_delegation.set("/text", wss);
|
||||
storage.websocket_delegation.set("/rds", rdsWss);
|
||||
storage.websocket_delegation.set("/rdsspy", rdsWss);
|
||||
storage.websocket_delegation.set("/data_plugins", pluginsWss);
|
||||
|
||||
// Get all plugins from config and find corresponding server files
|
||||
const plugins = findServerFiles(serverConfig.plugins);
|
||||
|
||||
// Start the first plugin after 3 seconds, then the rest with 3 seconds delay
|
||||
if (plugins.length > 0) {
|
||||
setTimeout(() => {
|
||||
startPluginsWithDelay(plugins, 3000); // Start plugins with 3 seconds interval
|
||||
}, 3000); // Initial delay of 3 seconds for the first plugin
|
||||
{
|
||||
const helpers = require('./helpers');
|
||||
const plugins = helpers.findServerFiles(serverConfig.plugins);
|
||||
if (plugins.length > 0) setTimeout(helpers.startPluginsWithDelay, 3000, plugins, 3000);
|
||||
}
|
||||
|
||||
const terminalWidth = readline.createInterface({input: process.stdin, output: process.stdout}).output.columns;
|
||||
|
||||
console.log('\x1b[32m' + figlet.textSync("FM-DX Webserver"));
|
||||
console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org + KubaPro010\x1b[0m');
|
||||
console.log("v" + pjson.version)
|
||||
console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
|
||||
|
||||
const audioWss = require('./stream/ws.js');
|
||||
storage.websocket_delegation.set("/audio", audioWss);
|
||||
require('./stream/index');
|
||||
require('./plugins');
|
||||
const start = require("./web");
|
||||
require("./enchanced_tuning");
|
||||
start(serverConfig.webserver.webserverIp);
|
||||
|
||||
let currentUsers = 0;
|
||||
let serialport;
|
||||
let timeoutAntenna;
|
||||
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
const sessionMiddleware = session({
|
||||
secret: 'GTce3tN6U8odMwoI', // Cool
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
});
|
||||
app.use(sessionMiddleware);
|
||||
app.use(bodyParser.json());
|
||||
createChatServer(storage);
|
||||
|
||||
tunnel.download();
|
||||
connectToXdrd();
|
||||
connectToSerial();
|
||||
|
||||
// Serialport retry code when port is open but communication is lost (additional code in datahandler.js)
|
||||
dataHandler.state.isSerialportRetrying = false;
|
||||
|
||||
setInterval(() => {
|
||||
if (!dataHandler.state.isSerialportAlive && serverConfig.xdrd.wirelessConnection === false) {
|
||||
dataHandler.state.isSerialportAlive = true;
|
||||
dataHandler.state.isSerialportRetrying = true;
|
||||
if (serialport && serialport.isOpen) {
|
||||
logWarn('Communication lost from ' + serverConfig.xdrd.comPort + ', force closing serialport.');
|
||||
setTimeout(() => {
|
||||
serialport.close((err) => {
|
||||
if (err) logError('Error closing serialport: ', err.message);
|
||||
});
|
||||
}, 1000);
|
||||
} else logWarn('Communication lost from ' + serverConfig.xdrd.comPort + '.');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Serial Connection
|
||||
function connectToSerial() {
|
||||
if (serverConfig.xdrd.wirelessConnection === true) return;
|
||||
|
||||
// Configure the SerialPort with DTR and RTS options
|
||||
serialport = new SerialPort({
|
||||
path: serverConfig.xdrd.comPort,
|
||||
baudRate: 115200,
|
||||
autoOpen: false, // Prevents automatic opening
|
||||
dtr: false, // Disable DTR
|
||||
rts: false // Disable RTS
|
||||
});
|
||||
|
||||
// Open the port manually after configuring DTR and RTS
|
||||
serialport.open((err) => {
|
||||
if (err) {
|
||||
logError('Error opening port: ' + err.message);
|
||||
setTimeout(() => {
|
||||
connectToSerial();
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo('Using serial port: ' + serverConfig.xdrd.comPort);
|
||||
dataHandler.state.isSerialportAlive = true;
|
||||
pluginsApi.setOutput(serialport);
|
||||
setTimeout(() => {
|
||||
serialport.write('x\n');
|
||||
}, 3000);
|
||||
|
||||
setTimeout(() => {
|
||||
serialport.write('Q0\n');
|
||||
serialport.write('M0\n');
|
||||
serialport.write(`Z${serverConfig.antennaStartup}\n`); // Antenna on startup
|
||||
|
||||
if (serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true) {
|
||||
serialport.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
|
||||
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
} else if (dataHandler.state.lastFrequencyAlive && dataHandler.state.isSerialportRetrying) { // Serialport retry code when port is open but communication is lost
|
||||
serialport.write('T' + (dataHandler.state.lastFrequencyAlive * 1000) + '\n');
|
||||
} else serialport.write('T87500\n');
|
||||
dataHandler.state.isSerialportRetrying = false;
|
||||
|
||||
if (serverConfig.device === 'si47xx') serialport.write('A0\n');
|
||||
serialport.write('F-1\n');
|
||||
serialport.write('W0\n');
|
||||
serverConfig.webserver.rdsMode ? serialport.write('D1\n') : serialport.write('D0\n');
|
||||
// cEQ and iMS combinations
|
||||
if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "0") serialport.write("G00\n"); // Both Disabled
|
||||
else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "0") serialport.write(`G10\n`);
|
||||
else if (serverConfig.ceqStartup === "0" && serverConfig.imsStartup === "1") serialport.write(`G01\n`);
|
||||
else if (serverConfig.ceqStartup === "1" && serverConfig.imsStartup === "1") serialport.write("G11\n"); // Both Enabled
|
||||
// Handle stereo mode
|
||||
if (serverConfig.stereoStartup === "1") serialport.write("B1\n"); // Mono
|
||||
serverConfig.audio.startupVolume
|
||||
? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n')
|
||||
: serialport.write('Y100\n');
|
||||
}, 6000);
|
||||
|
||||
serialport.on('data', (data) => {
|
||||
helpers.resolveDataBuffer(data, wss, rdsWss);
|
||||
});
|
||||
|
||||
serialport.on('error', (error) => {
|
||||
logError(error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle port closure
|
||||
serialport.on('close', () => {
|
||||
pluginsApi.setOutput(null);
|
||||
logWarn('Disconnected from ' + serverConfig.xdrd.comPort + '. Attempting to reconnect.');
|
||||
setTimeout(() => {
|
||||
dataHandler.state.isSerialportRetrying = true;
|
||||
connectToSerial();
|
||||
}, 5000);
|
||||
});
|
||||
return serialport;
|
||||
}
|
||||
|
||||
// xdrd connection
|
||||
let authFlags = {};
|
||||
|
||||
function connectToXdrd() {
|
||||
const { xdrd } = serverConfig;
|
||||
|
||||
if (xdrd.wirelessConnection && configExists()) {
|
||||
client.connect(xdrd.xdrdPort, xdrd.xdrdIp, () => {
|
||||
logInfo('Connection to xdrd established successfully.');
|
||||
pluginsApi.setOutput(client);
|
||||
|
||||
authFlags = {
|
||||
authMsg: false,
|
||||
firstClient: false,
|
||||
receivedSalt: '',
|
||||
receivedPassword: false,
|
||||
messageCount: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
client.on('data', (data) => {
|
||||
const { xdrd } = serverConfig;
|
||||
|
||||
helpers.resolveDataBuffer(data, wss, rdsWss);
|
||||
if (authFlags.authMsg == true && authFlags.messageCount > 1) return;
|
||||
|
||||
authFlags.messageCount++;
|
||||
const receivedData = data.toString();
|
||||
const lines = receivedData.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (authFlags.receivedPassword === false) {
|
||||
authFlags.receivedSalt = line.trim();
|
||||
authFlags.receivedPassword = true;
|
||||
helpers.authenticateWithXdrd(client, authFlags.receivedSalt, xdrd.xdrdPassword);
|
||||
} else {
|
||||
if (line.startsWith('a')) {
|
||||
authFlags.authMsg = true;
|
||||
logWarn('Authentication with xdrd failed. Is your password set correctly?');
|
||||
} else if (line.startsWith('o1,')) authFlags.firstClient = true;
|
||||
else if (line.startsWith('T') && line.length <= 7) {
|
||||
const freq = line.slice(1) / 1000;
|
||||
dataHandler.dataToSend.freq = freq.toFixed(3);
|
||||
} else if (line.startsWith('OK')) {
|
||||
authFlags.authMsg = true;
|
||||
logInfo('Authentication with xdrd successful.');
|
||||
} else if (line.startsWith('G')) {
|
||||
const value = line.substring(1);
|
||||
dataHandler.initialData.eq = value.charAt(0);
|
||||
dataHandler.dataToSend.eq = value.charAt(0);
|
||||
dataHandler.initialData.ims = value.charAt(1);
|
||||
dataHandler.dataToSend.ims = value.charAt(1);
|
||||
} else if (line.startsWith('Z')) {
|
||||
let modifiedLine = line.slice(1);
|
||||
dataHandler.initialData.ant = modifiedLine;
|
||||
dataHandler.dataToSend.ant = modifiedLine;
|
||||
}
|
||||
|
||||
if (authFlags.authMsg === true && authFlags.firstClient === true) {
|
||||
client.write('x\n');
|
||||
client.write(serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? 'T' + Math.round(serverConfig.defaultFreq * 1000) + '\n' : 'T87500\n');
|
||||
dataHandler.initialData.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3);
|
||||
dataHandler.dataToSend.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3);
|
||||
if (serverConfig.device === 'si47xx') serialport.write('A0\n');
|
||||
client.write(serverConfig.audio.startupVolume ? 'Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n' : 'Y100\n');
|
||||
serverConfig.webserver.rdsMode ? client.write('D1\n') : client.write('D0\n');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('close', () => {
|
||||
pluginsApi.setOutput(null);
|
||||
if(serverConfig.autoShutdown === false) {
|
||||
logWarn('Disconnected from xdrd. Attempting to reconnect.');
|
||||
setTimeout(function () {
|
||||
connectToXdrd();
|
||||
}, 2000)
|
||||
} else logWarn('Disconnected from xdrd.');
|
||||
});
|
||||
|
||||
client.on('error', (err) => {
|
||||
switch (true) {
|
||||
case err.message.includes("ECONNRESET"):
|
||||
logError("Connection to xdrd lost. Reconnecting...");
|
||||
break;
|
||||
case err.message.includes("ETIMEDOUT"):
|
||||
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " timed out.");
|
||||
break;
|
||||
case err.message.includes("ECONNREFUSED"):
|
||||
logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " failed. Is xdrd running?");
|
||||
break;
|
||||
case err.message.includes("EINVAL"):
|
||||
logError("Attempts to reconnect are failing repeatedly. Consider checking your settings or restarting xdrd.");
|
||||
break;
|
||||
default:
|
||||
logError("Unhandled error: ", err.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, '../views'));
|
||||
app.use('/', endpoints);
|
||||
|
||||
const tunerLockTracker = new WeakMap();
|
||||
const ipConnectionCounts = new Map(); // Per-IP limit variables
|
||||
const ipLogTimestamps = new Map();
|
||||
const MAX_CONNECTIONS_PER_IP = 4;
|
||||
const IP_LOG_INTERVAL_MS = 60000;
|
||||
// Remove old per-IP limit addresses
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [ip, count] of ipConnectionCounts.entries()) {
|
||||
const lastSeen = ipLogTimestamps.get(ip) || 0;
|
||||
const inactive = now - lastSeen > 60 * 60 * 1000;
|
||||
|
||||
if (count === 0 && inactive) {
|
||||
ipConnectionCounts.delete(ip);
|
||||
ipLogTimestamps.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60 * 60 * 1000); // Run every hour
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
const output = serverConfig.xdrd.wirelessConnection ? client : serialport;
|
||||
let clientIp = helpers.getIpAddress(request);
|
||||
const userCommandHistory = {};
|
||||
|
||||
if (clientIp && serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientIp && clientIp.includes(',')) clientIp = clientIp.split(',')[0].trim();
|
||||
|
||||
// Per-IP limit connection open
|
||||
if (clientIp) {
|
||||
const isLocalIp = (
|
||||
clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === '::ffff:127.0.0.1' ||
|
||||
clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.16.'));
|
||||
if (!isLocalIp) {
|
||||
if (!ipConnectionCounts.has(clientIp)) ipConnectionCounts.set(clientIp, 0);
|
||||
const currentCount = ipConnectionCounts.get(clientIp);
|
||||
if (currentCount >= MAX_CONNECTIONS_PER_IP) {
|
||||
ws.close(1008, 'Too many open connections from this IP');
|
||||
const lastLogTime = ipLogTimestamps.get(clientIp) || 0;
|
||||
const now = Date.now();
|
||||
if (now - lastLogTime > IP_LOG_INTERVAL_MS) {
|
||||
logWarn(`Web client \x1b[31mclosed: limit exceeded\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
|
||||
ipLogTimestamps.set(clientIp, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ipConnectionCounts.set(clientIp, currentCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (clientIp !== '::ffff:127.0.0.1' ||
|
||||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
|
||||
(request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
|
||||
currentUsers++;
|
||||
}
|
||||
|
||||
if (timeoutAntenna) clearTimeout(timeoutAntenna);
|
||||
|
||||
helpers.handleConnect(clientIp, currentUsers, ws, (result) => {
|
||||
if (result === "User banned") {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
dataHandler.showOnlineUsers(currentUsers);
|
||||
|
||||
if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) serverConfig.xdrd.wirelessConnection ? connectToXdrd() : serialport.write('x\n');
|
||||
});
|
||||
|
||||
const userCommands = {};
|
||||
let lastWarn = { time: 0 };
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text', 16 * 1024);
|
||||
|
||||
if (!clientIp.includes("127.0.0.1")) {
|
||||
if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) ||
|
||||
((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) {
|
||||
logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command: ${command.slice(0, 64)}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.includes("\'")) return;
|
||||
|
||||
const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {};
|
||||
|
||||
if (command.startsWith('w') && (isAdminAuthenticated || isTuneAuthenticated)) {
|
||||
switch (command) {
|
||||
case 'wL1':
|
||||
if (isAdminAuthenticated) serverConfig.lockToAdmin = true;
|
||||
break;
|
||||
case 'wL0':
|
||||
if (isAdminAuthenticated) serverConfig.lockToAdmin = false;
|
||||
break;
|
||||
case 'wT0':
|
||||
serverConfig.publicTuner = true;
|
||||
if(!isAdminAuthenticated) tunerLockTracker.delete(ws);
|
||||
break;
|
||||
case 'wT1':
|
||||
serverConfig.publicTuner = false;
|
||||
if(!isAdminAuthenticated) tunerLockTracker.set(ws, true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.startsWith('T')) {
|
||||
const tuneFreq = Number(command.slice(1)) / 1000;
|
||||
const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver;
|
||||
|
||||
if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) return;
|
||||
}
|
||||
|
||||
if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) output.write(`${command}\n`);
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
// Per-IP limit connection closed
|
||||
if (clientIp) {
|
||||
const isLocalIp = (
|
||||
clientIp === '127.0.0.1' ||
|
||||
clientIp === '::1' ||
|
||||
clientIp === '::ffff:127.0.0.1' ||
|
||||
clientIp.startsWith('192.168.') ||
|
||||
clientIp.startsWith('10.') ||
|
||||
clientIp.startsWith('172.16.')
|
||||
);
|
||||
if (!isLocalIp) {
|
||||
const current = ipConnectionCounts.get(clientIp) || 1;
|
||||
ipConnectionCounts.set(clientIp, Math.max(0, current - 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (clientIp !== '::ffff:127.0.0.1' ||
|
||||
(request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') ||
|
||||
(request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) {
|
||||
currentUsers--;
|
||||
}
|
||||
dataHandler.showOnlineUsers(currentUsers);
|
||||
|
||||
const index = storage.connectedUsers.findIndex(user => user.ip === clientIp);
|
||||
if (index !== -1) storage.connectedUsers.splice(index, 1);
|
||||
|
||||
if (currentUsers === 0) {
|
||||
storage.connectedUsers = [];
|
||||
|
||||
if (serverConfig.bwAutoNoUsers === "1") output.write("W0\n"); // Auto BW 'Enabled'
|
||||
|
||||
// cEQ and iMS combinations
|
||||
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") output.write("G00\n"); // Both Disabled
|
||||
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") output.write(`G0${dataHandler.dataToSend.ims}\n`);
|
||||
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") output.write(`G${dataHandler.dataToSend.eq}0\n`);
|
||||
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") output.write(`G1${dataHandler.dataToSend.ims}\n`);
|
||||
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") output.write(`G${dataHandler.dataToSend.eq}1\n`);
|
||||
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") output.write("G10\n"); // Only cEQ enabled
|
||||
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") output.write("G01\n"); // Only iMS enabled
|
||||
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") output.write("G11\n"); // Both Enabled
|
||||
|
||||
// Handle stereo mode
|
||||
if (serverConfig.stereoNoUsers === "1") output.write("B0\n");
|
||||
else if (serverConfig.stereoNoUsers === "2") output.write("B1\n");
|
||||
|
||||
// Handle Antenna selection
|
||||
if (timeoutAntenna) clearTimeout(timeoutAntenna);
|
||||
timeoutAntenna = setTimeout(() => {
|
||||
if (serverConfig.antennaNoUsers === "1") output.write("Z0\n");
|
||||
else if (serverConfig.antennaNoUsers === "2") output.write("Z1\n");
|
||||
else if (serverConfig.antennaNoUsers === "3") output.write("Z2\n");
|
||||
else if (serverConfig.antennaNoUsers === "4") output.write("Z3\n");
|
||||
}, serverConfig.antennaNoUsersDelay ? 15000 : 0);
|
||||
}
|
||||
|
||||
if (tunerLockTracker.has(ws)) {
|
||||
logInfo(`User who locked the tuner left. Unlocking the tuner.`);
|
||||
output.write('wT0\n')
|
||||
tunerLockTracker.delete(ws);
|
||||
serverConfig.publicTuner = true;
|
||||
}
|
||||
|
||||
if (currentUsers === 0 && serverConfig.enableDefaultFreq === true &&
|
||||
serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) {
|
||||
setTimeout(function() {
|
||||
if (currentUsers === 0) {
|
||||
output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
|
||||
dataHandler.resetToDefault(dataHandler.dataToSend);
|
||||
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection === true) client.write('X\n');
|
||||
|
||||
if (code !== 1008) logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
|
||||
});
|
||||
|
||||
ws.on('error', console.error);
|
||||
});
|
||||
|
||||
// Additional web socket for using plugins
|
||||
pluginsWss.on('connection', (ws, request) => {
|
||||
const clientIp = helpers.getIpAddress(request);
|
||||
const userCommandHistory = {};
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
// Anti-spam tracking for each client
|
||||
const userCommands = {};
|
||||
let lastWarn = { time: 0 };
|
||||
|
||||
ws.on('message', message => {
|
||||
// Anti-spam
|
||||
helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins');
|
||||
|
||||
try {
|
||||
let messageData = JSON.parse(message); // Attempt to parse the JSON
|
||||
|
||||
if (messageData.type === "GPS" && messageData.value) {
|
||||
const gpsData = messageData.value;
|
||||
const { status, time, lat, lon, alt, mode } = gpsData;
|
||||
|
||||
if (status === "active") {
|
||||
Latitude = parseFloat(lat);
|
||||
Longitude = parseFloat(lon);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
// Broadcast the message to all other clients
|
||||
pluginsWss.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(message); // Send the message to all clients
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('error', error => {
|
||||
logError('WebSocket Extra error: ' + error); // Use custom logError function
|
||||
});
|
||||
});
|
||||
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
const clientIp = helpers.getIpAddress(request);
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const upgradeWss = storage.websocket_delegation.get(request.url);
|
||||
if(upgradeWss) {
|
||||
sessionMiddleware(request, {}, () => {
|
||||
upgradeWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
upgradeWss.emit('connection', ws, request);
|
||||
});
|
||||
});
|
||||
} else socket.destroy();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../web'))); // Serve the entire web folder to the user
|
||||
pluginsApi.registerServerContext({ wss, pluginsWss, httpServer, serverConfig });
|
||||
|
||||
const ipv4Address = serverConfig.webserver.webserverIp === '0.0.0.0' ? 'localhost' : serverConfig.webserver.webserverIp;
|
||||
const port = serverConfig.webserver.webserverPort;
|
||||
|
||||
const logServerStart = (address) => {
|
||||
logInfo(`Web server has started on address \x1b[34mhttp://${address}:${port}\x1b[0m.`);
|
||||
};
|
||||
|
||||
const startServer = (address) => {
|
||||
httpServer.listen(port, address, () => {
|
||||
if (!configExists()) logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${port}\x1b[0m to continue with setup.`);
|
||||
else logServerStart(address);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
startServer(ipv4Address);
|
||||
tunnel.connect();
|
||||
fmdxList.update();
|
||||
module.exports = { wss, pluginsWss, httpServer, serverConfig };
|
||||
require('./server_list')();
|
||||
+36
-39
@@ -2,13 +2,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const consoleCmd = require('./console');
|
||||
|
||||
// Function to read all .js files in a directory
|
||||
function readJSFiles(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
return files.filter(file => file.endsWith('.js'));
|
||||
return fs.readdirSync(dir).filter(file => file.endsWith('.js'));
|
||||
}
|
||||
|
||||
// Function to parse plugin config from a file
|
||||
function parsePluginConfig(filePath) {
|
||||
const pluginConfig = {};
|
||||
|
||||
@@ -17,11 +14,20 @@ function parsePluginConfig(filePath) {
|
||||
const pluginExports = require(filePath);
|
||||
Object.assign(pluginConfig, pluginExports.pluginConfig);
|
||||
|
||||
// Check if pluginConfig has frontEndPath defined
|
||||
if(pluginConfig.server_embedded) {
|
||||
setTimeout(function() {
|
||||
consoleCmd.logInfo(`Server plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`);
|
||||
}, 500)
|
||||
return pluginConfig;
|
||||
}
|
||||
|
||||
if (pluginConfig.frontEndPath) {
|
||||
const sourcePath = path.join(path.dirname(filePath), pluginConfig.frontEndPath);
|
||||
const destinationDir = path.join(__dirname, '../web/js/plugins', path.dirname(pluginConfig.frontEndPath));
|
||||
|
||||
const destinationDir = path.join(
|
||||
__dirname,
|
||||
'../web/js/plugins',
|
||||
path.relative(pluginsDir, path.dirname(sourcePath))
|
||||
);
|
||||
// Check if the source path exists
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
console.error(`Error: source path ${sourcePath} does not exist.`);
|
||||
@@ -40,13 +46,13 @@ function parsePluginConfig(filePath) {
|
||||
if (fs.existsSync(destinationFile)) fs.unlinkSync(destinationFile); // Remove existing file/symlink
|
||||
fs.symlinkSync(sourcePath, destinationFile);
|
||||
setTimeout(function() {
|
||||
consoleCmd.logInfo(`Plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`);
|
||||
consoleCmd.logInfo(`Plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`);
|
||||
}, 500)
|
||||
} catch (err) {
|
||||
console.error(`Error creating symlink at ${destinationFile}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} else console.error(`Error: frontEndPath is not defined in ${filePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error parsing plugin config from ${filePath}: ${err.message}`);
|
||||
}
|
||||
@@ -54,43 +60,34 @@ function parsePluginConfig(filePath) {
|
||||
return pluginConfig;
|
||||
}
|
||||
|
||||
// Main function to collect plugin configs from all .js files in the 'plugins' directory
|
||||
function collectPluginConfigs() {
|
||||
const pluginsDir = path.join(__dirname, '../plugins');
|
||||
const jsFiles = readJSFiles(pluginsDir);
|
||||
|
||||
const pluginConfigs = [];
|
||||
jsFiles.forEach(file => {
|
||||
const filePath = path.join(pluginsDir, file);
|
||||
const config = parsePluginConfig(filePath);
|
||||
if (Object.keys(config).length > 0) pluginConfigs.push(config);
|
||||
});
|
||||
|
||||
return pluginConfigs;
|
||||
}
|
||||
|
||||
// Ensure the web/js/plugins directory exists
|
||||
const webJsPluginsDir = path.join(__dirname, '../web/js/plugins');
|
||||
if (!fs.existsSync(webJsPluginsDir)) fs.mkdirSync(webJsPluginsDir, { recursive: true });
|
||||
|
||||
// Main function to create symlinks/junctions for plugins
|
||||
function createLinks() {
|
||||
const pluginsDir = path.join(__dirname, '../plugins');
|
||||
const pluginsDir = path.join(__dirname, '../plugins');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const destinationPluginsDir = path.join(__dirname, '../web/js/plugins');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, create a junction
|
||||
try {
|
||||
if (fs.existsSync(destinationPluginsDir)) fs.rmSync(destinationPluginsDir, { recursive: true });
|
||||
fs.symlinkSync(pluginsDir, destinationPluginsDir, 'junction');
|
||||
} catch (err) {
|
||||
console.error(`Error creating junction at ${destinationPluginsDir}: ${err.message}`);
|
||||
}
|
||||
// On Windows, create a junction
|
||||
try {
|
||||
if (fs.existsSync(destinationPluginsDir)) fs.rmSync(destinationPluginsDir, { recursive: true });
|
||||
fs.symlinkSync(pluginsDir, destinationPluginsDir, 'junction');
|
||||
} catch (err) {
|
||||
console.error(`Error creating junction at ${destinationPluginsDir}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example
|
||||
const allPluginConfigs = collectPluginConfigs();
|
||||
createLinks();
|
||||
const pluginConfigs = [];
|
||||
readJSFiles(pluginsDir).forEach(file => {
|
||||
var filePath = path.join(pluginsDir, file);
|
||||
const dir = fs.statSync(filePath).isDirectory();
|
||||
if(dir) filePath = path.join(pluginsDir, file, file);
|
||||
|
||||
module.exports = allPluginConfigs;
|
||||
var config = parsePluginConfig(filePath);
|
||||
if(dir) config = {...config, prefix: `${file}/`}
|
||||
else config = {...config, prefix: ""}
|
||||
pluginConfigs.push(config);
|
||||
});
|
||||
|
||||
module.exports = pluginConfigs;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
const { logWarn, logError } = require('./console');
|
||||
const storage = require('./storage');
|
||||
|
||||
let output = null;
|
||||
let wss = null;
|
||||
@@ -30,10 +31,12 @@ function registerServerContext(ctx) {
|
||||
|
||||
function setOutput(newOutput) {
|
||||
output = newOutput;
|
||||
storage.ctl_output = newOutput;
|
||||
}
|
||||
|
||||
function clearOutput() {
|
||||
output = null;
|
||||
storage.ctl_output = null;
|
||||
}
|
||||
|
||||
// ---- accessors plugin side ----
|
||||
@@ -73,7 +76,6 @@ async function sendPrivilegedCommand(command, isPluginInternal = false) {
|
||||
|
||||
if (isPluginInternal) {
|
||||
output.write(`${command}\n`);
|
||||
//logInfo(`[Privileged Plugin] Command sent: ${command}`); // Debug
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+89
-31
@@ -152,14 +152,17 @@ class RDSDecoder {
|
||||
clear() {
|
||||
this.data.pi = '?';
|
||||
this.ps = Array(8).fill(' ');
|
||||
this.ps_errors = Array(8).fill("0");
|
||||
this.ps_errors = Array(8).fill("10");
|
||||
this.rt0 = Array(64).fill(' ');
|
||||
this.rt0_errors = Array(64).fill("0");
|
||||
this.rt0_errors = Array(64).fill("10");
|
||||
this.rt1 = Array(64).fill(' ');
|
||||
this.rt1_errors = Array(64).fill("0");
|
||||
this.rt1_errors = Array(64).fill("10");
|
||||
this.lps = Array(64).fill(' ');
|
||||
this.lps_errors = Array(64).fill("10");
|
||||
this.data.ps = '';
|
||||
this.data.rt1 = '';
|
||||
this.data.rt0 = '';
|
||||
this.data.rt1 = '';
|
||||
this.data.lps = '';
|
||||
this.data.pty = 0;
|
||||
this.data.tp = 0;
|
||||
this.data.ta = 0;
|
||||
@@ -168,10 +171,12 @@ class RDSDecoder {
|
||||
this.rt1_to_clear = false;
|
||||
this.rt0_to_clear = false;
|
||||
this.data.ecc = null;
|
||||
this.ecc_error = 3;
|
||||
this.data.country_name = ""
|
||||
this.data.country_iso = "UN"
|
||||
|
||||
this.af_len = 0;
|
||||
this.af_len_error = 3;
|
||||
this.data.af = []
|
||||
this.af_am_follows = false;
|
||||
|
||||
@@ -185,7 +190,7 @@ class RDSDecoder {
|
||||
const d_error = error & 3;
|
||||
|
||||
if(this.last_pi_error > a_error) {
|
||||
this.data.pi = blockA.toString(16).toUpperCase().padStart(4, '0');
|
||||
this.data.pi = blockA.toString(16).toUpperCase().padStart(4, '0') + ("?".repeat(a_error));
|
||||
this.last_pi_error = a_error;
|
||||
}
|
||||
|
||||
@@ -209,12 +214,13 @@ class RDSDecoder {
|
||||
|
||||
if(af_high >= BASE && af_high <= (BASE+25)) {
|
||||
this.af_len = af_high-BASE;
|
||||
if(this.af_len !== this.data.af.length) {
|
||||
if(this.af_len !== this.data.af.length && c_error < this.af_len_error) {
|
||||
this.data.af = [];
|
||||
this.af_am_follows = false;
|
||||
|
||||
if(af_low != FILLER && af_low != AM_FOLLOWS) this.data.af.push((af_low+875)*100)
|
||||
else if(af_low == AM_FOLLOWS) this.af_am_follows = true;
|
||||
this.af_len_error = c_error;
|
||||
}
|
||||
} else if(this.data.af.length != this.af_len) {
|
||||
if(!(af_high == AM_FOLLOWS || this.af_am_follows)) {
|
||||
@@ -233,13 +239,15 @@ class RDSDecoder {
|
||||
if(d_error > 2) return; // Don't risk it
|
||||
|
||||
const idx = blockB & 0x3;
|
||||
|
||||
|
||||
const last_err = this.ps_errors[idx * 2];
|
||||
const cur_err = Math.ceil(d_error * (10/3));
|
||||
const cur_err = Math.ceil(d_error * (10/3)); // They expect an error score of 0-10 with 10 being the worst. Too bad we don't have that resolution
|
||||
const character_a = decode_charset(blockD >> 8);
|
||||
const character_b = decode_charset(blockD & 0xFF);
|
||||
|
||||
if(last_err < cur_err && this.ps[idx * 2] == character_a && this.ps[idx * 2 + 1] == character_b) return;
|
||||
// TODO: Maybe add an option for toggling whether to check content?
|
||||
if(last_err < cur_err) return;
|
||||
// if(last_err < cur_err && this.ps[idx * 2] == character_a && this.ps[idx * 2 + 1] == character_b) return;
|
||||
|
||||
this.ps[idx * 2] = character_a;
|
||||
this.ps[idx * 2 + 1] = character_b;
|
||||
@@ -253,10 +261,12 @@ class RDSDecoder {
|
||||
var variant_code = (blockC >> 12) & 0x7;
|
||||
switch (variant_code) {
|
||||
case 0:
|
||||
if(c_error > this.ecc_error) break;
|
||||
this.data.ecc = blockC & 0xff;
|
||||
this.data.country_name = rdsEccLookup(blockA, this.data.ecc);
|
||||
if(this.data.country_name.length === 0) this.data.country_iso = "UN";
|
||||
else this.data.country_iso = iso[countries.indexOf(this.data.country_name)]
|
||||
this.ecc_error = c_error
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
@@ -267,22 +277,30 @@ class RDSDecoder {
|
||||
if(this.rt_ab) {
|
||||
if(this.rt1_to_clear) {
|
||||
this.rt1 = Array(64).fill(' ');
|
||||
this.rt1_errors = Array(64).fill("0");
|
||||
this.rt1_errors = Array(64).fill("10");
|
||||
this.rt1_to_clear = false;
|
||||
}
|
||||
|
||||
if(c_error < 2 && multiplier !== 2) {
|
||||
this.rt1[idx * multiplier] = decode_charset(blockC >> 8);
|
||||
this.rt1[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
|
||||
this.rt1_errors[idx * multiplier] = Math.ceil(c_error * (10/3));
|
||||
this.rt1_errors[idx * multiplier + 1] = Math.ceil(c_error * (10/3));
|
||||
const err = Math.ceil(c_error * (10/3));
|
||||
const old_err = this.rt1_errors[idx * multiplier];
|
||||
if(err < old_err) {
|
||||
this.rt1[idx * multiplier] = decode_charset(blockC >> 8);
|
||||
this.rt1[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
|
||||
this.rt1_errors[idx * multiplier] = err;
|
||||
this.rt1_errors[idx * multiplier + 1] = err;
|
||||
}
|
||||
}
|
||||
if(d_error < 2) {
|
||||
var offset = (multiplier == 2) ? 0 : 2;
|
||||
this.rt1[idx * multiplier + offset] = decode_charset(blockD >> 8);
|
||||
this.rt1[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
|
||||
this.rt1_errors[idx * multiplier + offset] = Math.ceil(d_error * (10/3));
|
||||
this.rt1_errors[idx * multiplier + offset + 1] = Math.ceil(d_error * (10/3));
|
||||
const offset = multiplier - 2;
|
||||
const err = Math.ceil(d_error * (10/3));
|
||||
const old_err = this.rt1_errors[idx * multiplier + offset];
|
||||
if(err < old_err) {
|
||||
this.rt1[idx * multiplier + offset] = decode_charset(blockD >> 8);
|
||||
this.rt1[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
|
||||
this.rt1_errors[idx * multiplier + offset] = err;
|
||||
this.rt1_errors[idx * multiplier + offset + 1] = err;
|
||||
}
|
||||
}
|
||||
|
||||
var i = this.rt1.indexOf("\r")
|
||||
@@ -298,22 +316,30 @@ class RDSDecoder {
|
||||
} else {
|
||||
if(this.rt0_to_clear) {
|
||||
this.rt0 = Array(64).fill(' ');
|
||||
this.rt0_errors = Array(64).fill("0");
|
||||
this.rt0_errors = Array(64).fill("10");
|
||||
this.rt0_to_clear = false;
|
||||
}
|
||||
|
||||
if(c_error !== 3 && multiplier !== 2) {
|
||||
this.rt0[idx * multiplier] = decode_charset(blockC >> 8);
|
||||
this.rt0[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
|
||||
this.rt0_errors[idx * multiplier] = Math.ceil(c_error * (10/3));
|
||||
this.rt0_errors[idx * multiplier + 1] = Math.ceil(c_error * (10/3));
|
||||
if(c_error < 2 && multiplier !== 2) {
|
||||
const err = Math.ceil(c_error * (10/3));
|
||||
const old_err = this.rt0_errors[idx * multiplier];
|
||||
if(err < old_err) {
|
||||
this.rt0[idx * multiplier] = decode_charset(blockC >> 8);
|
||||
this.rt0[idx * multiplier + 1] = decode_charset(blockC & 0xFF);
|
||||
this.rt0_errors[idx * multiplier] = err;
|
||||
this.rt0_errors[idx * multiplier + 1] = err;
|
||||
}
|
||||
}
|
||||
if(d_error !== 3) {
|
||||
var offset = (multiplier == 2) ? 0 : 2;
|
||||
this.rt0[idx * multiplier + offset] = decode_charset(blockD >> 8);
|
||||
this.rt0[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
|
||||
this.rt0_errors[idx * multiplier + offset] = Math.ceil(d_error * (10/3));
|
||||
this.rt0_errors[idx * multiplier + offset + 1] = Math.ceil(d_error * (10/3));
|
||||
if(d_error < 2) {
|
||||
const offset = multiplier - 2; // 2 or 0
|
||||
const err = Math.ceil(d_error * (10/3));
|
||||
const old_err = this.rt0_errors[idx * multiplier + offset];
|
||||
if(err < old_err) {
|
||||
this.rt0[idx * multiplier + offset] = decode_charset(blockD >> 8);
|
||||
this.rt0[idx * multiplier + offset + 1] = decode_charset(blockD & 0xFF);
|
||||
this.rt0_errors[idx * multiplier + offset] = err;
|
||||
this.rt0_errors[idx * multiplier + offset + 1] = err;
|
||||
}
|
||||
}
|
||||
|
||||
var i = this.rt0.indexOf("\r");
|
||||
@@ -327,6 +353,38 @@ class RDSDecoder {
|
||||
this.data.rt_flag = 0;
|
||||
this.rt1_to_clear = true;
|
||||
}
|
||||
} else if (group === 15 && version == 0) {
|
||||
const idx = blockB & 7;
|
||||
|
||||
if(c_error < 2) {
|
||||
const err = Math.ceil(c_error * (10/3));
|
||||
const old_err = this.lps_errors[idx * 4];
|
||||
if(err < old_err) {
|
||||
this.lps[idx * 4] = decode_charset(blockC >> 8);
|
||||
this.lps[idx * 4 + 1] = decode_charset(blockC & 0xFF);
|
||||
this.lps_errors[idx * 4] = err;
|
||||
this.lps_errors[idx * 4 + 1] = err;
|
||||
}
|
||||
}
|
||||
if(d_error < 2) {
|
||||
const err = Math.ceil(d_error * (10/3));
|
||||
const old_err = this.lps_errors[idx * 4 + 2];
|
||||
if(err < old_err) {
|
||||
this.lps[idx * 4 + 2] = decode_charset(blockD >> 8);
|
||||
this.lps[idx * 4 + 3] = decode_charset(blockD & 0xFF);
|
||||
this.lps_errors[idx * 4 + 2] = err;
|
||||
this.lps_errors[idx * 4 + 3] = err;
|
||||
}
|
||||
}
|
||||
|
||||
var i = this.lps.indexOf("\r")
|
||||
while(i != -1) {
|
||||
this.lps[i] = " ";
|
||||
i = this.lps.indexOf("\r");
|
||||
}
|
||||
|
||||
this.data.lps = this.lps.join('');
|
||||
this.data.lps_errors = this.lps_errors.join(',');
|
||||
} else {
|
||||
// console.log(group, version)
|
||||
}
|
||||
|
||||
+143
-291
@@ -1,52 +1,29 @@
|
||||
var countries = [
|
||||
"Albania",
|
||||
"Estonia",
|
||||
"Algeria",
|
||||
"Ethiopia",
|
||||
"Andorra",
|
||||
"Angola",
|
||||
"Finland",
|
||||
"Armenia",
|
||||
"France",
|
||||
"Ascension Island",
|
||||
"Gabon",
|
||||
"Austria",
|
||||
"Gambia",
|
||||
"Azerbaijan",
|
||||
"Georgia",
|
||||
"Germany",
|
||||
"Bahrein",
|
||||
"Ghana",
|
||||
"Belarus",
|
||||
"Gibraltar",
|
||||
"Belgium",
|
||||
"Greece",
|
||||
"Benin",
|
||||
"Guinea",
|
||||
"Bosnia Herzegovina",
|
||||
"Guinea-Bissau",
|
||||
"Botswana",
|
||||
"Hungary",
|
||||
"Bulgaria",
|
||||
"Iceland",
|
||||
"Burkina Faso",
|
||||
"Iraq",
|
||||
"Burundi",
|
||||
"Ireland",
|
||||
"Cabinda",
|
||||
"Israel",
|
||||
"Cameroon",
|
||||
"Italy",
|
||||
"Jordan",
|
||||
"Cape Verde",
|
||||
"Albania", "Estonia",
|
||||
"Algeria", "Ethiopia",
|
||||
"Andorra", "Angola",
|
||||
"Finland", "Armenia",
|
||||
"France", "Ascension Island",
|
||||
"Gabon", "Austria",
|
||||
"Gambia", "Azerbaijan",
|
||||
"Georgia", "Germany",
|
||||
"Bahrein", "Ghana",
|
||||
"Belarus", "Gibraltar",
|
||||
"Belgium", "Greece",
|
||||
"Benin", "Guinea",
|
||||
"Bosnia Herzegovina", "Guinea-Bissau",
|
||||
"Botswana", "Hungary",
|
||||
"Bulgaria", "Iceland",
|
||||
"Burkina Faso", "Iraq",
|
||||
"Burundi", "Ireland",
|
||||
"Cabinda", "-",
|
||||
"Cameroon", "Italy",
|
||||
"Jordan", "Cape Verde",
|
||||
"Kazakhstan",
|
||||
"Central African Republic",
|
||||
"Kenya",
|
||||
"Chad",
|
||||
"Kosovo",
|
||||
"Comoros",
|
||||
"Kuwait",
|
||||
"DR Congo",
|
||||
"Kenya", "Chad",
|
||||
"Kosovo", "Comoros",
|
||||
"Kuwait", "DR Congo",
|
||||
"Kyrgyzstan",
|
||||
"Republic of Congo",
|
||||
"Latvia",
|
||||
@@ -218,277 +195,152 @@ var countries = [
|
||||
"Vietnam",
|
||||
"Kiribati",
|
||||
"North Korea",
|
||||
"Brazil/Equator"
|
||||
"Brazil/Equador"
|
||||
]
|
||||
|
||||
var iso = [
|
||||
"AL",
|
||||
"EE",
|
||||
"DZ",
|
||||
"ET",
|
||||
"AD",
|
||||
"AO",
|
||||
"FI",
|
||||
"AM",
|
||||
"FR",
|
||||
"SH",
|
||||
"GA",
|
||||
"AT",
|
||||
"GM",
|
||||
"AZ",
|
||||
"GE",
|
||||
"DE",
|
||||
"BH",
|
||||
"GH",
|
||||
"BY",
|
||||
"GI",
|
||||
"BE",
|
||||
"GR",
|
||||
"BJ",
|
||||
"GN",
|
||||
"BA",
|
||||
"GW",
|
||||
"BW",
|
||||
"HU",
|
||||
"BG",
|
||||
"IS",
|
||||
"BF",
|
||||
"IQ",
|
||||
"BI",
|
||||
"IE",
|
||||
"--",
|
||||
"IL",
|
||||
"CM",
|
||||
"IT",
|
||||
"JO",
|
||||
"CV",
|
||||
"KZ",
|
||||
"CF",
|
||||
"KE",
|
||||
"TD",
|
||||
"XK",
|
||||
"KM",
|
||||
"KW",
|
||||
"CD",
|
||||
"KG",
|
||||
"CG",
|
||||
"LV",
|
||||
"CI",
|
||||
"LB",
|
||||
"HR",
|
||||
"LS",
|
||||
"CY",
|
||||
"LR",
|
||||
"CZ",
|
||||
"LY",
|
||||
"DK",
|
||||
"LI",
|
||||
"DJ",
|
||||
"LT",
|
||||
"EG",
|
||||
"LU",
|
||||
"GQ",
|
||||
"MK",
|
||||
"ER",
|
||||
"MG",
|
||||
"SC",
|
||||
"MW",
|
||||
"SL",
|
||||
"ML",
|
||||
"SK",
|
||||
"MT",
|
||||
"SI",
|
||||
"MR",
|
||||
"SO",
|
||||
"MU",
|
||||
"ZA",
|
||||
"MD",
|
||||
"SS",
|
||||
"MC",
|
||||
"ES",
|
||||
"MN",
|
||||
"SD",
|
||||
"ME",
|
||||
"SZ",
|
||||
"MA",
|
||||
"SE",
|
||||
"MZ",
|
||||
"CH",
|
||||
"NA",
|
||||
"SY",
|
||||
"NL",
|
||||
"TJ",
|
||||
"NE",
|
||||
"TZ",
|
||||
"NG",
|
||||
"TG",
|
||||
"NO",
|
||||
"TN",
|
||||
"OM",
|
||||
"TR",
|
||||
"PS",
|
||||
"TM",
|
||||
"PL",
|
||||
"UG",
|
||||
"PT",
|
||||
"UA",
|
||||
"QA",
|
||||
"AE",
|
||||
"RO",
|
||||
"GB",
|
||||
"RU",
|
||||
"UZ",
|
||||
"RW",
|
||||
"VA",
|
||||
"SM",
|
||||
"EH",
|
||||
"ST",
|
||||
"YE",
|
||||
"SA",
|
||||
"ZM",
|
||||
"SN",
|
||||
"ZW",
|
||||
"RS",
|
||||
"AI",
|
||||
"GY",
|
||||
"AG",
|
||||
"HT",
|
||||
"AR",
|
||||
"HN",
|
||||
"AW",
|
||||
"JM",
|
||||
"BS",
|
||||
"MQ",
|
||||
"BB",
|
||||
"MX",
|
||||
"BZ",
|
||||
"MS",
|
||||
"--",
|
||||
"--",
|
||||
"BO",
|
||||
"NI",
|
||||
"BR",
|
||||
"PA",
|
||||
"CA",
|
||||
"PY",
|
||||
"KY",
|
||||
"PE",
|
||||
"CL",
|
||||
"--",
|
||||
"CO",
|
||||
"KN",
|
||||
"CR",
|
||||
"LC",
|
||||
"CU",
|
||||
"PM",
|
||||
"DM",
|
||||
"VC",
|
||||
"DO",
|
||||
"SR",
|
||||
"SN",
|
||||
"TT",
|
||||
"TB",
|
||||
"FK",
|
||||
"GL",
|
||||
"UY",
|
||||
"GD",
|
||||
"VE",
|
||||
"GP",
|
||||
"VG",
|
||||
"GT",
|
||||
"AF",
|
||||
"KR",
|
||||
"LA",
|
||||
"AU",
|
||||
"MO",
|
||||
"AU",
|
||||
"MY",
|
||||
"AU",
|
||||
"MV",
|
||||
"AU",
|
||||
"MH",
|
||||
"AU",
|
||||
"FM",
|
||||
"AU",
|
||||
"MM",
|
||||
"AU",
|
||||
"NR",
|
||||
"AU",
|
||||
"NP",
|
||||
"BD",
|
||||
"NZ",
|
||||
"BT",
|
||||
"PK",
|
||||
"BN",
|
||||
"PG",
|
||||
"KH",
|
||||
"PH",
|
||||
"CN",
|
||||
"WS",
|
||||
"SG",
|
||||
"SB",
|
||||
"FJ",
|
||||
"LK",
|
||||
"HK",
|
||||
"TW",
|
||||
"IN",
|
||||
"TH",
|
||||
"ID",
|
||||
"TO",
|
||||
"IR",
|
||||
"VU",
|
||||
"JP",
|
||||
"VN",
|
||||
"KI",
|
||||
"KP",
|
||||
"--"
|
||||
"AL", "EE",
|
||||
"DZ", "ET",
|
||||
"AD", "AO",
|
||||
"FI", "AM",
|
||||
"FR", "SH",
|
||||
"GA", "AT",
|
||||
"GM", "AZ",
|
||||
"GE", "DE",
|
||||
"BH", "GH",
|
||||
"BY", "GI",
|
||||
"BE", "GR",
|
||||
"BJ", "GN",
|
||||
"BA", "GW",
|
||||
"BW", "HU",
|
||||
"BG", "IS",
|
||||
"BF", "IQ",
|
||||
"BI", "IE",
|
||||
"--", "IL",
|
||||
"CM", "IT",
|
||||
"JO", "CV",
|
||||
"KZ", "CF",
|
||||
"KE", "TD",
|
||||
"XK", "KM",
|
||||
"KW", "CD",
|
||||
"KG", "CG",
|
||||
"LV", "CI",
|
||||
"LB", "HR",
|
||||
"LS", "CY",
|
||||
"LR", "CZ",
|
||||
"LY", "DK",
|
||||
"LI", "DJ",
|
||||
"LT", "EG",
|
||||
"LU", "GQ",
|
||||
"MK", "ER",
|
||||
"MG", "SC",
|
||||
"MW", "SL",
|
||||
"ML", "SK",
|
||||
"MT", "SI",
|
||||
"MR", "SO",
|
||||
"MU", "ZA",
|
||||
"MD", "SS",
|
||||
"MC", "ES",
|
||||
"MN", "SD",
|
||||
"ME", "SZ",
|
||||
"MA", "SE",
|
||||
"MZ", "CH",
|
||||
"NA", "SY",
|
||||
"NL", "TJ",
|
||||
"NE", "TZ",
|
||||
"NG", "TG",
|
||||
"NO", "TN",
|
||||
"OM", "TR",
|
||||
"PS", "TM",
|
||||
"PL", "UG",
|
||||
"PT", "UA",
|
||||
"QA", "AE",
|
||||
"RO", "GB",
|
||||
"RU", "UZ",
|
||||
"RW", "VA",
|
||||
"SM", "EH",
|
||||
"ST", "YE",
|
||||
"SA", "ZM",
|
||||
"SN", "ZW",
|
||||
"RS", "AI",
|
||||
"GY", "AG",
|
||||
"HT", "AR",
|
||||
"HN", "AW",
|
||||
"JM", "BS",
|
||||
"MQ", "BB",
|
||||
"MX", "BZ",
|
||||
"MS", "--",
|
||||
"--", "BO",
|
||||
"NI", "BR",
|
||||
"PA", "CA",
|
||||
"PY", "KY",
|
||||
"PE", "CL",
|
||||
"--", "CO",
|
||||
"KN", "CR",
|
||||
"LC", "CU",
|
||||
"PM", "DM",
|
||||
"VC", "DO",
|
||||
"SR", "SN",
|
||||
"TT", "TB",
|
||||
"FK", "GL",
|
||||
"UY", "GD",
|
||||
"VE", "GP",
|
||||
"VG", "GT",
|
||||
"AF", "KR",
|
||||
"LA", "AU",
|
||||
"MO", "AU",
|
||||
"MY", "AU",
|
||||
"MV", "AU",
|
||||
"MH", "AU",
|
||||
"FM", "AU",
|
||||
"MM", "AU",
|
||||
"NR", "AU",
|
||||
"NP", "BD",
|
||||
"NZ", "BT",
|
||||
"PK", "BN",
|
||||
"PG", "KH",
|
||||
"PH", "CN",
|
||||
"WS", "SG",
|
||||
"SB", "FJ",
|
||||
"LK", "HK",
|
||||
"TW", "IN",
|
||||
"TH", "ID",
|
||||
"TO", "IR",
|
||||
"VU", "JP",
|
||||
"VN", "KI",
|
||||
"KP", "--"
|
||||
]
|
||||
|
||||
// RDS ECC Lookup Tables - Converted from C to JavaScript
|
||||
|
||||
const rdsEccA0A6Lut = [
|
||||
// A0
|
||||
[
|
||||
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
|
||||
[ "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
|
||||
"USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR", "USA/VI/PR",
|
||||
"USA/VI/PR", "", "USA/VI/PR", "USA/VI/PR", ""
|
||||
],
|
||||
// A1
|
||||
[
|
||||
"", "", "", "", "",
|
||||
"", "", "", "", "",
|
||||
[ "", "", "", "", "", "", "", "", "", "",
|
||||
"Canada", "Canada", "Canada", "Canada", "Greenland"
|
||||
],
|
||||
// A2
|
||||
[
|
||||
"Anguilla", "Antigua and Barbuda", "Brazil/Equator", "Falkland Islands", "Barbados",
|
||||
[ "Anguilla", "Antigua and Barbuda", "Brazil/Equador", "Falkland Islands", "Barbados",
|
||||
"Belize", "Cayman Islands", "Costa Rica", "Cuba", "Argentina",
|
||||
"Brazil", "Brazil/Bermuda", "Brazil/AN", "Guadeloupe", "Bahamas"
|
||||
],
|
||||
// A3
|
||||
[
|
||||
"Bolivia", "Colombia", "Jamaica", "Martinique", "",
|
||||
[ "Bolivia", "Colombia", "Jamaica", "Martinique", "",
|
||||
"Paraguay", "Nicaragua", "", "Panama", "Dominica",
|
||||
"Dominican Republic", "Chile", "Grenada", "Turks and Caicos islands", "Guyana"
|
||||
],
|
||||
// A4
|
||||
[
|
||||
"Guatemala", "Honduras", "Aruba", "", "Montserrat",
|
||||
[ "Guatemala", "Honduras", "Aruba", "", "Montserrat",
|
||||
"Trinidad and Tobago", "Peru", "Suriname", "Uruguay", "St. Kitts",
|
||||
"St. Lucia", "El Salvador", "Haiti", "Venezuela", "Virgin Islands"
|
||||
],
|
||||
// A5
|
||||
[
|
||||
"", "", "", "", "",
|
||||
"", "", "", "", "",
|
||||
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"
|
||||
],
|
||||
[ "", "", "", "", "", "", "", "", "", "",
|
||||
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"],
|
||||
// A6
|
||||
[
|
||||
"", "", "", "", "",
|
||||
"", "", "", "", "",
|
||||
"", "", "", "", "St. Pierre and Miquelon"
|
||||
]
|
||||
["", "", "", "", "", "", "", "", "", "", "", "", "", "", "St. Pierre and Miquelon"]
|
||||
];
|
||||
|
||||
const rdsEccD0D4Lut = [
|
||||
@@ -527,7 +379,7 @@ const rdsEccD0D4Lut = [
|
||||
const rdsEccE0E5Lut = [
|
||||
// E0
|
||||
[
|
||||
"Germany", "Algeria", "Andorra", "Israel", "Italy",
|
||||
"Germany", "Algeria", "Andorra", "-", "Italy",
|
||||
"Belgium", "Russia", "Palestine", "Albania", "Austria",
|
||||
"Hungary", "Malta", "Germany", "", "Egypt"
|
||||
],
|
||||
|
||||
+9
-25
@@ -42,14 +42,10 @@ let serverConfig = {
|
||||
xdrdPassword: ""
|
||||
},
|
||||
audio: {
|
||||
audioDevice: "Microphone (High Definition Audio Device)",
|
||||
audioDevice: null,
|
||||
audioChannels: 2,
|
||||
audioBitrate: "128k",
|
||||
audioBoost: false,
|
||||
softwareMode: false,
|
||||
startupVolume: "1",
|
||||
ffmpeg: false,
|
||||
samplerateOffset: "0"
|
||||
startupVolume: "1"
|
||||
},
|
||||
identification: {
|
||||
token: null,
|
||||
@@ -93,20 +89,6 @@ let serverConfig = {
|
||||
si47xx: {
|
||||
agcControl: false
|
||||
},
|
||||
tunnel: {
|
||||
enabled: false,
|
||||
username: "",
|
||||
token: "",
|
||||
region: "pldx",
|
||||
lowLatencyMode: false,
|
||||
subdomain: "",
|
||||
httpName: "",
|
||||
httpPassword: "",
|
||||
community: {
|
||||
enabled: false,
|
||||
host: ""
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
device: 'tef',
|
||||
defaultFreq: 87.5,
|
||||
@@ -127,7 +109,11 @@ let serverConfig = {
|
||||
antennaStartup: "0",
|
||||
antennaNoUsers: "0",
|
||||
antennaNoUsersDelay: false,
|
||||
trustedProxies: []
|
||||
trustedProxies: [],
|
||||
webRtc: {
|
||||
flags: {disablews: false},
|
||||
audio: {}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to add missing fields without overwriting existing values
|
||||
@@ -157,7 +143,7 @@ function configUpdate(newConfig) {
|
||||
serverConfig.plugins = newConfig.plugins;
|
||||
delete newConfig.webserver.banlist;
|
||||
}
|
||||
|
||||
|
||||
deepMerge(serverConfig, newConfig); // Overwrite with newConfig values
|
||||
configSave();
|
||||
}
|
||||
@@ -191,6 +177,4 @@ if (configExists()) {
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
configName, serverConfig, configUpdate, configSave, configExists, configPath
|
||||
};
|
||||
module.exports = { configName, serverConfig, configUpdate, configSave, configExists, configPath };
|
||||
|
||||
@@ -8,8 +8,6 @@ var os = require('os');
|
||||
let timeoutID = null;
|
||||
|
||||
function send(request) {
|
||||
const url = "https://servers.fmdx.org/api/";
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -19,7 +17,7 @@ function send(request) {
|
||||
body: JSON.stringify(request)
|
||||
};
|
||||
|
||||
fetch(url, options)
|
||||
fetch("https://servers.fmdx.org/api/", options)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.token) {
|
||||
@@ -83,4 +81,4 @@ function update() {
|
||||
timeoutID = setInterval(sendKeepalive, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports.update = update;
|
||||
module.exports = update;
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
let connectedUsers = [];
|
||||
let chatHistory = [];
|
||||
let websocket_delegation = new Map();
|
||||
var ctl_output;
|
||||
var tx_search_hook = {};
|
||||
|
||||
module.exports = { connectedUsers, chatHistory, websocket_delegation };
|
||||
module.exports = { connectedUsers, chatHistory, websocket_delegation, ctl_output, tx_search_hook };
|
||||
@@ -1,20 +0,0 @@
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
function checkFFmpeg() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkFFmpegProcess = spawn('ffmpeg', ['-version'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
|
||||
checkFFmpegProcess.on('error', () => {
|
||||
resolve(require('ffmpeg-static'));
|
||||
});
|
||||
|
||||
checkFFmpegProcess.on('exit', (code) => {
|
||||
if (code === 0) resolve('ffmpeg');
|
||||
else resolve(require('ffmpeg-static'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = checkFFmpeg;
|
||||
+32
-20
@@ -1,8 +1,29 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { serverConfig } = require('../server_config');
|
||||
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console');
|
||||
const checkFFmpeg = require('./checkFFmpeg');
|
||||
const { PassThrough } = require('stream');
|
||||
const audio_pipe = new PassThrough();
|
||||
module.exports = audio_pipe; // Important
|
||||
|
||||
const { serverConfig, configExists } = require('../server_config');
|
||||
if (!configExists() || !serverConfig.audio.audioDevice) return;
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../console');
|
||||
|
||||
const checkFFmpeg = function() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkFFmpegProcess = spawn('ffmpeg', ['-version'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
|
||||
checkFFmpegProcess.on('error', () => {
|
||||
resolve(require('ffmpeg-static'));
|
||||
});
|
||||
|
||||
checkFFmpegProcess.on('exit', (code) => {
|
||||
if (code === 0) resolve('ffmpeg');
|
||||
else resolve(require('ffmpeg-static'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const consoleLogTitle = '[Audio Stream]';
|
||||
|
||||
@@ -15,14 +36,11 @@ function connectMessage(message) {
|
||||
}
|
||||
}
|
||||
|
||||
const audio_pipe = new PassThrough();
|
||||
|
||||
checkFFmpeg().then((ffmpegPath) => {
|
||||
logInfo(`${consoleLogTitle} Using ${ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'}`);
|
||||
logInfo(`${consoleLogTitle} Starting audio stream on device: \x1b[35m${serverConfig.audio.audioDevice}\x1b[0m`);
|
||||
|
||||
const sampleRate = Number(serverConfig.audio.sampleRate || 44100) + Number(serverConfig.audio.samplerateOffset || 0); // Maybe even do 32 khz, we do not need higher than 15 khz precision
|
||||
|
||||
const sampleRate = Number(serverConfig.audio.sampleRate || 32000);
|
||||
const channels = Number(serverConfig.audio.audioChannels || 2);
|
||||
|
||||
let ffmpeg = null;
|
||||
@@ -41,14 +59,14 @@ checkFFmpeg().then((ffmpegPath) => {
|
||||
else inputArgs = ["-f", "alsa", "-i", device];
|
||||
|
||||
return [
|
||||
"-fflags", "+nobuffer",
|
||||
"-fflags", "+nobuffer+flush_packets",
|
||||
"-flags", "low_delay",
|
||||
"-rtbufsize", "6144",
|
||||
"-probesize", "256",
|
||||
"-rtbufsize", "2048",
|
||||
"-probesize", "128",
|
||||
|
||||
...inputArgs,
|
||||
|
||||
"-thread_queue_size", "2048",
|
||||
"-thread_queue_size", "1024",
|
||||
"-ar", String(sampleRate),
|
||||
"-ac", String(channels),
|
||||
|
||||
@@ -62,7 +80,7 @@ checkFFmpeg().then((ffmpegPath) => {
|
||||
"-id3v2_version", "0",
|
||||
|
||||
"-fflags", "+nobuffer",
|
||||
// "-flush_packets", "1",
|
||||
"-flush_packets", "1",
|
||||
|
||||
"pipe:1"
|
||||
];
|
||||
@@ -77,11 +95,8 @@ checkFFmpeg().then((ffmpegPath) => {
|
||||
|
||||
ffmpeg.stdout.pipe(audio_pipe, { end: false });
|
||||
|
||||
connectMessage(`${consoleLogTitle} Connected FFmpeg → MP3 → audioWss`);
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
logFfmpeg(`[FFmpeg stderr]: ${msg}`);
|
||||
|
||||
// Detect frozen timestamps
|
||||
const match = msg.match(/time=(\d\d):(\d\d):(\d\d\.\d+)/);
|
||||
@@ -134,9 +149,6 @@ checkFFmpeg().then((ffmpegPath) => {
|
||||
});
|
||||
|
||||
launchFFmpeg();
|
||||
|
||||
}).catch((err) => {
|
||||
logError(`${consoleLogTitle} Error: ${err.message}`);
|
||||
});
|
||||
|
||||
module.exports = audio_pipe;
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const exec = require('child_process').exec;
|
||||
const fs = require('fs').promises; // Use the Promise-based fs API
|
||||
const ffmpeg = require('ffmpeg-static');
|
||||
const filePath = '/proc/asound/cards';
|
||||
const platform = process.platform;
|
||||
|
||||
function parseAudioDevice(options, callback) {
|
||||
let videoDevices = [];
|
||||
let audioDevices = [];
|
||||
let isVideo = true;
|
||||
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = null;
|
||||
}
|
||||
options = options || {};
|
||||
const ffmpegPath = `"${ffmpeg.replace(/\\/g, '\\\\')}"`;
|
||||
const callbackExists = typeof callback === 'function';
|
||||
|
||||
const execute = async (fulfill, reject) => {
|
||||
try {
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const regex = /\[([^\]]+)\]/g;
|
||||
const matches = (data.match(regex) || []).map(match => 'hw:' + match.replace(/\s+/g, '').slice(1, -1));
|
||||
|
||||
matches.forEach(match => {
|
||||
if (typeof match === 'string') audioDevices.push({ name: match });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error reading file: ${err.message}`);
|
||||
}
|
||||
|
||||
// Linux doesn't support the `-list_devices` ffmpeg command like macOS/Windows,
|
||||
// so skip the ffmpeg exec for Linux
|
||||
const result = { videoDevices: [], audioDevices };
|
||||
if (callbackExists) return callback(result);
|
||||
return fulfill(result);
|
||||
}
|
||||
|
||||
let inputDevice, prefix, audioSeparator, alternativeName, deviceParams;
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
inputDevice = 'dshow';
|
||||
prefix = /\[dshow/;
|
||||
audioSeparator = /DirectShow\saudio\sdevices/;
|
||||
alternativeName = /Alternative\sname\s*?"(.*?)"/;
|
||||
deviceParams = /"(.*?)"/;
|
||||
break;
|
||||
case 'darwin':
|
||||
inputDevice = 'avfoundation';
|
||||
prefix = /^\[AVFoundation/;
|
||||
audioSeparator = /AVFoundation\saudio\sdevices/;
|
||||
deviceParams = /^\[AVFoundation.*?]\s\[(\d+)]\s(.*)$/;
|
||||
break;
|
||||
}
|
||||
|
||||
exec(`${ffmpegPath} -f ${inputDevice} -list_devices true -i ""`, (err, stdout, stderr) => {
|
||||
stderr.split("\n")
|
||||
.filter(line => line.search(prefix) > -1)
|
||||
.forEach(line => {
|
||||
const deviceList = isVideo ? videoDevices : audioDevices;
|
||||
if (line.search(audioSeparator) > -1) {
|
||||
isVideo = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform === 'win32' && line.search(/Alternative\sname/) > -1) {
|
||||
const lastDevice = deviceList[deviceList.length - 1];
|
||||
const alt = line.match(alternativeName);
|
||||
if (lastDevice && alt) lastDevice.alternativeName = alt[1];
|
||||
return;
|
||||
}
|
||||
|
||||
const params = line.match(deviceParams);
|
||||
if (params) {
|
||||
let device;
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
device = { name: params[1] };
|
||||
break;
|
||||
case 'darwin':
|
||||
device = { id: parseInt(params[1]), name: params[2] };
|
||||
break;
|
||||
}
|
||||
deviceList.push(device);
|
||||
}
|
||||
});
|
||||
|
||||
audioDevices = audioDevices.filter(device => device.name !== undefined);
|
||||
const result = { videoDevices, audioDevices };
|
||||
if (callbackExists) return callback(result);
|
||||
return fulfill(result);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Unexpected error:', err);
|
||||
if (callbackExists) callback({ videoDevices: [], audioDevices: [] });
|
||||
else reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (callbackExists) execute();
|
||||
else return new Promise(execute);
|
||||
}
|
||||
|
||||
module.exports = { parseAudioDevice };
|
||||
+3
-15
@@ -1,19 +1,9 @@
|
||||
const WebSocket = require('ws');
|
||||
const { serverConfig } = require('../server_config');
|
||||
const audio_pipe = require('./index.js');
|
||||
const { getIpAddress } = require("../helpers.js")
|
||||
const storage = require('../storage');
|
||||
|
||||
const audioWss = new WebSocket.Server({ noServer: true, skipUTF8Validation: true });
|
||||
|
||||
audioWss.on('connection', (ws, request) => {
|
||||
const clientIp = getIpAddress(request);
|
||||
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
audio_pipe.on('data', (chunk) => {
|
||||
audioWss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(chunk, {binary: true, compress: false});
|
||||
@@ -21,9 +11,7 @@ audio_pipe.on('data', (chunk) => {
|
||||
});
|
||||
|
||||
audio_pipe.on('end', () => {
|
||||
audioWss.clients.forEach((client) => {
|
||||
client.close(1001, "Audio stream ended");
|
||||
});
|
||||
audioWss.clients.forEach((client) => client.close(1001, "Audio stream ended"));
|
||||
});
|
||||
|
||||
module.exports = audioWss;
|
||||
storage.websocket_delegation.set("/audio", audioWss);
|
||||
@@ -1,4 +1,4 @@
|
||||
const tunerProfiles = [
|
||||
module.exports = [
|
||||
{
|
||||
id: 'tef',
|
||||
label: 'TEF668x',
|
||||
@@ -19,8 +19,7 @@ const tunerProfiles = [
|
||||
{ value: 254000, label: '254 kHz' },
|
||||
{ value: 287000, label: '287 kHz' },
|
||||
{ value: 311000, label: '311 kHz' }
|
||||
],
|
||||
details: ''
|
||||
], details: ''
|
||||
},
|
||||
{
|
||||
id: 'xdr',
|
||||
@@ -43,12 +42,11 @@ const tunerProfiles = [
|
||||
{ value: 281000, value2: 13, label: '281 kHz' },
|
||||
{ value: 298000, value2: 14, label: '298 kHz' },
|
||||
{ value: 309000, value2: 15, label: '309 kHz' }
|
||||
],
|
||||
details: ''
|
||||
], details: ''
|
||||
},
|
||||
{
|
||||
id: 'sdr',
|
||||
label: 'SDR (RTL-SDR / AirSpy)',
|
||||
label: 'SDR',
|
||||
fmBandwidths: [
|
||||
{ value: 0, label: 'Auto' },
|
||||
{ value: 4000, label: '4 kHz' },
|
||||
@@ -64,21 +62,17 @@ const tunerProfiles = [
|
||||
{ value: 175000, label: '175 kHz' },
|
||||
{ value: 200000, label: '200 kHz' },
|
||||
{ value: 225000, label: '225 kHz' }
|
||||
],
|
||||
details: ''
|
||||
], details: ''
|
||||
},
|
||||
{
|
||||
id: 'si47xx',
|
||||
label: 'Si47XX (Si4735 / Si4732)',
|
||||
label: 'Si47xx (Si4735 / Si4732)',
|
||||
fmBandwidths: [
|
||||
{ value: 0, label: 'Auto' },
|
||||
{ value: 40000, label: '40 kHz' },
|
||||
{ value: 60000, label: '60 kHz' },
|
||||
{ value: 84000, label: '84 kHz' },
|
||||
{ value: 110000, label: '110 kHz' }
|
||||
],
|
||||
details: ''
|
||||
], details: ''
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = tunerProfiles;
|
||||
@@ -1,103 +0,0 @@
|
||||
const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('./console');
|
||||
const { serverConfig } = require('./server_config');
|
||||
const { Readable } = require('stream');
|
||||
const { finished } = require('stream/promises');
|
||||
const fs = require('fs/promises');
|
||||
const fs2 = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const ejs = require('ejs');
|
||||
const { spawn } = require('child_process');
|
||||
const readline = require('readline');
|
||||
|
||||
const fileExists = path => new Promise(resolve => fs.access(path, fs.constants.F_OK).then(() => resolve(true)).catch(() => resolve(false)));
|
||||
|
||||
async function download() {
|
||||
if (serverConfig.tunnel?.enabled === true) {
|
||||
const librariesDir = path.resolve(__dirname, '../libraries');
|
||||
if (!await fileExists(librariesDir)) await fs.mkdir(librariesDir);
|
||||
const frpcPath = path.resolve(librariesDir, 'frpc' + (os.platform() === 'win32' ? '.exe' : ''));
|
||||
if (!await fileExists(frpcPath)) {
|
||||
logInfo('frpc binary, required for tunnel is not available. Downloading now...');
|
||||
const frpcFileName = `frpc_${os.platform}_${os.arch}` + (os.platform() === 'win32' ? '.exe' : '');
|
||||
|
||||
try {
|
||||
const res = await fetch('https://fmtuner.org/binaries/' + frpcFileName);
|
||||
if (res.status === 404) throw new Error('404 error');
|
||||
const stream = fs2.createWriteStream(frpcPath);
|
||||
await finished(Readable.fromWeb(res.body).pipe(stream));
|
||||
} catch (err) {
|
||||
logError('Failed to download frpc, reason: ' + err);
|
||||
return;
|
||||
}
|
||||
logInfo('Downloading of frpc is completed.')
|
||||
if (os.platform() === 'linux' || os.platform() === 'darwin') await fs.chmod(frpcPath, 0o770);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (serverConfig.tunnel?.enabled === true) {
|
||||
const librariesDir = path.resolve(__dirname, '../libraries');
|
||||
const frpcPath = path.resolve(librariesDir, 'frpc' + (os.platform() === 'win32' ? '.exe' : ''));
|
||||
|
||||
const cfg = ejs.render(frpcConfigTemplate, {
|
||||
cfg: serverConfig.tunnel,
|
||||
host: serverConfig.tunnel.community.enabled ? serverConfig.tunnel.community.host : ((serverConfig.tunnel.region == "pldx") ? "pldx.duckdns.org" : (serverConfig.tunnel.region + ".fmtuner.org")),
|
||||
server: {
|
||||
port: serverConfig.webserver.webserverPort
|
||||
}
|
||||
});
|
||||
const cfgPath = path.resolve(librariesDir, 'frpc.toml');
|
||||
await fs.writeFile(cfgPath, cfg);
|
||||
const child = spawn(frpcPath, ['-c', cfgPath]);
|
||||
process.on('exit', () => {
|
||||
child.kill();
|
||||
});
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
rl.on('line', (line) => {
|
||||
if (line.includes('connect to server error')) logError('Failed to connect to tunnel, reason: ' + line.substring(line.indexOf(': ')+2));
|
||||
else if (line.includes('invalid user or token')) logError('Failed to connect to tunnel, reason: invalid user or token');
|
||||
else if (line.includes('start proxy success')) logInfo('Tunnel established successfully');
|
||||
else if (line.includes('login to server success')) logInfo('Connection to tunnel server was successful');
|
||||
else logDebug('Tunnel log:', line);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
logError('Failed to start tunnel process:', err);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
logInfo(`Tunnel process exited with code ${code}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const frpcConfigTemplate = `
|
||||
serverAddr = "<%= host %>"
|
||||
serverPort = 7000
|
||||
loginFailExit = false
|
||||
log.disablePrintColor = true
|
||||
user = "<%= cfg.username %>"
|
||||
metadatas.token = "<%= cfg.token %>"
|
||||
<% if (cfg.lowLatencyMode) { %>
|
||||
transport.protocol = "kcp"
|
||||
<% } %>
|
||||
|
||||
[[proxies]]
|
||||
name = "web"
|
||||
type = "http"
|
||||
localPort = <%= server.port %>
|
||||
subdomain = "<%= cfg.subdomain %>"
|
||||
<% if (cfg.httpName != "") { %>
|
||||
httpUser = "<%= cfg.httpName %>"
|
||||
httpPassword = "<%= cfg.httpPassword %>"
|
||||
<% } %>
|
||||
`;
|
||||
|
||||
module.exports = { connect, download};
|
||||
+13
-14
@@ -1,10 +1,13 @@
|
||||
const { serverConfig, configExists } = require('./server_config');
|
||||
if(!configExists()) return;
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const { serverConfig } = require('./server_config');
|
||||
const consoleCmd = require('./console');
|
||||
const storage = require('./storage');
|
||||
|
||||
let localDb = {};
|
||||
let nextLocalDbUpdate = 0;
|
||||
const localDbUpdateInterval = 7 * 24 * 60 * 60 * 1000; // 7-day database update interval
|
||||
const localDbUpdateInterval = 3 * 24 * 60 * 60 * 1000; // 3-day database update interval
|
||||
let awaitingTxInfo = true;
|
||||
let lastFetchTime = 0;
|
||||
let piFreqIndex = {}; // Indexing for speedier PI+Freq combinations
|
||||
@@ -18,11 +21,6 @@ let usStatesGeoJson = null; // To cache the GeoJSON data for US states
|
||||
let Latitude = serverConfig.identification.lat;
|
||||
let Longitude = serverConfig.identification.lon;
|
||||
|
||||
// Create WebSocket URL for GPS lat/lon update.
|
||||
const webserverPort = serverConfig.webserver.webserverPort || 8080; // Fallback to port 8080
|
||||
const externalWsUrl = `ws://127.0.0.1:${webserverPort}/data_plugins`;
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// Get weighting values based on algorithm setting.
|
||||
// Defaults = algorithm 1
|
||||
let weightedErp = 10;
|
||||
@@ -159,10 +157,10 @@ function validPsCompare(rdsPs, stationPs) {
|
||||
|
||||
// Standardize the rdsPs string: replace spaces with underscores and convert to lowercase.
|
||||
const standardizedRdsPs = rdsPs.replace(/ /g, '_').toLowerCase();
|
||||
|
||||
|
||||
// Split stationPs into tokens (e.g., "__mdr___ _kultur_" -> ["__mdr___", "_kultur_"])
|
||||
const psTokens = stationPs.split(/\s+/).filter(token => token.length > 0).map(token => { const lower = token.toLowerCase(); return lower.length < 8 ? lower.padEnd(8, '_') : lower; });
|
||||
|
||||
|
||||
// Iterate through all tokens and check if any token yields at least three valid (non "_" ) matches.
|
||||
for (let token of psTokens) {
|
||||
// If total non "_" length of token is less than 3, allow match based on that length instead
|
||||
@@ -170,7 +168,7 @@ function validPsCompare(rdsPs, stationPs) {
|
||||
const minMatchLen = tokenLength > 2 ? 3 : tokenLength;
|
||||
// If the token's length does not match the standardized rdsPs length, skip this token.
|
||||
if (token.length !== standardizedRdsPs.length) continue;
|
||||
|
||||
|
||||
let matchCount = 0;
|
||||
for (let i = 0; i < standardizedRdsPs.length; i++) {
|
||||
// Skip this position if the character in standardizedRdsPs is an underscore.
|
||||
@@ -220,7 +218,8 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
if (serverConfig.webserver.rdsMode === true) await loadUsStatesGeoJson();
|
||||
|
||||
const key = `${freq}|${piCode.toUpperCase()}`;
|
||||
let rawMatches = piFreqIndex[key] || [];
|
||||
var rawMatches = piFreqIndex[key] || [];
|
||||
rawMatches = [...(storage.tx_search_hook[key] || []), ...rawMatches];
|
||||
|
||||
// Format the results into the same structure as before
|
||||
let filteredLocations = rawMatches.map(({ station, ...locData }) => ({
|
||||
@@ -239,7 +238,7 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
if (filteredLocations.length > 1) {
|
||||
const extraFilteredLocations = filteredLocations.map(locData => ({
|
||||
...locData,
|
||||
stations: locData.stations.filter(station =>
|
||||
stations: locData.stations.filter(station =>
|
||||
station.ps?.toLowerCase().includes(
|
||||
rdsPs.replace(/ /g, '_').toLowerCase()
|
||||
) ?? false
|
||||
@@ -248,7 +247,7 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
|
||||
if (extraFilteredLocations.length > 0) filteredLocations = extraFilteredLocations;
|
||||
}
|
||||
|
||||
|
||||
for (let loc of filteredLocations) {
|
||||
loc = Object.assign(loc, loc.stations[0]);
|
||||
delete loc.stations;
|
||||
@@ -256,7 +255,7 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
loc = Object.assign(loc, dist);
|
||||
loc.detectedByPireg = (loc.pireg === piCode.toUpperCase());
|
||||
}
|
||||
|
||||
|
||||
if (filteredLocations.length > 1) {
|
||||
// Check for any 10kW+ stations within 700km, and don't Es weight if any found.
|
||||
const tropoPriority = filteredLocations.some(loc => loc.distanceKm < 700 && loc.erp >= 10);
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const storage = require('./storage.js');
|
||||
const { serverConfig, configExists } = require('./server_config');
|
||||
const path = require('path');
|
||||
const endpoints = require('./endpoints');
|
||||
const pluginsApi = require('./plugins_api');
|
||||
const { logError, logInfo, logWarn } = require('./console');
|
||||
const { send_to_xdr } = require("./xdr_server")
|
||||
const helpers = require('./helpers');
|
||||
const dataHandler = require('./datahandler');
|
||||
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const bodyParser = require('body-parser');
|
||||
const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
const sessionMiddleware = session({
|
||||
secret: 'GTce3tN6U8odMwoI', // Cool
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
});
|
||||
app.use(sessionMiddleware);
|
||||
app.use(bodyParser.json());
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, '../views'));
|
||||
app.use('/', endpoints);
|
||||
app.use(express.static(path.join(__dirname, '../web')));
|
||||
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const rdsWss = new WebSocket.Server({ noServer: true });
|
||||
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
|
||||
pluginsApi.registerServerContext({ wss, pluginsWss, httpServer, serverConfig });
|
||||
|
||||
require('./chat');
|
||||
|
||||
const tunerLockTracker = new WeakMap();
|
||||
const ipConnectionCounts = new Map(); // Per-IP limit variables
|
||||
const ipLogTimestamps = new Map();
|
||||
const MAX_CONNECTIONS_PER_IP = 4;
|
||||
const IP_LOG_INTERVAL_MS = 60000;
|
||||
const MAX_USERS = 12; // Server max
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [ip, count] of ipConnectionCounts.entries()) {
|
||||
const lastSeen = ipLogTimestamps.get(ip) || 0;
|
||||
const inactive = now - lastSeen > 60 * 60 * 1000;
|
||||
|
||||
if (count === 0 && inactive) {
|
||||
ipConnectionCounts.delete(ip);
|
||||
ipLogTimestamps.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 30 * 60 * 1000); // Run every half hour (this is a stop sign ahh comment, i wrote it lol)
|
||||
|
||||
let currentUsers = 0;
|
||||
let timeoutAntenna;
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
let clientIp = helpers.getIpAddress(request);
|
||||
const userCommandHistory = {};
|
||||
|
||||
// Per-IP limit connection open
|
||||
if (clientIp) {
|
||||
if (!ipConnectionCounts.has(clientIp)) ipConnectionCounts.set(clientIp, 0);
|
||||
const currentCount = ipConnectionCounts.get(clientIp);
|
||||
if (currentCount >= MAX_CONNECTIONS_PER_IP) {
|
||||
ws.close(1008, 'Too many open connections from this IP');
|
||||
const lastLogTime = ipLogTimestamps.get(clientIp) || 0;
|
||||
const now = Date.now();
|
||||
if (now - lastLogTime > IP_LOG_INTERVAL_MS) {
|
||||
logWarn(`Web client \x1b[31mclosed: limit exceeded\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
|
||||
ipLogTimestamps.set(clientIp, now);
|
||||
} return;
|
||||
} ipConnectionCounts.set(clientIp, currentCount + 1);
|
||||
}
|
||||
|
||||
if(currentUsers >= MAX_USERS) {
|
||||
ws.send("a0"); // This means not authenticated in XDR
|
||||
ws.close(1008, 'Too many users using this server at the moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientIp !== '127.0.0.1') currentUsers++;
|
||||
|
||||
if (timeoutAntenna) clearTimeout(timeoutAntenna);
|
||||
|
||||
helpers.handleConnect(clientIp, currentUsers, ws, request, (result) => {
|
||||
if (result === "User banned") {
|
||||
ws.close(1008, 'Banned IP');
|
||||
return;
|
||||
}
|
||||
dataHandler.showOnlineUsers(currentUsers);
|
||||
|
||||
if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) serverConfig.xdrd.wirelessConnection ? connectToXdrd() : storage.ctl_output.write('x\n');
|
||||
});
|
||||
|
||||
const userCommands = {};
|
||||
let lastWarn = { time: 0 };
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text', 16 * 1024);
|
||||
if(!command) return;
|
||||
|
||||
if(command.startsWith("PING")) {
|
||||
ws.send(command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientIp.includes("127.0.0.1")) {
|
||||
if (((command.startsWith('X') || command.startsWith('Y')) && !helpers.isAdmin(req)) ||
|
||||
((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) {
|
||||
logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command: ${command.slice(0, 64)}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.includes("\'")) return;
|
||||
|
||||
const isAdminAuthenticated = helpers.isAdmin(request)
|
||||
const { isTuneAuthenticated } = request.session || {};
|
||||
|
||||
if (command.startsWith('w') && (isAdminAuthenticated || isTuneAuthenticated)) {
|
||||
switch (command) {
|
||||
case 'wL1':
|
||||
if (isAdminAuthenticated) {
|
||||
serverConfig.lockToAdmin = true;
|
||||
send_to_xdr("wL1\n");
|
||||
} break;
|
||||
case 'wL0':
|
||||
if (isAdminAuthenticated) {
|
||||
serverConfig.lockToAdmin = false;
|
||||
send_to_xdr("wL0\n");
|
||||
} break;
|
||||
case 'wT0':
|
||||
serverConfig.publicTuner = true;
|
||||
if(!isAdminAuthenticated) tunerLockTracker.delete(ws);
|
||||
send_to_xdr("wT0\n");
|
||||
break;
|
||||
case 'wT1':
|
||||
serverConfig.publicTuner = false;
|
||||
if(!isAdminAuthenticated) tunerLockTracker.set(ws, true);
|
||||
send_to_xdr("wT1\n");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (command.startsWith('T')) {
|
||||
const tuneFreq = Number(command.slice(1)) / 1000;
|
||||
const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver;
|
||||
|
||||
if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) return;
|
||||
}
|
||||
|
||||
if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) storage.ctl_output.write(`${command}\n`);
|
||||
});
|
||||
|
||||
ws.on('close', (code) => {
|
||||
// Per-IP limit connection closed
|
||||
if (clientIp) {
|
||||
const current = ipConnectionCounts.get(clientIp) || 1;
|
||||
ipConnectionCounts.set(clientIp, Math.max(0, current - 1));
|
||||
}
|
||||
|
||||
if (clientIp !== '127.0.0.1') currentUsers--;
|
||||
dataHandler.showOnlineUsers(currentUsers);
|
||||
|
||||
const index = storage.connectedUsers.findIndex(user => user.ip === clientIp);
|
||||
if (index !== -1) storage.connectedUsers.splice(index, 1);
|
||||
|
||||
if (currentUsers === 0) {
|
||||
storage.connectedUsers = [];
|
||||
|
||||
if (serverConfig.bwAutoNoUsers === "1") storage.ctl_output.write("W0\n"); // Auto BW 'Enabled'
|
||||
|
||||
// cEQ and iMS combinations
|
||||
if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") storage.ctl_output.write("G00\n"); // Both Disabled
|
||||
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") storage.ctl_output.write(`G0${dataHandler.dataToSend.ims}\n`);
|
||||
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") storage.ctl_output.write(`G${dataHandler.dataToSend.eq}0\n`);
|
||||
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") storage.ctl_output.write(`G1${dataHandler.dataToSend.ims}\n`);
|
||||
else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") storage.ctl_output.write(`G${dataHandler.dataToSend.eq}1\n`);
|
||||
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") storage.ctl_output.write("G10\n"); // Only cEQ enabled
|
||||
else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") storage.ctl_output.write("G01\n"); // Only iMS enabled
|
||||
else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") storage.ctl_output.write("G11\n"); // Both Enabled
|
||||
|
||||
// Handle stereo mode
|
||||
if (serverConfig.stereoNoUsers === "1") storage.ctl_output.write("B0\n");
|
||||
else if (serverConfig.stereoNoUsers === "2") storage.ctl_output.write("B1\n");
|
||||
|
||||
// Handle Antenna selection
|
||||
if (timeoutAntenna) clearTimeout(timeoutAntenna);
|
||||
timeoutAntenna = setTimeout(() => {
|
||||
if (serverConfig.antennaNoUsers === "1") storage.ctl_output.write("Z0\n");
|
||||
else if (serverConfig.antennaNoUsers === "2") storage.ctl_output.write("Z1\n");
|
||||
else if (serverConfig.antennaNoUsers === "3") storage.ctl_output.write("Z2\n");
|
||||
else if (serverConfig.antennaNoUsers === "4") storage.ctl_output.write("Z3\n");
|
||||
}, serverConfig.antennaNoUsersDelay ? 15000 : 0);
|
||||
}
|
||||
|
||||
if (tunerLockTracker.has(ws)) {
|
||||
logInfo(`User who locked the tuner left. Unlocking the tuner.`);
|
||||
tunerLockTracker.delete(ws);
|
||||
serverConfig.publicTuner = true;
|
||||
}
|
||||
|
||||
if (currentUsers === 0 && serverConfig.enableDefaultFreq === true &&
|
||||
serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) {
|
||||
setTimeout(function() {
|
||||
if (currentUsers === 0) {
|
||||
storage.ctl_output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n');
|
||||
dataHandler.resetToDefault(dataHandler.dataToSend);
|
||||
dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) client.write('X\n');
|
||||
|
||||
if (code !== 1008) logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`);
|
||||
});
|
||||
|
||||
ws.on('error', console.error);
|
||||
});
|
||||
|
||||
// Additional web socket for using plugins
|
||||
pluginsWss.on('connection', (ws, request) => {
|
||||
const clientIp = helpers.getIpAddress(request);
|
||||
const userCommandHistory = {};
|
||||
// Anti-spam tracking for each client
|
||||
const userCommands = {};
|
||||
let lastWarn = { time: 0 };
|
||||
|
||||
ws.on('message', message => {
|
||||
// Anti-spam
|
||||
const msg2 = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins', 1024 * 1024 * 2);
|
||||
if(!msg2) return;
|
||||
|
||||
try {
|
||||
let messageData = JSON.parse(msg2); // Attempt to parse the JSON. This code will attemp to parse json using the JSON.parse which coincidentally actually parses the json
|
||||
|
||||
if (messageData.type === "GPS" && messageData.value) {
|
||||
const gpsData = messageData.value;
|
||||
const { status, time, lat, lon, alt, mode } = gpsData;
|
||||
|
||||
if (status === "active") {
|
||||
Latitude = parseFloat(lat);
|
||||
Longitude = parseFloat(lon);
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
// Broadcast the message to all other clients
|
||||
pluginsWss.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(message); // Send the message to all clients
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('error', error => logError('WebSocket Extra error: ' + error));
|
||||
});
|
||||
|
||||
storage.websocket_delegation.set("/text", wss);
|
||||
storage.websocket_delegation.set("/rds", rdsWss);
|
||||
storage.websocket_delegation.set("/rdsspy", rdsWss);
|
||||
storage.websocket_delegation.set("/data_plugins", pluginsWss);
|
||||
require('./stream/ws.js');
|
||||
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
if (serverConfig.webserver.banlist?.includes(helpers.getIpAddress(request))) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const upgradeWss = storage.websocket_delegation.get(request.url);
|
||||
if(upgradeWss) {
|
||||
sessionMiddleware(request, {}, () => {
|
||||
// Sure want to, but no
|
||||
upgradeWss.handleUpgrade(request, socket, head, (ws) => upgradeWss.emit('connection', ws, request));
|
||||
});
|
||||
} else socket.destroy();
|
||||
});
|
||||
|
||||
const logServerStart = (address) => logInfo(`Web server has started on address \x1b[34mhttp://${address}:${serverConfig.webserver.webserverPort}\x1b[0m.`);
|
||||
|
||||
const startServer = (address) => {
|
||||
httpServer.listen(serverConfig.webserver.webserverPort, address, () => {
|
||||
if (!configExists()) {
|
||||
logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${serverConfig.webserver.webserverPort}\x1b[0m to continue with setup.`);
|
||||
logInfo("Remember to restart afterwards!");
|
||||
} else logServerStart(address);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = startServer;
|
||||
@@ -0,0 +1,118 @@
|
||||
const storage = require('./storage');
|
||||
const WebSocket = require('ws');
|
||||
const crypto = require('crypto');
|
||||
const xdr = new WebSocket.Server({ noServer: true });
|
||||
const { serverConfig } = require('./server_config');
|
||||
|
||||
let currentUsers = 0;
|
||||
let clients = []
|
||||
|
||||
function send_to_xdr(data) {
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(data);
|
||||
});
|
||||
}
|
||||
|
||||
function send_xdr_online(fmusers) {
|
||||
send_to_xdr(`o${currentUsers},${fmusers}\n`); // First value is normal users, and the second is the guest users, XDR-GTK displays them either as `2 users` or `2 ( + 1) users` if the guest number is non zero
|
||||
}
|
||||
|
||||
function randomString(length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function xdr_auth(ws, salt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const expected = crypto.createHash('sha1')
|
||||
.update(salt + serverConfig.password.adminPass).digest('hex');
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Auth timeout'));
|
||||
}, 10_000);
|
||||
|
||||
ws.once('message', (message) => {
|
||||
clearTimeout(timeout);
|
||||
const received = message.toString().trim();
|
||||
if (received === expected) resolve();
|
||||
else reject(new Error('Invalid credentials'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
xdr.on('connection', async (ws) => {
|
||||
const { initialData } = require('./datahandler');
|
||||
|
||||
const salt = randomString(16); // 16 characters are needed, because XDR-GTK does not wait for the new line, but rather the 16 characters with a new line
|
||||
ws.send(`${salt}\n`);
|
||||
|
||||
try {
|
||||
await xdr_auth(ws, salt);
|
||||
} catch (err) {
|
||||
ws.send("a0\n"); // a0 is sent when you have not authenticated
|
||||
// a1 is sent when you also didnt authenticate, but you're a guest now! and not completly kicked out. im giving these comments because i think sjef is reading this, and he got the protocol VERY WRONG in his plugin for xdrgtk
|
||||
ws.close(1008, err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ws.send(`OK\n`)
|
||||
currentUsers++;
|
||||
send_xdr_online(initialData.users); // Broadcast
|
||||
ws.send(`T${initialData.freq * 1000}\n`);
|
||||
ws.send(`G${initialData.eq}${initialData.ims}\n`);
|
||||
ws.send(`Z${initialData.ant}\n`);
|
||||
ws.send(`A${initialData.agc}\n`);
|
||||
ws.send(`F${initialData.bw}\n`);
|
||||
ws.send(`W${initialData.bw}\n`);
|
||||
ws.send(`wL${serverConfig.lockToAdmin ? "1" : "0"}\n`);
|
||||
ws.send(`wT${serverConfig.publicTuner ? "0" : "1"}\n`);
|
||||
ws.send(`OK\n`)
|
||||
clients.push(ws);
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const data = message.toString();
|
||||
|
||||
if(data.startsWith("w")) {
|
||||
switch(data.trim()) {
|
||||
case "wL1":
|
||||
serverConfig.lockToAdmin = true;
|
||||
send_to_xdr("wL1\n")
|
||||
return;
|
||||
case "wL0":
|
||||
serverConfig.lockToAdmin = false;
|
||||
send_to_xdr("wL0\n")
|
||||
return;
|
||||
case "wT1":
|
||||
serverConfig.publicTuner = false;
|
||||
send_to_xdr("wT1\n")
|
||||
return;
|
||||
case "wT0":
|
||||
serverConfig.publicTuner = true;
|
||||
send_to_xdr("wT0\n")
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!storage.ctl_output.write(data)) {
|
||||
ws.pause();
|
||||
storage.ctl_output.once('drain', () => ws.resume());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
currentUsers--;
|
||||
send_xdr_online(initialData.users);
|
||||
|
||||
clients = clients.filter(client => client !== ws);
|
||||
});
|
||||
});
|
||||
|
||||
storage.websocket_delegation.set("/xdr", xdr);
|
||||
storage.websocket_delegation.set("/xdrgtk", xdr);
|
||||
|
||||
module.exports = { send_to_xdr, send_xdr_online };
|
||||
@@ -0,0 +1,23 @@
|
||||
# Template
|
||||
|
||||
```
|
||||
serverAddr = "<%= host %>"
|
||||
serverPort = 7000
|
||||
loginFailExit = false
|
||||
log.disablePrintColor = true
|
||||
user = "<%= cfg.username %>"
|
||||
metadatas.token = "<%= cfg.token %>"
|
||||
<% if (cfg.lowLatencyMode) { %>
|
||||
transport.protocol = "kcp"
|
||||
<% } %>
|
||||
|
||||
[[proxies]]
|
||||
name = "web"
|
||||
type = "http"
|
||||
localPort = <%= server.port %>
|
||||
subdomain = "<%= cfg.subdomain %>"
|
||||
<% if (cfg.httpName != "") { %>
|
||||
httpUser = "<%= cfg.httpName %>"
|
||||
httpPassword = "<%= cfg.httpPassword %>"
|
||||
<% } %>
|
||||
```
|
||||
+4
-5
@@ -15,17 +15,16 @@
|
||||
<div id="wrapper" class="auto">
|
||||
<div class="panel-100 no-bg">
|
||||
<img class="top-10" src="./images/openradio_logo_neutral.png" height="64px">
|
||||
<h2 class="text-monospace text-light text-center">[403]</h2>
|
||||
<h2 class="text-monospace text-light text-center">Forbidden</h2>
|
||||
|
||||
<div class="panel-100 p-10">
|
||||
<br>
|
||||
<i class="text-big fa-solid fa-exclamation-triangle color-4"></i>
|
||||
<p>
|
||||
Service refused..<br>
|
||||
Please try again later.</p>
|
||||
|
||||
<% if (reason) { %>
|
||||
<p><strong>Reason:</strong> <%= reason %></p>
|
||||
<p><%= reason %></p>
|
||||
<% } else { %>
|
||||
<p>Service refused, please try again later.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>FM-DX Webserver (Audio)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link href="css/libs/jquery-ui.min.css" type="text/css" rel="stylesheet">
|
||||
<script src="js/libs/jquery.min.js"></script>
|
||||
<script src="js/libs/jquery-ui.min.js"></script>
|
||||
<script src="js/3las/util/3las.helpers.js"></script>
|
||||
<script src="js/3las/util/3las.logging.js"></script>
|
||||
<script src="js/3las/fallback/3las.liveaudioplayer.js"></script>
|
||||
<script src="js/3las/fallback/3las.formatreader.js"></script>
|
||||
<script src="js/3las/fallback/formats/3las.formatreader.mpeg.js"></script>
|
||||
<script src="js/3las/fallback/formats/3las.formatreader.wav.js"></script>
|
||||
<script src="js/3las/util/3las.websocketclient.js"></script>
|
||||
<script src="js/3las/fallback/3las.fallback.js"></script>
|
||||
<script src="js/3las/3las.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/rtc-audio.js"></script>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #eee;
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playbutton {
|
||||
background-color: #222;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
padding: 10px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.playbutton:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.playbutton:active {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#volumeSlider {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
accent-color: #4af;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fmdx-webrtc-playbutton-host .fmdx-webrtc-playbutton {
|
||||
background-color: #222;
|
||||
color: #eee;
|
||||
height: auto !important;
|
||||
min-height: unset !important;
|
||||
padding: 10px 24px !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hide-phone">
|
||||
<div class="panel-10">
|
||||
<button class="playbutton">
|
||||
<i class="fa fa-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="range" id="volumeSlider" min="0" max="1" step="0.001" value="1">
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
</body>
|
||||
</html>
|
||||
+13
-16
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title><%= tunerName %> - FM-DX Webserver</title>
|
||||
<link href="css/entry.css" type="text/css" rel="stylesheet">
|
||||
<link href="css/flags.min.css" type="text/css" rel="stylesheet">
|
||||
<link href="css/libs/fontawesome.css" type="text/css" rel="stylesheet">
|
||||
<link href="css/libs/jquery-ui.min.css" type="text/css" rel="stylesheet">
|
||||
<link href="css/enchanced_tuning.css" type="text/css" rel="stylesheet">
|
||||
<!--<link href="css/libs/jquery-ui.theme.min.css" type="text/css" rel="stylesheet">-->
|
||||
<script src="js/libs/jquery.min.js"></script>
|
||||
<script src="js/libs/jquery-ui.min.js"></script>
|
||||
@@ -20,16 +20,18 @@
|
||||
|
||||
<meta property="og:title" content="FM-DX WebServer [<%= tunerName %>]">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="https://fmdx.org/img/webserver_icon.png">
|
||||
<meta property="og:image" content="https://data.fmtuner.net/img/webserver_icon.png">
|
||||
<meta property="og:description" content="Server description: <%= tunerDescMeta %>.">
|
||||
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/3las_helper.js"></script>
|
||||
<!-- Audio streaming -->
|
||||
<script src="js/audio/mp3_player.js"></script>
|
||||
<script src="js/audio/main.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', Init, false);
|
||||
document.ontouchmove = function(e) {
|
||||
document.ontouchmove = function(e) { // Why?
|
||||
e.preventDefault();
|
||||
}
|
||||
<script src="js/rtc-audio.js"></script>
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -174,7 +176,7 @@
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-33 hover-brighten tooltip no-bg-phone" id="pi-code-container" data-tooltip="Clicking on the PI code will show the current station on a map.">
|
||||
<h2 class="signal-heading">PI CODE</h2>
|
||||
<h2 class="signal-heading text-uppercase">PI Code</h2>
|
||||
<div class="text-small text-gray highest-signal-container">
|
||||
<span id="data-regular-pi"> </span>
|
||||
</div>
|
||||
@@ -182,12 +184,12 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-33 hover-brighten no-bg-phone" id="freq-container">
|
||||
<h2>FREQUENCY</h2>
|
||||
<h2 class="text-uppercase">Frequency</h2>
|
||||
<span id="data-frequency" class="text-big"></span>
|
||||
</div>
|
||||
|
||||
<div class="panel-33 no-bg-phone">
|
||||
<h2 class="signal-heading">SIGNAL</h2>
|
||||
<h2 class="signal-heading text-uppercase">Signal</h2>
|
||||
<div class="text-small text-gray highest-signal-container">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
<span id="data-signal-highest"></span>
|
||||
@@ -454,7 +456,7 @@
|
||||
|
||||
<div id="myModal" class="modal">
|
||||
<div class="modal-panel">
|
||||
<div class="flex-container flex-phone" style="height: calc(100% - 100px)">
|
||||
<div class="flex-container flex-phone" style="height: calc(100% - 50px)">
|
||||
<div class="modal-panel-sidebar hover-brighten flex-center text-medium-big closeModal" role="button" aria-label="Close settings" tabindex="0"><i class="fa-solid fa-chevron-right"></i></div>
|
||||
<div class="modal-panel-content">
|
||||
<h1 class="top-25">Settings</h1>
|
||||
@@ -480,7 +482,7 @@
|
||||
<%- include('_components', { component: 'dropdown', id: 'signal-selector', inputId: 'signal-selector-input', label: 'Signal units', cssClass: '', placeholder: 'dBf',
|
||||
options: [
|
||||
{ value: 'dbf', label: 'dBf' },
|
||||
{ value: 'dbuv', label: 'dBuV' },
|
||||
{ value: 'dbuv', label: 'dBμV' },
|
||||
{ value: 'dbm', label: 'dBm' },
|
||||
]
|
||||
}) %>
|
||||
@@ -542,17 +544,11 @@
|
||||
</div>
|
||||
<div class="modal-panel-footer flex-container flex-phone">
|
||||
<div class="modal-panel-sidebar" style="font-size: 22px;">
|
||||
<div class="flex-center" style="height: 50px">
|
||||
<i class="fa-solid fa-hand-holding-medical"></i>
|
||||
</div>
|
||||
<div class="flex-center" style="height: 50px">
|
||||
<i class="fa-brands fa-discord"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-panel-content">
|
||||
<div class="hover-brighten br-0 bg-color-1" style="height: 50px;padding:12px;" onclick="window.open('https://buymeacoffee.com/noobish')">
|
||||
<strong>Support</strong> the developer!
|
||||
</div>
|
||||
<div class="hover-brighten br-0 bg-color-1" style="height: 50px;padding:12px;" onclick="window.open('https://discord.gg/cY66nt6UhV')">
|
||||
Join our <strong>FMDX.org Discord</strong> community!
|
||||
</div>
|
||||
@@ -564,6 +560,7 @@
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
<script src="js/websocket.js"></script>
|
||||
<script src="js/webserver.js"></script>
|
||||
<script src="js/enchanced_tuning.js"></script>
|
||||
<% if (!noPlugins) { %>
|
||||
<% plugins?.forEach(function(plugin) { %>
|
||||
<script src="js/plugins/<%= plugin %>"></script>
|
||||
|
||||
+45
-695
@@ -13,552 +13,61 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast-container"></div>
|
||||
<div class="wrapper-outer wrapper-full wrapper-outer-static">
|
||||
<div id="navigation" class="sidenav flex-container flex-phone">
|
||||
<div class="sidenav-content">
|
||||
<h1 class="top-25">Settings</h1>
|
||||
<ul class="nav" role="tablist" style="border-radius: 15px 15px 0 0;">
|
||||
<li role="tab" data-panel="dashboard" aria-selected="true" tabindex="0">
|
||||
<a href="#" role="tab" aria-controls="dashboard"><i class="fa-solid fa-fw fa-chart-line"></i> Dashboard</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="tuner" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="tuner"><i class="fa-solid fa-fw fa-radio"></i> Tuner</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="audio" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="audio"><i class="fa-solid fa-fw fa-volume-high"></i> Audio</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="webserver" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="webserver"><i class="fa-solid fa-fw fa-server"></i> Webserver</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="identification" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="identification"><i class="fa-solid fa-fw fa-circle-info"></i> Identification & Map</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="plugins-tab" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="plugins-tab"><i class="fa-solid fa-fw fa-puzzle-piece"></i> Plugins</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="users" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="users"><i class="fa-solid fa-fw fa-user"></i> User management</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="startup" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="startup"><i class="fa-solid fa-fw fa-plug"></i> Startup</a>
|
||||
</li>
|
||||
<li role="tab" data-panel="extras" tabindex="0">
|
||||
<a href="#" role="tab" tabindex="-1" aria-controls="extras"><i class="fa-solid fa-fw fa-star"></i> Extras</a>
|
||||
</li>
|
||||
<div id="wrapper" class="panel-full">
|
||||
<div class="m-0 no-bg">
|
||||
<h2>Dashboard</h2>
|
||||
|
||||
<div class="admin-quick-dashboard top-25">
|
||||
<div class="icon tooltip" role="button" aria-label="Go back to the main screen" tabindex="0" data-tooltip="Go back to the main screen" onclick="document.location.href='./'">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
</div>
|
||||
<div class="icon tooltip" id="submit-config" role="button" aria-label="Save settings" tabindex="0" data-tooltip="Save settings" onclick="submitConfig()">
|
||||
<i class="fa-solid fa-save"></i>
|
||||
</div>
|
||||
<div class="icon tooltip logout-link" role="button" aria-label="Sign out" tabindex="0" data-tooltip="Sign out">
|
||||
<i class="fa-solid fa-sign-out"></i>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="panel-100-real m-0 p-10 no-bg">
|
||||
<p class="color-4">Feel free to contact us on <a href="https://discord.gg/ZAVNdS74mC" target="_blank"><strong class="color-5"><i class="fa-brands fa-discord"></i> Discord</strong></a> for community support.</p>
|
||||
<a href="https://github.com/noobishsvk/fm-dx-webserver" target="_blank" class="text-small">Version: <span class="version-string color-4"></span></a>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sidenav-close text-medium-big hover-brighten flex-center color-4 modal-panel-sidebar" role="button" onclick="toggleNav()" aria-label="Toggle navigation" tabindex="0"><i class="fa-solid fa-chevron-left"></i></div>
|
||||
</div>
|
||||
<div id="wrapper" class="setup-wrapper admin-wrapper panel-full">
|
||||
<div class="panel-full" style="margin-top: -20px; border-radius: 0; padding-left: 20px;padding-right: 20px;">
|
||||
<div class="panel-full tab-content no-bg m-0" id="dashboard" role="tabpanel">
|
||||
<h2>Dashboard</h2>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-33 p-20">
|
||||
<span class="text-medium-big color-5"><%= onlineUsers %></span>
|
||||
<p>Online users</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-33 p-20">
|
||||
<span class="text-medium-big color-5"><%= memoryUsage %></span>
|
||||
<span class="text-small color-4" style="display: block; font-size: 1em; font-weight: 600; line-height: 0;">(<%= memoryHeap %> heap)</span>
|
||||
<p>Memory usage</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-33 p-20">
|
||||
<span class="text-medium-big color-5"><%= processUptime %></span>
|
||||
<p >Uptime</p>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="panel-33 p-20">
|
||||
<span class="text-medium-big color-5"><%= onlineUsers %></span>
|
||||
<p>Online users</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20" style="overflow-x: auto;">
|
||||
<h3>Current users</h3>
|
||||
<table class="table-big">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Location</th>
|
||||
<th>Online since</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (connectedUsers.length > 0) { %>
|
||||
<% connectedUsers.forEach(user => { %>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://dnschecker.org/ip-location.php?ip=<%= user.ip.replace('::ffff:', '') %>" target="_blank">
|
||||
<%= user.ip.replace('::ffff:', '') %>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<%= user.location %><% if (user.isp) { %> (<%= user.isp %>)<% } %>
|
||||
</td>
|
||||
<td><%= user.time %></td>
|
||||
<td><a href="./kick?ip=<%= user.ip %>">Kick</a></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center">No users online</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel-33 p-20">
|
||||
<span class="text-medium-big color-5"><%= memoryUsage %></span>
|
||||
<span class="text-small color-4" style="display: block; font-size: 1em; font-weight: 600; line-height: 0;">(<%= memoryHeap %> heap)</span>
|
||||
<p>Memory usage</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20">
|
||||
<h3>Quick settings</h3>
|
||||
<div class="flex-container flex-center" style="margin: 30px;">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Unlocked Tuner', id: 'publicTuner'}) %>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Admin lock', id: 'lockToAdmin'}) %><br>
|
||||
</div>
|
||||
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Tune password', id: 'password-tunePass', password: true}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Admin password', id: 'password-adminPass', password: true}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20 bottom-20">
|
||||
<h3>Console</h3>
|
||||
<% if (consoleOutput && consoleOutput.length > 0) { %>
|
||||
<div class="panel-100 auto br-5 p-10 text-small text-left top-10" id="console-output">
|
||||
<% consoleOutput.forEach(function(log) { %>
|
||||
<pre class="m-0" style="white-space:pre-wrap;"><%= log %></pre>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<p>No console output available.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="panel-33 p-20">
|
||||
<span class="text-medium-big color-5"><%= processUptime %></span>
|
||||
<p >Uptime</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full tab-content no-bg m-0" id="audio" role="tabpanel">
|
||||
<h2>Audio settings</h2>
|
||||
|
||||
<div class="flex-container contains-dropdown">
|
||||
<div class="panel-33 p-bottom-20">
|
||||
<h3>Device</h3>
|
||||
<p>Your audio device port.<br>
|
||||
<span class="text-gray">This is where your tuner is plugged in.</span>
|
||||
</p>
|
||||
|
||||
<%- include('_components', {
|
||||
component: 'dropdown',
|
||||
id: 'audioList',
|
||||
inputId: 'audio-audioDevice',
|
||||
label: 'Audio device',
|
||||
cssClass: '',
|
||||
placeholder: 'Choose your audio device',
|
||||
options: [
|
||||
...videoDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: `${device.name}`
|
||||
})),
|
||||
...audioDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: `${device.name}`
|
||||
}))
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
<div class="panel-33 p-bottom-20">
|
||||
<h3>Channels</h3>
|
||||
<p>Audio channel count.<br>
|
||||
<span class="text-gray">Choose between Mono / Stereo.</span>
|
||||
</p>
|
||||
<%- include('_components', { component: 'dropdown', id: 'audio-channels-dropdown', inputId: 'audio-audioChannels', label: 'Audio channels', cssClass: '', placeholder: 'Stereo',
|
||||
options: [
|
||||
{ value: '2', label: 'Stereo' },
|
||||
{ value: '1', label: 'Mono' }
|
||||
]
|
||||
}) %><br>
|
||||
</div>
|
||||
<div class="panel-33 p-bottom-20">
|
||||
<h3>Bitrate</h3>
|
||||
<p>The bitrate of the mp3 audio.<br>
|
||||
<span class="text-gray">Minimum: 64 Kbps • Maximum: 320 Kbps</span>
|
||||
</p>
|
||||
<%- include('_components', { component: 'dropdown', id: 'audio-quality-dropdown', inputId: 'audio-audioBitrate', label: 'Audio quality', cssClass: '', placeholder: '128kbps (standard)',
|
||||
options: [
|
||||
{ value: '64k', label: '64kbps (lowest quality)' },
|
||||
{ value: '96k', label: '96kbps (low quality)' },
|
||||
{ value: '128k', label: '128kbps (standard)' },
|
||||
{ value: '192k', label: '192kbps (high quality)' },
|
||||
{ value: '256k', label: '256kbps (very high quality)' },
|
||||
{ value: '320k', label: '320kbps (ultra quality)' }
|
||||
]
|
||||
}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Audio boost</h3>
|
||||
<p>This option will boost the audio volume. Use if the output is too quiet.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Audio Boost', id: 'audio-audioBoost'}) %>
|
||||
</div>
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Experimental</h3>
|
||||
<p>If you use a USB audio card on Linux, enabling this option might fix your audio issues.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'ALSA Software mode', id: 'audio-softwareMode'}) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 p-bottom-20 bottom-20">
|
||||
<h3>FFmpeg</h3>
|
||||
<p>Legacy option for Linux / macOS that could resolve audio issues, but will consume additional CPU and RAM usage.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Additional FFmpeg', id: 'audio-ffmpeg'}) %>
|
||||
</div>
|
||||
<div class="panel-50 p-bottom-20 bottom-20">
|
||||
<h3>Sample rate offset</h3>
|
||||
<p>Using a negative value could eliminate audio buffering issues during long periods of listening. <br>
|
||||
However, a value that’s too low might increase the buffer over time.</p>
|
||||
<p><input class="panel-33 input-text w-100 auto" type="number" style="min-height: 40px; color: var(--color-text); padding: 10px; padding-left: 10px; box-sizing: border-box; border: 2px solid transparent; font-family: 'Titillium Web', sans-serif;" id="audio-samplerateOffset" min="-10" max="10" step="1" value="0" aria-label="Samplerate offset"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="webserver" role="tabpanel">
|
||||
<h2>Webserver settings</h2>
|
||||
<div class="flex-container contains-dropdown">
|
||||
<div class="panel-50 p-bottom-20" style="padding-left: 20px; padding-right: 20px;">
|
||||
<h3>Connection</h3>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '0.0.0.0', label: 'Webserver IP', id: 'webserver-webserverIp'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '8080', label: 'Webserver port', id: 'webserver-webserverPort'}) %><br>
|
||||
</div>
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Design</h3>
|
||||
<h4>Background image</h4>
|
||||
<%- include('_components', {component: 'text', cssClass: 'br-15', placeholder: 'Direct image link', label: 'Image link', id: 'webserver-bgImage'}) %><br>
|
||||
|
||||
<h4 class="top-25">Themes</h4>
|
||||
<%- include('_components', { component: 'dropdown', id: 'server-theme-selector', inputId: 'webserver-defaultTheme', label: 'Default server theme', cssClass: '', placeholder: 'Default',
|
||||
options: [
|
||||
{ value: 'theme1', label: 'Mint' },
|
||||
{ value: 'theme2', label: 'Cappuccino' },
|
||||
{ value: 'theme3', label: 'Nature' },
|
||||
{ value: 'theme4', label: 'Ocean' },
|
||||
{ value: 'theme5', label: 'Terminal' },
|
||||
{ value: 'theme6', label: 'Nightlife' },
|
||||
{ value: 'theme7', label: 'Blurple' },
|
||||
{ value: 'theme8', label: 'Construction' },
|
||||
{ value: 'theme9', label: 'Amoled' },
|
||||
]
|
||||
}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20">
|
||||
<h3>Antennas</h3>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'bottom-20', label: 'Antenna switch', id: 'antennas-enabled'}) %><br>
|
||||
|
||||
<div class="flex-container flex-center p-20">
|
||||
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Antenna 1', id: 'antennas-ant1-enabled'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant A', label: 'Antenna 1 name', id: 'antennas-ant1-name'}) %><br>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'top-25', label: 'Antenna 2', id: 'antennas-ant2-enabled'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant B', label: 'Antenna 2 name', id: 'antennas-ant2-name'}) %><br>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Antenna 3', id: 'antennas-ant3-enabled'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant C', label: 'Antenna 3 name', id: 'antennas-ant3-name'}) %><br>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flex-phone flex-column bottom-20" style="margin-left: 15px; margin-right: 15px;">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Antenna 4', id: 'antennas-ant4-enabled'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'Ant D', label: 'Antenna 4 name', id: 'antennas-ant4-name'}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-50" style="padding-left: 20px; padding-right: 20px;">
|
||||
<h3>Tuning options</h3>
|
||||
<p>If you want to limit which frequencies the users can tune to,<br>you can set the lower and upper limit here.<br>
|
||||
<span class="text-gray">Enter frequencies in MHz.</span>
|
||||
</p>
|
||||
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Limit tuning', id: 'webserver-tuningLimit'}) %><br>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '0', label: 'Lower limit', id: 'webserver-tuningLowerLimit'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '108', label: 'Upper limit', id: 'webserver-tuningUpperLimit'}) %>
|
||||
</div>
|
||||
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Presets</h3>
|
||||
<p>You can set up to 4 presets.<br>These presets are accessible with the F1-F4 buttons.<br>
|
||||
<span class="text-gray">Enter frequencies in MHz.</span></p>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '87.5', label: 'Preset 1', id: 'webserver-presets-1'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '87.5', label: 'Preset 2', id: 'webserver-presets-2'}) %>
|
||||
<br>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '87.5', label: 'Preset 3', id: 'webserver-presets-3'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '87.5', label: 'Preset 4', id: 'webserver-presets-4'}) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container p-bottom-20">
|
||||
<div class="panel-50 p-bottom-20" style="padding-left: 20px; padding-right: 20px;">
|
||||
<h3>RDS Mode</h3>
|
||||
<p>You can switch between American (RBDS) / Global (RDS) mode here.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'bottom-20', iconClass: '', label: 'American RDS mode (RBDS)', id: 'webserver-rdsMode'}) %>
|
||||
<h3>RDS Timeout</h3>
|
||||
<p>If no data is received, RDS will be automatically cleared after a timeout.<br>
|
||||
<span class="text-gray">Enter timeout in seconds or 0 to disable.</span><br></p>
|
||||
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100', placeholder: '0', label: 'RDS Timeout', id: 'webserver-rdsTimeout'}) %>
|
||||
</div>
|
||||
<div class="panel-50 p-bottom-20" style="padding-left: 20px; padding-right: 20px; padding-bottom: 80px;">
|
||||
<h3>Transmitter Search Algorithm</h3>
|
||||
<p>Different modes may help with more accurate transmitter identification depending on your region.</p>
|
||||
<%- include('_components', { component: 'dropdown', id: 'server-tx-id-algo', inputId: 'webserver-txIdAlgorithm', label: 'Transmitter ID Algorithm', cssClass: '', placeholder: 'Algorithm 1',
|
||||
options: [
|
||||
{ value: '0', label: 'Algorithm 1' },
|
||||
{ value: '1', label: 'Algorithm 2' },
|
||||
{ value: '2', label: 'Algorithm 3' },
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="tuner" role="tabpanel">
|
||||
<h2>Tuner settings</h2>
|
||||
<div class="panel-100 p-bottom-20 contains-dropdown" style="z-index: 991;">
|
||||
<h3>Device type</h3>
|
||||
<div class="flex-center" style="max-width: 520px; margin: 10px auto 0;">
|
||||
<%- include('_components', { component: 'dropdown', id: 'device-selector', inputId: 'device', label: 'Device', cssClass: '', placeholder: 'TEF668x / TEA685x',
|
||||
options: tunerProfiles.map(profile => ({
|
||||
value: profile.id,
|
||||
label: profile.label
|
||||
}))
|
||||
}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-container contains-dropdown">
|
||||
<div class="panel-100 p-bottom-20" style="padding-right: 20px; padding-left: 20px;">
|
||||
<h3>Connection type</h3>
|
||||
<p class="text-gray">If you want to choose the serial port directly, choose "Direct".<br>If you use xdrd or your receiver is connected via Wi-Fi, choose TCP/IP.</p>
|
||||
<div class="auto top-10">
|
||||
<label class="toggleSwitch nolabel" onclick="">
|
||||
<input id="xdrd-wirelessConnection" type="checkbox" tabindex="0" aria-label="Connection type"/>
|
||||
<a></a>
|
||||
<span>
|
||||
<span class="left-span">Direct</span>
|
||||
<span class="right-span">TCP/IP</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="tuner-usb">
|
||||
<p class="text-gray">Choose your desired <strong>serial port</strong><br> </p>
|
||||
<%- include('_components', {
|
||||
component: 'dropdown',
|
||||
id: 'deviceList',
|
||||
inputId: 'xdrd-comPort',
|
||||
label: 'Serial port',
|
||||
cssClass: '',
|
||||
placeholder: 'Choose your serial port',
|
||||
options: serialPorts.map(serialPort => ({
|
||||
value: serialPort.path,
|
||||
label: `${serialPort.path} - ${serialPort.friendlyName}`
|
||||
}))
|
||||
}) %>
|
||||
</div>
|
||||
|
||||
<div id="tuner-wireless">
|
||||
<p class="text-gray">If you are connecting your tuner <strong>wirelessly</strong>, enter the tuner IP. <br> If you use <strong>xdrd</strong>, use 127.0.0.1 as your IP.</p>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', label: 'xdrd IP address', id: 'xdrd-xdrdIp'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', label: 'xdrd port', id: 'xdrd-xdrdPort'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', label: 'xdrd password', id: 'xdrd-xdrdPassword', password: true}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Startup</h3>
|
||||
<h4>Startup volume</h4>
|
||||
<div class="panel-75 auto" style="height: 48px;">
|
||||
<input type="range" id="audio-startupVolume" min="0" max="1" step="0.01" value="1" aria-label="Startup Volume slider">
|
||||
</div>
|
||||
<h4 class="top-10 text-gray" id="volume-percentage-value"></h4>
|
||||
|
||||
<hr>
|
||||
<h4 class="bottom-20">Default frequency</h4>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Default frequency for first client', id: 'enableDefaultFreq'}) %><br>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100', placeholder: '87.5', label: 'Default frequency', id: 'defaultFreq'}) %>
|
||||
</div>
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Miscellaneous</h3>
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 no-bg">
|
||||
<h4>Bandwidth switch</h4>
|
||||
<p>Bandwidth switch allows the user to set the bandwidth manually.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Bandwidth switch', id: 'bwSwitch'}) %><br>
|
||||
</div>
|
||||
<div class="panel-50 no-bg">
|
||||
<h4>Automatic shutdown</h4>
|
||||
<p>Toggling this option will put the tuner to sleep when no clients are connected.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Auto-shutdown', id: 'autoShutdown'}) %><br>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 no-bg">
|
||||
<h4>SI47XX AGC control</h4>
|
||||
<p>Allow users to change SI47XX AGC mode from the main UI.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Enable AGC control', id: 'si47xx-agcControl'}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="plugins-tab" role="tabpanel">
|
||||
<h2>Plugins</h2>
|
||||
<div class="panel-100 p-bottom-20">
|
||||
<h3>Plugin list</h3>
|
||||
<p>Any compatible <strong>.js</strong> plugin, which is in the "plugins" folder, will be listed here.<br>
|
||||
Click on the individual plugins to enable/disable them.</p>
|
||||
<select id="plugins" class="multiselect" multiple>
|
||||
<% plugins.forEach(function(plugin) { %>
|
||||
<option data-name="<%= plugin.frontEndPath %>" title="<%= plugin.name %> by <%= plugin.author %> [v<%= plugin.version %>]">
|
||||
<%= plugin.name %> by <%= plugin.author %> [v<%= plugin.version %>]
|
||||
</option>
|
||||
<% }); %>
|
||||
</select><br><br>
|
||||
<a href="https://github.com/NoobishSVK/fm-dx-webserver/wiki/Plugin-List" target="_blank">Download new plugins here!</a>
|
||||
</div>
|
||||
|
||||
<div class="panel-100 p-bottom-20">
|
||||
<h3>Plugin settings</h3>
|
||||
<div id="plugin-settings">No plugin settings are available.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="identification" role="tabpanel">
|
||||
<h2>Identification & Map</h2>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 p-bottom-20">
|
||||
<h3>Basic info</h3>
|
||||
|
||||
<p>Set your tuner name and description here.<br>This info will be visible to anyone who tunes in. </p>
|
||||
<div class="panel-full no-bg" style="padding-left: 20px; padding-right: 20px;">
|
||||
<label for="identification-tunerName" style="width: 100%;max-width: 768px; margin:auto;">Webserver name:</label>
|
||||
<input style="width: 100%; max-width: 768px;" class="input-text br-15" type="text" name="identification-tunerName" id="identification-tunerName" placeholder="Fill your server name here." maxlength="32">
|
||||
<br>
|
||||
<label for="identification-tunerDesc" style="width: 100%;max-width: 768px; margin: auto;">Webserver description:</label>
|
||||
<textarea id="identification-tunerDesc" class="br-15" name="webserver-desc" placeholder="Fill the server description here. You can put useful info here such as your antenna setup. You can use simple markdown." maxlength="255"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-50">
|
||||
<h3>Online map</h3>
|
||||
<p>If your location information is filled,<br>you can add your tuner to a public list.</p>
|
||||
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Broadcast to map', id: 'identification-broadcastTuner'}) %><br>
|
||||
<%- include('_components', {component: 'text', cssClass: 'br-15', placeholder: 'Your e-mail or Discord...', label: 'Owner contact', id: 'identification-contact'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'br-15', label: 'Proxy address', id: 'identification-proxyIp'}) %>
|
||||
|
||||
<p>Check your tuner at <strong><a href="https://servers.fmdx.org" target="_blank" class="color-4">servers.fmdx.org</a></strong>.</p>
|
||||
<p class="text-small text-gray">By activating the <strong>Broadcast to map</strong> option,<br>you agree to the <a href="https://fmdx.org/projects/webserver.php#rules" target="_blank">Terms of Service</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-100">
|
||||
<h3>Location</h3>
|
||||
<p class="text-gray">Location info is useful for automatic identification of stations using RDS.</p>
|
||||
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Latitude', id: 'identification-lat'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Longitude', id: 'identification-lon'}) %>
|
||||
|
||||
<div id="map"></div>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="users" role="tabpanel">
|
||||
<h2>User management</h2>
|
||||
<div class="panel-100">
|
||||
<h3>Chat options</h3>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Chat', id: 'webserver-chatEnabled'}) %>
|
||||
</div>
|
||||
|
||||
<div class="panel-100 p-bottom-20">
|
||||
<h3>Banlist</h3>
|
||||
<p>If you have users that don't behave on your server, you can choose to ban them by their IP address.<br>
|
||||
<span class="text-gray">You can see their IP address by hovering over their nickname. One IP per row.</span></p>
|
||||
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20" style="overflow-x: auto;">
|
||||
<h3>Current users</h3>
|
||||
<table class="table-big">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Location</th>
|
||||
<th>Ban date</th>
|
||||
<th>Reason</th>
|
||||
<th class="color-5"></th>
|
||||
<th>User Agent</th>
|
||||
<th>Online since</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: 'IP address', label: '', id: 'banlist-add-ip'}) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: 'Ban reason (note)', label: '', id: 'banlist-add-reason'}) %></td>
|
||||
<td class="color-5">
|
||||
<a href="#" id="banlist-add-link"><i class="fa-solid fa-plus-circle banlist-add"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<% if (banlist.length > 0) { %>
|
||||
<% banlist.forEach(bannedUser => { %>
|
||||
<% if (connectedUsers.length > 0) { %>
|
||||
<% connectedUsers.forEach(user => { %>
|
||||
<tr>
|
||||
<% if (Array.isArray(bannedUser)) { %>
|
||||
<!-- If it's an array, use its values -->
|
||||
<td style="text-align: center !important;"><a href="https://dnschecker.org/ip-location.php?ip=<%= bannedUser[0] %>" target="_blank"><%= bannedUser[0] %></a></td>
|
||||
<td><%= bannedUser[1] %></td>
|
||||
<td class="text-bold"><%= new Date(parseInt(bannedUser[2])).toLocaleString() %></td> <!-- Assuming the ban date is a timestamp in seconds -->
|
||||
<td><%= bannedUser[3] %></td>
|
||||
<% } else { %>
|
||||
<!-- If it's just an IP address without additional data, show it as is -->
|
||||
<td style="text-align: center !important;"><a href="https://dnschecker.org/ip-location.php?ip=<%= bannedUser %>" target="_blank"><%= bannedUser %></a></td>
|
||||
<td>Unknown</td>
|
||||
<td class="text-bold">Unknown</td>
|
||||
<td>Unknown</td>
|
||||
<% } %>
|
||||
<td><a href="#" class="banlist-remove"><i class="fa-solid fa-lock-open text-gray"></i></a></td>
|
||||
<td>
|
||||
<a href="https://ipinfo.io/<%= user.ip %>" target="_blank">
|
||||
<%= user.ip %>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<%= user.location %><% if (user.isp) { %> (<%= user.isp %>)<% } %>
|
||||
</td>
|
||||
<td><%= user.agent %></td>
|
||||
<td><%= user.time %></td>
|
||||
<td><a href="./kick?ip=<%= user.ip %>">Kick</a></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center">The banlist is empty.</td>
|
||||
<td colspan="4" style="text-align: center">No users online</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
@@ -566,164 +75,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="startup" role="tabpanel">
|
||||
<h2>Startup settings</h2>
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20" style="z-index: 10; padding-bottom: 120px;">
|
||||
<h3>On startup</h3>
|
||||
<p>These settings will be applied after a server launch or restart.</p>
|
||||
<div class="flex-container flex-center p-20">
|
||||
<% if (device === 'tef') { %>
|
||||
<%- include('_components', { component: 'dropdown', id: 'ceqStartup-dropdown', inputId: 'ceqStartup', label: 'cEQ', cssClass: '', placeholder: 'Disabled',
|
||||
options: [
|
||||
{ value: '0', label: 'Disabled' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<%- include('_components', { component: 'dropdown', id: 'imsStartup-dropdown', inputId: 'imsStartup', label: 'iMS', cssClass: '', placeholder: 'Disabled',
|
||||
options: [
|
||||
{ value: '0', label: 'Disabled' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<% } else if (device === 'xdr') { %>
|
||||
<%- include('_components', { component: 'dropdown', id: 'rfStartup-dropdown', inputId: 'ceqStartup', label: 'RF+', cssClass: '', placeholder: 'Disabled',
|
||||
options: [
|
||||
{ value: '0', label: 'Disabled' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<%- include('_components', { component: 'dropdown', id: 'ifStartup-dropdown', inputId: 'imsStartup', label: 'IF+', cssClass: '', placeholder: 'Disabled',
|
||||
options: [
|
||||
{ value: '0', label: 'Disabled' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<% } %>
|
||||
<%- include('_components', { component: 'dropdown', id: 'stereoStartup-dropdown', inputId: 'stereoStartup', label: 'Stereo Mode', cssClass: '', placeholder: 'Stereo (Default)',
|
||||
options: [
|
||||
{ value: '0', label: 'Stereo (Default)' },
|
||||
{ value: '1', label: 'Mono' },
|
||||
]
|
||||
}) %><br>
|
||||
</div>
|
||||
<div class="panel-100-real p-bottom-20 no-bg">
|
||||
<%- include('_components', { component: 'dropdown', id: 'antennaStartup-dropdown', inputId: 'antennaStartup', label: 'Antenna', cssClass: '', placeholder: 'Antenna 0 (Default)',
|
||||
options: [
|
||||
{ value: '0', label: 'Antenna 0 (Default)' },
|
||||
{ value: '1', label: 'Antenna 1' },
|
||||
{ value: '2', label: 'Antenna 2' },
|
||||
{ value: '3', label: 'Antenna 3' },
|
||||
]
|
||||
}) %><br>
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20 bottom-20">
|
||||
<h3>Console</h3>
|
||||
<% if (consoleOutput && consoleOutput.length > 0) { %>
|
||||
<div class="panel-100 auto br-5 p-10 text-small text-left top-10" id="console-output">
|
||||
<% consoleOutput.forEach(function(log) { %>
|
||||
<pre class="m-0" style="white-space:pre-wrap;"><%= log %></pre>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="panel-100-real p-bottom-20 bottom-20" style="z-index: 9; padding-bottom: 180px;">
|
||||
<h3>Empty server defaults</h3>
|
||||
<p>These settings will apply once the last user disconnects from the server, so the server can be ready for a new user with default settings.</p>
|
||||
<div class="flex-container flex-center p-20">
|
||||
<%- include('_components', { component: 'dropdown', id: 'bwAutoNoUsers-dropdown', inputId: 'bwAutoNoUsers', label: 'Auto BW', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<% if (device === 'tef') { %>
|
||||
<%- include('_components', { component: 'dropdown', id: 'ceqNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'cEQ', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Disabled' },
|
||||
{ value: '2', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<%- include('_components', { component: 'dropdown', id: 'imsNoUsers-dropdown', inputId: 'imsNoUsers', label: 'iMS', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Disabled' },
|
||||
{ value: '2', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<% } else if (device === 'xdr') { %>
|
||||
<%- include('_components', { component: 'dropdown', id: 'rfNoUsers-dropdown', inputId: 'ceqNoUsers', label: 'RF+', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Disabled' },
|
||||
{ value: '2', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<%- include('_components', { component: 'dropdown', id: 'ifNoUsers-dropdown', inputId: 'imsNoUsers', label: 'IF+', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Disabled' },
|
||||
{ value: '2', label: 'Enabled' },
|
||||
]
|
||||
}) %><br>
|
||||
<% } %>
|
||||
<%- include('_components', { component: 'dropdown', id: 'stereoNoUsers-dropdown', inputId: 'stereoNoUsers', label: 'Stereo Mode', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Stereo' },
|
||||
{ value: '2', label: 'Mono' },
|
||||
]
|
||||
}) %><br>
|
||||
</div>
|
||||
<div class="panel-100-real p-bottom-20 no-bg">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Delayed Antenna Change', id: 'antennaNoUsersDelay'}) %><br>
|
||||
<%- include('_components', { component: 'dropdown', id: 'antennaNoUsers-dropdown', inputId: 'antennaNoUsers', label: 'Antenna', cssClass: '', placeholder: 'Unchanged',
|
||||
options: [
|
||||
{ value: '0', label: 'Unchanged' },
|
||||
{ value: '1', label: 'Antenna 0' },
|
||||
{ value: '2', label: 'Antenna 1' },
|
||||
{ value: '3', label: 'Antenna 2' },
|
||||
{ value: '4', label: 'Antenna 3' },
|
||||
]
|
||||
}) %>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-full m-0 tab-content no-bg" id="extras" role="tabpanel">
|
||||
<h2>Extras</h2>
|
||||
<div class="panel-100 p-bottom-20">
|
||||
<h3>FMLIST Integration</h3>
|
||||
<p>FMLIST integration allows you to get potential DXes logged on the <a href="http://fmlist.org/fm_logmap.php?hours=900" target="_blank" class="text-bold color-4">FMLIST Visual Logbook</a>.<br>
|
||||
Your server also needs to have a valid UUID, which is obtained by registering on maps in the <strong>Identification & Map</strong> tab.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'm-right-10', label: 'FMLIST integration', id: 'extras-fmlistIntegration'}) %><br>
|
||||
<p>If you don't feel comfortable with the general public logging on your server, you can make this feature available only for people with a password</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'm-right-10', label: 'Admin-only logging', id: 'extras-fmlistAdminOnly'}) %><br>
|
||||
|
||||
<p>You can also fill in your OMID from FMLIST.org, if you want the logs to be saved to your account.</p>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '', label: 'OMID', id: 'extras-fmlistOmid'}) %>
|
||||
</div>
|
||||
<div class="panel-100 p-bottom-20">
|
||||
<h3>Tunnel</h3>
|
||||
<p>When you become an <a href="https://buymeacoffee.com/fmdx" target="_blank"><strong>FMDX.org supporter</strong></a>, you can host your webserver without the need of a public IP address & port forwarding.<br>
|
||||
When you become a supporter, you can message the Founders on Discord for your login details.</p><br>
|
||||
<p>You can also get an tunnel from kuba201 discord, one of the contributors of this version of the application.</p>
|
||||
<h4>Main tunnel settings</h4>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'm-right-10', label: 'Enable tunnel', id: 'tunnel-enabled'}) %><br>
|
||||
<%- include('_components', { component: 'dropdown', id: 'tunnel-regionSelect', inputId: 'tunnel-region', label: 'Official server region', cssClass: '', placeholder: 'Europe',
|
||||
options: [
|
||||
{ value: 'eu', label: 'Europe' },
|
||||
{ value: 'us', label: 'Americas' },
|
||||
{ value: 'sg', label: 'Asia & Oceania' },
|
||||
{ value: 'pldx', label: 'Poland (k201)' },
|
||||
]
|
||||
}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Username', id: 'tunnel-username'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-250 br-15', password: true, placeholder: '', label: 'Token', id: 'tunnel-token'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Subdomain name', id: 'tunnel-subdomain'}) %><br>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'm-right-10', label: 'Low latency mode', id: 'tunnel-lowLatencyMode'}) %><br>
|
||||
<p>Enabling low latency mode may provide better experience, however it will also use more bandwidth.</p>
|
||||
|
||||
<h4 class="top-25">Community tunnel settings</h4>
|
||||
<p>You can also self-host or ask other people to provide you a token. In this case, the server owner is responsible for any potential security issues.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'm-right-10', label: 'Use a community tunnel', id: 'tunnel-community-enabled'}) %><br>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-250 br-15', placeholder: '', label: 'Community Tunnel host (IP or hostname)', id: 'tunnel-community-host'}) %>
|
||||
<% } else { %>
|
||||
<p>No console output available.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -735,18 +98,5 @@
|
||||
<script src="js/dropdown.js"></script>
|
||||
<script src="js/setup.js"></script>
|
||||
<script src="js/confighandler.js"></script>
|
||||
<% enabledPlugins?.forEach(function(plugin) { %>
|
||||
<script src="js/plugins/<%= plugin %>"></script>
|
||||
<% }); %>
|
||||
|
||||
<script>
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.ctrlKey && event.key === 's') {
|
||||
event.preventDefault();
|
||||
if (event.repeat) return;
|
||||
submitConfig();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Wizard - FM-DX Webserver</title>
|
||||
<link href="css/entry.css" type="text/css" rel="stylesheet">
|
||||
<link href="css/flags.min.css" type="text/css" rel="stylesheet">
|
||||
<link href="css/libs/fontawesome.css" type="text/css" rel="stylesheet">
|
||||
<script src="js/libs/jquery.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" type="text/css" rel="stylesheet">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" id="favicon" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="toast-container" style="position: fixed; top: 20px; right: 20px; z-index: 9999;"></div>
|
||||
<div class="wrapper-outer wrapper-full">
|
||||
<div id="wrapper">
|
||||
<div class="panel-100 no-bg">
|
||||
<img class="top-10" src="../images/openradio_logo_neutral.png" height="64px">
|
||||
<h2 class="text-monospace text-light text-gray">[SETUP WIZARD]</h2>
|
||||
</div>
|
||||
<div class="panel-100 no-bg flex-container flex-center flex-phone">
|
||||
<div class="btn-rounded-cube activated">1</div>
|
||||
<div class="btn-rounded-cube">2</div>
|
||||
<div class="btn-rounded-cube">3</div>
|
||||
<div class="btn-rounded-cube">4</div>
|
||||
<div class="btn-rounded-cube">5</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-100">
|
||||
<!-- BASIC SETTINGS -->
|
||||
<div class="panel-100 step no-bg" id="step1">
|
||||
<h2 class="settings-heading">Basic settings</h2>
|
||||
<p class="m-0">Welcome to the setup wizard! Let's set up some basic things.</p>
|
||||
|
||||
<h3 class="settings-heading">Webserver connection</h3>
|
||||
<p class="m-0">Leave the IP at 0.0.0.0 unless you explicitly know you have to change it.<br>DO NOT enter your public IP here.</p>
|
||||
<div class="flex-center top-25">
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '0.0.0.0', label: 'Webserver IP', id: 'webserver-webserverIp'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', placeholder: '8080', label: 'Webserver port', id: 'webserver-webserverPort'}) %><br>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BASIC SETTINGS END -->
|
||||
<!-- TUNER SETTINGS -->
|
||||
<div id="step2" class="step" style="display: none">
|
||||
<h2 class="settings-heading">Tuner settings</h2>
|
||||
|
||||
<h3 class="settings-heading">Tuner type</h3>
|
||||
<p class="m-0">Settings a proper device type ensures that the correct interface and settings will load.</p>
|
||||
<div class="panel-100 no-bg flex-center" style="max-width: 520px; margin: 10px auto 0;">
|
||||
<%- include('_components', { component: 'dropdown', id: 'device-selector', inputId: 'device', label: 'Device', cssClass: '', placeholder: 'TEF668x / TEA685x',
|
||||
options: tunerProfiles.map(profile => ({
|
||||
value: profile.id,
|
||||
label: profile.label
|
||||
}))
|
||||
}) %><br>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<h3 class="settings-heading">Tuner connection</h3>
|
||||
<div style="width: 300px;" class="auto top-10">
|
||||
<label class="toggleSwitch nolabel" onclick="">
|
||||
<input id="xdrd-wirelessConnection" type="checkbox" aria-label="Tuner connection type"/>
|
||||
<a></a>
|
||||
<span>
|
||||
<span class="left-span">Direct</span>
|
||||
<span class="right-span">TCP/IP</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="tuner-usb" class="top-25">
|
||||
<p>It's time to choose your serial port.</p>
|
||||
|
||||
<div class="panel-100 no-bg flex-center">
|
||||
<%- include('_components', {
|
||||
component: 'dropdown',
|
||||
id: 'deviceList',
|
||||
inputId: 'xdrd-comPort',
|
||||
label: 'Serial port',
|
||||
cssClass: '',
|
||||
placeholder: 'Choose your serial port',
|
||||
options: serialPorts.map(serialPort => ({
|
||||
value: serialPort.path,
|
||||
label: `${serialPort.path} - ${serialPort.friendlyName}`
|
||||
}))
|
||||
}) %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="tuner-wireless" class="top-25">
|
||||
<p class="m-0">If you are connecting your tuner <strong>wirelessly</strong>, enter the tuner IP. <br> If you use <strong>xdrd</strong>, use 127.0.0.1 as your IP.</p>
|
||||
<div class="flex-center top-25">
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', label: 'xdrd IP address', id: 'xdrd-xdrdIp'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-100 br-15', label: 'xdrd port', id: 'xdrd-xdrdPort'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', label: 'xdrd password', id: 'xdrd-xdrdPassword', password: true}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TUNER SETTINGS END -->
|
||||
<!-- AUDIO SETTINGS -->
|
||||
<div id="step3" class="step" style="display: none;">
|
||||
<div class="panel-100 no-bg" style="min-height: 120px;margin-bottom: 0;">
|
||||
<h2 class="settings-heading">Audio settings</h2>
|
||||
<p class="m-0">In this section, we will set up the audio.<br>
|
||||
Choose the audio port your tuner is connected to and desired audio settings here.</p>
|
||||
<p class="text-gray">Recommended defaults have already been set for the audio quality, you can keep them as-is.</p>
|
||||
|
||||
<div class="panel-100 no-bg p-bottom-20 flex-container flex-center">
|
||||
<%- include('_components', {
|
||||
component: 'dropdown',
|
||||
id: 'audioList',
|
||||
inputId: 'audio-audioDevice',
|
||||
label: 'Audio device',
|
||||
cssClass: '',
|
||||
placeholder: 'Choose your audio device',
|
||||
options: [
|
||||
...videoDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: `${device.name}`
|
||||
})),
|
||||
...audioDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: `${device.name}`
|
||||
}))
|
||||
]
|
||||
}) %>
|
||||
|
||||
<%- include('_components', { component: 'dropdown', id: 'audio-channels-dropdown', inputId: 'audio-audioChannels', label: 'Audio channels', cssClass: '', placeholder: 'Stereo',
|
||||
options: [
|
||||
{ value: '2', label: 'Stereo' },
|
||||
{ value: '1', label: 'Mono' }
|
||||
]
|
||||
}) %>
|
||||
|
||||
<%- include('_components', { component: 'dropdown', id: 'audio-quality-dropdown', inputId: 'audio-audioBitrate', label: 'Audio quality', cssClass: '', placeholder: '128kbps (standard)',
|
||||
options: [
|
||||
{ value: '64k', label: '64kbps (lowest quality)' },
|
||||
{ value: '96k', label: '96kbps (low quality)' },
|
||||
{ value: '128k', label: '128kbps (standard)' },
|
||||
{ value: '192k', label: '192kbps (high quality)' },
|
||||
{ value: '256k', label: '256kbps (very high quality)' },
|
||||
{ value: '320k', label: '320kbps (ultra quality)' }
|
||||
]
|
||||
}) %>
|
||||
</div>
|
||||
|
||||
<div class="panel-100 no-bg text-center">
|
||||
<div class="flex-container">
|
||||
<div class="panel-50 no-bg">
|
||||
<p>If you use an USB audio card on Linux, enabling this option might fix your audio issues.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'panel-100 flex-container flex-center', label: 'ALSA Software mode', id: 'audio-softwareMode'}) %>
|
||||
</div>
|
||||
<div class="panel-50 no-bg">
|
||||
<p>Legacy option for Linux / macOS that could resolve audio issues, but will consume additional CPU and RAM usage.</p>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: 'panel-100 flex-container flex-center', label: 'Additional FFmpeg', id: 'audio-ffmpeg'}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AUDIO SETTINGS END -->
|
||||
<!-- IDENTIFICATION START -->
|
||||
<div id="step4" class="step" style="display: none;">
|
||||
<div class="panel-100 no-bg" style="padding-bottom: 20px;">
|
||||
<h2 class="settings-heading">Webserver info</h2>
|
||||
<p class="m-0">In this part, we will set up your webserver info, such as the server name, description and location.</p>
|
||||
<label for="identification-tunerName" style="width: 100%;max-width: 768px; margin:auto;">Webserver name:</label>
|
||||
<input style="width: 100%; max-width: 768px;" class="input-text br-15" type="text" name="identification-tunerName" id="identification-tunerName" placeholder="Fill your server name here." maxlength="32">
|
||||
<br>
|
||||
<label for="identification-tunerDesc" style="width: 100%;max-width: 768px; margin: auto;">Webserver description:</label>
|
||||
<textarea id="identification-tunerDesc" name="webserver-desc" class="br-15" placeholder="Fill the server description here. You can put useful info here such as your antenna setup. You can use simple markdown." maxlength="255"></textarea>
|
||||
|
||||
<h3 class="settings-heading">Location</h3>
|
||||
<p>Location info is useful for automatic identification of stations using RDS.</p>
|
||||
<div class="panel-100 no-bg flex-container flex-center">
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-250 br-15', placeholder: '', label: 'Latitude', id: 'identification-lat'}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-250 br-15', placeholder: '', label: 'Longitude', id: 'identification-lon'}) %>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
|
||||
<h3 class="settings-heading">Map broadcast</h3>
|
||||
<p class="m-0">If your location info is filled, you can add your tuner to a public list.</p>
|
||||
<p class="m-0">The list is available at <strong><a href="https://servers.fmdx.org" target="_blank" class="color-4">servers.fmdx.org</a></strong>.</p>
|
||||
<p class="text-small text-gray">By activating the <strong>Broadcast to map</strong> option, you agree to the <a href="https://fmdx.org/projects/webserver.php#rules" target="_blank">Terms of Service</a>.</p>
|
||||
|
||||
<div class="panel-100 no-bg flex-container flex-center">
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Broadcast to map', id: 'identification-broadcastTuner'}) %><br>
|
||||
<%- include('_components', {component: 'checkbox', cssClass: '', label: 'Allow tuning without password', id: 'publicTuner'}) %>
|
||||
</div>
|
||||
|
||||
<p class="text-gray">If you use a proxy / tunnel service, enter the access link here. If you don't know what a proxy is, leave it empty.</p>
|
||||
|
||||
<div class="panel-100 no-bg flex-container flex-center">
|
||||
<%- include('_components', {component: 'text', cssClass: 'br-15', label: 'Broadcast address (if using a proxy)', id: 'identification-proxyIp'}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- IDENTIFICATION END -->
|
||||
<!-- ADMIN SETTINGS START -->
|
||||
<div id="step5" class="step" style="display: none;">
|
||||
<h2 class="settings-heading">Admin panel settings</h2>
|
||||
<p>We are at the last and final step of the wizard.</p>
|
||||
|
||||
<p>Here we can set the password. Tune password is optional.<br>Setting an admin password allows you to change settings later and setting one up is mandatory.</p>
|
||||
<div class="flex-container flex-center">
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Tune password', id: 'password-tunePass', password: true}) %>
|
||||
<%- include('_components', {component: 'text', cssClass: 'w-150 br-15', placeholder: '', label: 'Admin password', id: 'password-adminPass', password: true}) %><br>
|
||||
</div>
|
||||
<p>You can now click the <strong>save button</strong> to save your settings. After that, you will need to restart the webserver.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-100 no-bg">
|
||||
<button class="btn-prev"><i class="fa-solid fa-arrow-left"></i></button>
|
||||
<button class="btn-next">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-100 no-bg">
|
||||
<p>Feel free to contact us on <a href="https://discord.gg/ZAVNdS74mC" target="_blank"><strong><i class="fa-brands fa-discord"></i> Discord</strong></a> for community support.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/settings.js"></script>
|
||||
<script src="js/dropdown.js"></script>
|
||||
<script src="js/toast.js"></script>
|
||||
<script src="js/setup.js"></script>
|
||||
<script src="js/wizard.js"></script>
|
||||
<script src="js/confighandler.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,6 +39,7 @@ h2 {
|
||||
color: var(--color-4);
|
||||
margin-bottom: 0;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h3 {
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
/* A1: Styles for Tune Step Feature */
|
||||
body.tune-step-enabled #freq-container {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
body.tune-step-enabled .freq-digit-marker {
|
||||
color: #00FF00;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.et-dimmed-zero {
|
||||
opacity: 0.1;
|
||||
}
|
||||
.et-dimmed-zero.freq-digit-marker {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* A2: Styles for disabled bands (Tune Limit) */
|
||||
.disabled-band {
|
||||
background-color: var(--color-1) !important;
|
||||
color: var(--color-3) !important;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* MW 9 kHz/10 kHz button */
|
||||
#mw-step-toggle-button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
z-index: 11;
|
||||
display: none;
|
||||
max-width: 50px;
|
||||
text-align: center;
|
||||
height: auto;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 1.4;
|
||||
color: var(--color-main);
|
||||
background-color: var(--color-4);
|
||||
border: 1px solid var(--color-2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
#mw-step-toggle-button:hover {
|
||||
background-color: var(--color-5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#mw-step-toggle-button.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-4);
|
||||
}
|
||||
|
||||
|
||||
/* ========================================================================== */
|
||||
/* B: MODERN LAYOUT */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* B1: Basic structure for Modern Layout */
|
||||
body.layout-modern #freq-container {
|
||||
position: relative !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* B2: Main wrapper and buttons (Shown only if HIDE_ALL_BUTTONS is false) */
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin: 20px 10px 0 10px;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .side-band-button-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-selector-button {
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
background-color: color-mix(in srgb, var(--color-4) 60%, transparent);
|
||||
color: var(--color-main);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-selector-button:hover {
|
||||
background-color: var(--color-5);
|
||||
color: var(--color-main);
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .side-band-button-container .band-selector-button.active-band {
|
||||
background-color: var(--color-4);
|
||||
color: var(--color-main);
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #rt-container,
|
||||
body.layout-modern.modern-buttons-visible .am-bands-view-container {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
background-color: var(--color-1-transparent);
|
||||
backdrop-filter: blur(5px);
|
||||
border-radius: 15px;
|
||||
margin: 0 !important;
|
||||
height: auto !important;
|
||||
align-self: stretch;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .am-bands-view-container {
|
||||
display: grid;
|
||||
grid-template-columns: 85px 1fr;
|
||||
gap: 5px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .sw-grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 5px;
|
||||
height: 100%;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .am-view-button,
|
||||
body.layout-modern.modern-buttons-visible .sw-grid-button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-3);
|
||||
color: var(--color-main);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .am-view-button:hover,
|
||||
body.layout-modern.modern-buttons-visible .sw-grid-button:hover {
|
||||
background-color: var(--color-4);
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .am-bands-view-container .am-view-button.active-band,
|
||||
body.layout-modern.modern-buttons-visible .am-bands-view-container .sw-grid-button.active-band {
|
||||
background-color: var(--color-5);
|
||||
color: var(--color-main);
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .am-view-button {
|
||||
font-size: 14px;
|
||||
height: 22px;
|
||||
width: 60px;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .sw-bands-fieldset,
|
||||
body.layout-modern.modern-buttons-visible .band-fieldset {
|
||||
border: 1px solid var(--color-3);
|
||||
border-bottom: none;
|
||||
border-radius: 8px;
|
||||
padding: 0px 5px 5px 5px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .sw-bands-fieldset legend,
|
||||
body.layout-modern.modern-buttons-visible .band-fieldset legend {
|
||||
color: var(--color-4);
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-button-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding-top: 3px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .tooltip,
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltip {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltip {
|
||||
line-height: 0;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 180px;
|
||||
position: absolute;
|
||||
background-color: var(--color-2);
|
||||
border: 2px solid var(--color-3);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
border-radius: 15px;
|
||||
padding: 8px;
|
||||
z-index: 1000;
|
||||
bottom: 110%;
|
||||
left: 50%;
|
||||
margin-left: -90px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
line-height: normal;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper .bs-tooltip:hover .bs-tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* B3: Loop button and frequency range for Modern Layout */
|
||||
body.layout-modern.loop-button-visible .loop-toggle-button {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: 6px;
|
||||
z-index: 5;
|
||||
width: 34px;
|
||||
height: auto;
|
||||
min-height: 22px;
|
||||
line-height: 1.2;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-3);
|
||||
color: var(--color-main);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
body.layout-modern.loop-button-visible .loop-toggle-button:hover {
|
||||
background-color: var(--color-4);
|
||||
}
|
||||
body.layout-modern.loop-button-visible .loop-toggle-button.active {
|
||||
background-color: var(--color-5) !important;
|
||||
color: var(--color-main);
|
||||
}
|
||||
body.layout-modern.band-range-visible #band-range-container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
body.layout-modern.band-range-visible .band-range-part {
|
||||
cursor: pointer;
|
||||
}
|
||||
body.layout-modern.band-range-visible .band-range-part:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* B4: Mobile View for Modern Layout */
|
||||
@media (max-width: 768px) {
|
||||
body.layout-modern.modern-buttons-visible .band-selector-layout-wrapper {
|
||||
display: block !important;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible .side-band-button-container,
|
||||
body.layout-modern.modern-buttons-visible .am-bands-view-container {
|
||||
display: none !important;
|
||||
}
|
||||
body.layout-modern #band-range-container,
|
||||
body.layout-modern .loop-toggle-button {
|
||||
display: none !important;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 0 18px 10px 18px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container::before,
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container > .dropdown,
|
||||
body.layout-modern.modern-buttons-visible #mobile-band-selector-wrapper,
|
||||
body.layout-modern.modern-buttons-visible #mobile-sw-band-selector-wrapper {
|
||||
width: 45% !important;
|
||||
flex: 0 1 auto !important;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container.sw-mode-active > .dropdown,
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container.sw-mode-active > #mobile-band-selector-wrapper,
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container.sw-mode-active > #mobile-sw-band-selector-wrapper {
|
||||
width: 30% !important;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #mobile-sw-band-selector-wrapper {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container:has(#mobile-band-selector-wrapper:only-child)::before,
|
||||
body.layout-modern.modern-buttons-visible #data-ant-container:has(#mobile-band-selector-wrapper:only-child)::after {
|
||||
display: none;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #mobile-band-selector-wrapper:only-child {
|
||||
width: 50% !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
body.layout-modern.modern-buttons-visible #mobile-band-selector,
|
||||
body.layout-modern.modern-buttons-visible #mobile-sw-band-selector {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background-color: var(--color-4);
|
||||
color: var(--color-main);
|
||||
border: none;
|
||||
border-radius: 0 0 15px 15px;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
padding: 0 10px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data-image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ========================================================================== */
|
||||
/* C: CLASSIC LAYOUT */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* C1: Basic structure for Classic Layout */
|
||||
body.layout-classic .band-selector-button {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body.layout-classic #freq-container {
|
||||
position: relative !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
body.layout-classic .plugin-top-container {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
body.layout-classic .main-bands-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
body.layout-classic .main-band-button.active-band,
|
||||
body.layout-classic .sw-band-button.active-band,
|
||||
body.layout-classic #loop-toggle-button.active {
|
||||
background-color: var(--color-4);
|
||||
color: var(--color-main);
|
||||
opacity: 1;
|
||||
}
|
||||
body.layout-classic .sw-bands-container {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
body.layout-classic .sw-bands-grid {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
body.layout-classic .sw-bands-top-wrapper {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
body.layout-classic .sw-bands-bottom-wrapper {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
body.layout-classic .sw-band-button {
|
||||
padding: 1px 2px;
|
||||
text-align: center;
|
||||
}
|
||||
body.layout-classic #band-range-container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body.layout-classic .band-range-part {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
body.layout-classic .band-range-part:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
body.layout-classic .range-separator {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* C2: Mobile view for Classic Layout */
|
||||
@media (max-width: 768px) {
|
||||
body.layout-classic .plugin-top-container,
|
||||
body.layout-classic .sw-bands-container,
|
||||
body.layout-classic #band-range-container {
|
||||
display: none !important;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls {
|
||||
display: flex !important;
|
||||
gap: 5px;
|
||||
padding: 0 18px 10px 18px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls::before,
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls > .dropdown,
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls #mobile-band-selector-wrapper,
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls #mobile-sw-band-selector-wrapper {
|
||||
width: 45% !important;
|
||||
flex: 0 1 auto !important;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls.sw-mode-active > .dropdown,
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls.sw-mode-active > #mobile-band-selector-wrapper,
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls.sw-mode-active > #mobile-sw-band-selector-wrapper {
|
||||
width: 30% !important;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls #mobile-sw-band-selector-wrapper {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls:has(#mobile-band-selector-wrapper:only-child)::before,
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls:has(#mobile-band-selector-wrapper:only-child)::after {
|
||||
display: none;
|
||||
}
|
||||
body.layout-classic #data-ant-container.classic-mobile-controls #mobile-band-selector-wrapper:only-child {
|
||||
width: 50% !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
body.layout-classic #mobile-band-selector,
|
||||
body.layout-classic #mobile-sw-band-selector {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background-color: var(--color-4);
|
||||
color: var(--color-main);
|
||||
border: none;
|
||||
border-radius: 0 0 15px 15px;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
padding: 0 10px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data-image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* FIX FOR BAND RANGE (Start <-> End) OVERLAP PÅ MOBIL / SMÅ SKJERMER */
|
||||
/* ========================================================================== */
|
||||
|
||||
#band-range-container {
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
white-space: nowrap !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.band-range-part {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#band-range-container {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.band-range-unit {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#magic-eye-wrapper { display: none !important; }
|
||||
.magic-eye-panel-override { display: block !important; }
|
||||
.magic-eye-text-wrapper { display: contents !important; }
|
||||
}
|
||||
.magic-eye-hidden { display: none !important; }
|
||||
|
||||
.bw-option-selected {
|
||||
background-color: var(--color-4) !important;
|
||||
color: #000 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* D: ANALOG SCALE HIDE FIX (THE 1x1 PIXEL HACK) */
|
||||
/* ========================================================================== */
|
||||
body.et-analog-active #sdr-graph {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
z-index: 1 !important; /* Ligger skjult rett bak skalaen */
|
||||
opacity: 1 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/* Skjuler ALT ANNET som krever ytelse, inkludert det originale runde signal-canvaset! */
|
||||
body.et-analog-active #signal-canvas,
|
||||
body.et-analog-active #Antenna,
|
||||
body.et-analog-active #containerRotator,
|
||||
body.et-analog-active #sdr-graph-button-container,
|
||||
body.et-analog-active .spectrum-graph-update-text,
|
||||
body.et-analog-active #mm-mpx-combo-flex,
|
||||
body.et-analog-active #mm-signal-analyzer-flex,
|
||||
body.et-analog-active #mm-scope-flex {
|
||||
display: none !important;
|
||||
}
|
||||
+7
-7
@@ -101,15 +101,15 @@ li.active {
|
||||
height: 100%;
|
||||
z-index: 1000; /* Ensure it's above other content */
|
||||
transition: margin-left 0.3s ease; /* Smooth transition */
|
||||
}
|
||||
|
||||
.admin-wrapper {
|
||||
transition: margin-left 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-wrapper > .panel-full > .panel-full {
|
||||
.admin-wrapper {
|
||||
transition: margin-left 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-wrapper > .panel-full > .panel-full {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
#map {
|
||||
height:400px;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
Helpers is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var isAndroid;
|
||||
var isIOS;
|
||||
var isIPadOS;
|
||||
var isWindows;
|
||||
var isLinux;
|
||||
var isBSD;
|
||||
var isMacOSX;
|
||||
var isInternetExplorer;
|
||||
var isEdge;
|
||||
;
|
||||
var isSafari;
|
||||
;
|
||||
var isOpera;
|
||||
;
|
||||
var isChrome;
|
||||
;
|
||||
var isFirefox;
|
||||
;
|
||||
var webkitVer;
|
||||
var isNativeChrome;
|
||||
;
|
||||
var BrowserName;
|
||||
var OSName;
|
||||
{
|
||||
var ua = navigator.userAgent.toLowerCase();
|
||||
isAndroid = (ua.match('android') ? true : false);
|
||||
isIOS = (ua.match(/(iphone|ipod)/g) ? true : false);
|
||||
isIPadOS = ((ua.match('ipad') || (navigator.platform == 'MacIntel' && navigator.maxTouchPoints > 1)) ? true : false);
|
||||
isWindows = (ua.match('windows') ? true : false);
|
||||
isLinux = (ua.match('android') ? false : (ua.match('linux') ? true : false));
|
||||
isBSD = (ua.match('bsd') ? true : false);
|
||||
isMacOSX = !isIOS && !isIPadOS && (ua.match('mac os x') ? true : false);
|
||||
isInternetExplorer = (ua.match('msie') ? true : false);
|
||||
isEdge = (ua.match('edg') ? true : false);
|
||||
isSafari = (ua.match(/(chromium|chrome|crios)/g) ? false : (ua.match('safari') ? true : false));
|
||||
isOpera = (ua.match('opera') ? true : false);
|
||||
isChrome = !isSafari && (ua.match(/(chromium|chrome|crios)/g) ? true : false);
|
||||
isFirefox = (ua.match('like gecko') ? false : (ua.match(/(gecko|fennec|firefox)/g) ? true : false));
|
||||
webkitVer = parseInt((/WebKit\/([0-9]+)/.exec(navigator.appVersion) || ["", "0"])[1], 10) || void 0; // also match AppleWebKit
|
||||
isNativeChrome = isAndroid && webkitVer <= 537 && navigator.vendor.toLowerCase().indexOf('google') == 0;
|
||||
BrowserName = "Unknown";
|
||||
if (isInternetExplorer)
|
||||
BrowserName = "IE";
|
||||
else if (isEdge)
|
||||
BrowserName = "Edge";
|
||||
else if (isSafari)
|
||||
BrowserName = "Safari";
|
||||
else if (isOpera)
|
||||
BrowserName = "Opera";
|
||||
else if (isChrome)
|
||||
BrowserName = "Chrome";
|
||||
else if (isFirefox)
|
||||
BrowserName = "Firefox";
|
||||
else if (isNativeChrome)
|
||||
BrowserName = "NativeChrome";
|
||||
else
|
||||
BrowserName = "Unknown";
|
||||
OSName = "Unknown";
|
||||
if (isAndroid)
|
||||
OSName = "Android";
|
||||
else if (isIOS)
|
||||
OSName = "iOS";
|
||||
else if (isIPadOS)
|
||||
OSName = "iPadOS";
|
||||
else if (isWindows)
|
||||
OSName = "Windows";
|
||||
else if (isLinux)
|
||||
OSName = "Linux";
|
||||
else if (isBSD)
|
||||
OSName = "BSD";
|
||||
else if (isMacOSX)
|
||||
OSName = "MacOSX";
|
||||
else
|
||||
OSName = "Unknown";
|
||||
}
|
||||
;
|
||||
var WakeLock = /** @class */ (function () {
|
||||
function WakeLock(logger) {
|
||||
this.Logger = logger;
|
||||
this.Logger.Log("Preparing WakeLock");
|
||||
if (typeof navigator.wakeLock == "undefined") {
|
||||
this.Logger.Log("Using video loop method.");
|
||||
var video = document.createElement('video');
|
||||
video.setAttribute('loop', '');
|
||||
video.setAttribute('style', 'position: fixed; opacity: 0.1; pointer-events: none;');
|
||||
WakeLock.AddSourceToVideo(video, 'webm', 'data:video/webm;base64,' + WakeLock.VideoWebm);
|
||||
WakeLock.AddSourceToVideo(video, 'mp4', 'data:video/mp4;base64,' + WakeLock.VideoMp4);
|
||||
document.body.appendChild(video);
|
||||
this.LockElement = video;
|
||||
}
|
||||
else {
|
||||
this.Logger.Log("Using WakeLock API.");
|
||||
this.LockElement = null;
|
||||
}
|
||||
}
|
||||
WakeLock.prototype.Begin = function () {
|
||||
var _this = this;
|
||||
if (this.LockElement == null) {
|
||||
try {
|
||||
navigator.wakeLock.request("screen").then(function (obj) {
|
||||
_this.Logger.Log("WakeLock request successful. Lock acquired.");
|
||||
_this.LockElement = obj; // Not an audio/video element
|
||||
console.log("WakeLock request successful.");
|
||||
}, function () {
|
||||
_this.Logger.Log("WakeLock request failed.");
|
||||
console.log("WakeLock request failed.");
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
this.Logger.Log("WakeLock request failed.");
|
||||
console.log("WakeLock request failed.");
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.Logger.Log("WakeLock video loop started.");
|
||||
|
||||
// Ensure it's an audio/video element before calling play()
|
||||
if (_this.LockElement instanceof HTMLMediaElement) {
|
||||
_this.LockElement.play().catch(err => {
|
||||
console.error("LockElement failed:", err);
|
||||
});
|
||||
} else {
|
||||
console.warn("LockElement not a media element or already assigned.");
|
||||
}
|
||||
}
|
||||
};
|
||||
WakeLock.AddSourceToVideo = function (element, type, dataURI) {
|
||||
var source = document.createElement('source');
|
||||
source.src = dataURI;
|
||||
source.type = 'video/' + type;
|
||||
element.appendChild(source);
|
||||
};
|
||||
WakeLock.VideoWebm = 'GkXfo0AgQoaBAUL3gQFC8oEEQvOBCEKCQAR3ZWJtQoeBAkKFgQIYU4BnQI0VSalmQCgq17FAAw9CQE2AQAZ3aGFtbXlXQUAGd2hhbW15RIlACECPQAAAAAAAFlSua0AxrkAu14EBY8WBAZyBACK1nEADdW5khkAFVl9WUDglhohAA1ZQOIOBAeBABrCBCLqBCB9DtnVAIueBAKNAHIEAAIAwAQCdASoIAAgAAUAmJaQAA3AA/vz0AAA=';
|
||||
WakeLock.VideoMp4 = 'AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAAG21kYXQAAAGzABAHAAABthADAowdbb9/AAAC6W1vb3YAAABsbXZoZAAAAAB8JbCAfCWwgAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAIVdHJhawAAAFx0a2hkAAAAD3wlsIB8JbCAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAIAAAACAAAAAABsW1kaWEAAAAgbWRoZAAAAAB8JbCAfCWwgAAAA+gAAAAAVcQAAAAAAC1oZGxyAAAAAAAAAAB2aWRlAAAAAAAAAAAAAAAAVmlkZW9IYW5kbGVyAAAAAVxtaW5mAAAAFHZtaGQAAAABAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAEcc3RibAAAALhzdHNkAAAAAAAAAAEAAACobXA0dgAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAIAAgASAAAAEgAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABj//wAAAFJlc2RzAAAAAANEAAEABDwgEQAAAAADDUAAAAAABS0AAAGwAQAAAbWJEwAAAQAAAAEgAMSNiB9FAEQBFGMAAAGyTGF2YzUyLjg3LjQGAQIAAAAYc3R0cwAAAAAAAAABAAAAAQAAAAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAAAEwAAAAEAAAAUc3RjbwAAAAAAAAABAAAALAAAAGB1ZHRhAAAAWG1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAAK2lsc3QAAAAjqXRvbwAAABtkYXRhAAAAAQAAAABMYXZmNTIuNzguMw==';
|
||||
return WakeLock;
|
||||
}());
|
||||
+29
-29
@@ -1,47 +1,47 @@
|
||||
|
||||
function tuneUp() {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
getCurrentFreq();
|
||||
let addVal = 0;
|
||||
if (currentFreq < 0.52) addVal = 9 - (Math.round(currentFreq*1000) % 9);
|
||||
else if (currentFreq < 1.71) {
|
||||
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting
|
||||
addVal = 9 - (Math.round(currentFreq*1000) % 9);
|
||||
} else if (currentFreq < 29.6) addVal = 5 - (Math.round(currentFreq*1000) % 5);
|
||||
else if (currentFreq >= 65.9 && currentFreq < 74) addVal = 30 - ((Math.round(currentFreq*1000) - 65900) % 30);
|
||||
else addVal = 100 - (Math.round(currentFreq*1000) % 100);
|
||||
socket.send("T" + (Math.round(currentFreq*1000) + addVal));
|
||||
}
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
getCurrentFreq();
|
||||
let addVal = 0;
|
||||
if (currentFreq < 0.52) addVal = 9 - (Math.round(currentFreq*1000) % 9);
|
||||
else if (currentFreq < 1.71) {
|
||||
var step = 9;
|
||||
if(localStorage.getItem('rdsMode') == 'true') step = 10
|
||||
addVal = step - (Math.round(currentFreq*1000) % step);
|
||||
} else if (currentFreq < 29.6) addVal = 5 - (Math.round(currentFreq*1000) % 5);
|
||||
else if (currentFreq >= 65.9 && currentFreq < 74) addVal = 30 - ((Math.round(currentFreq*1000) - 65900) % 30);
|
||||
else addVal = 100 - (Math.round(currentFreq*1000) % 100);
|
||||
socket.send("T" + (Math.round(currentFreq*1000) + addVal));
|
||||
}
|
||||
|
||||
function tuneDown() {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
getCurrentFreq();
|
||||
let subVal = 0;
|
||||
if (currentFreq < 0.52) subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
|
||||
else if (currentFreq < 1.71) {
|
||||
// TODO: Rework to replace 9 with 9 or 10 based on regionalisation setting (Americans use 10, because of dumbfuckinstan)
|
||||
subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
|
||||
} else if (currentFreq < 29.6) subVal = (Math.round(currentFreq*1000) % 5 == 0) ? 5 : (Math.round(currentFreq*1000) % 5);
|
||||
else if (currentFreq > 65.9 && currentFreq <= 74) subVal = ((Math.round(currentFreq*1000) - 65900) % 30 == 0) ? 30 : ((Math.round(currentFreq*1000) - 65900) % 30);
|
||||
else subVal = (Math.round(currentFreq*1000) % 100 == 0) ? 100 : (Math.round(currentFreq*1000) % 100);
|
||||
socket.send("T" + (Math.round(currentFreq*1000) - subVal));
|
||||
}
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
getCurrentFreq();
|
||||
let subVal = 0;
|
||||
if (currentFreq < 0.52) subVal = (Math.round(currentFreq*1000) % 9 == 0) ? 9 : (Math.round(currentFreq*1000) % 9);
|
||||
else if (currentFreq < 1.71) {
|
||||
var step = 9;
|
||||
if(localStorage.getItem('rdsMode') == 'true') step = 10
|
||||
subVal = (Math.round(currentFreq*1000) % step == 0) ? step : (Math.round(currentFreq*1000) % step);
|
||||
} else if (currentFreq < 29.6) subVal = (Math.round(currentFreq*1000) % 5 == 0) ? 5 : (Math.round(currentFreq*1000) % 5);
|
||||
else if (currentFreq > 65.9 && currentFreq <= 74) subVal = ((Math.round(currentFreq*1000) - 65900) % 30 == 0) ? 30 : ((Math.round(currentFreq*1000) - 65900) % 30);
|
||||
else subVal = (Math.round(currentFreq*1000) % 100 == 0) ? 100 : (Math.round(currentFreq*1000) % 100);
|
||||
socket.send("T" + (Math.round(currentFreq*1000) - subVal));
|
||||
}
|
||||
|
||||
function tuneTo(freq) {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
previousFreq = getCurrentFreq();
|
||||
socket.send("T" + ((parseFloat(freq)) * 1000).toFixed(0));
|
||||
}
|
||||
|
||||
function resetRDS() {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
socket.send("T0");
|
||||
}
|
||||
|
||||
function getCurrentFreq() {
|
||||
currentFreq = $('#data-frequency').text();
|
||||
currentFreq = parseFloat(currentFreq).toFixed(3);
|
||||
currentFreq = parseFloat(currentFreq);
|
||||
|
||||
currentFreq = Math.round(parseFloat($('#data-frequency').text()) * 1000) / 1000;
|
||||
return currentFreq;
|
||||
}
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
class WebSocketAudioPlayer {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.ws = null;
|
||||
this.audio = null;
|
||||
this.mediaSource = null;
|
||||
this.sourceBuffer = null;
|
||||
this.queue = [];
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.audio = new Audio();
|
||||
this.mediaSource = new MediaSource();
|
||||
this.audio.src = URL.createObjectURL(this.mediaSource);
|
||||
|
||||
this.mediaSource.addEventListener('sourceopen', () => {
|
||||
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg');
|
||||
this.sourceBuffer.addEventListener('updateend', () => this._appendNext());
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.onopen = () => this.ws.send(JSON.stringify({ type: 'fallback', data: 'mp3' }));
|
||||
this.ws.onmessage = (event) => {
|
||||
this.queue.push(event.data);
|
||||
this._appendNext();
|
||||
if (!this.started && this.queue.length === 0) { this.audio.play(); this.started = true; }
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
if (this.mediaSource.readyState === 'open') this.mediaSource.endOfStream();
|
||||
};
|
||||
});
|
||||
|
||||
this.audio.addEventListener('canplay', () => {
|
||||
if (!this.started) { this.audio.play(); this.started = true; }
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.ws?.close();
|
||||
this.audio?.pause();
|
||||
this.queue = [];
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
setVolume(v) {
|
||||
if (this.audio) this.audio.volume = Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
_appendNext() {
|
||||
if (this.sourceBuffer.updating || this.queue.length === 0) return;
|
||||
this.sourceBuffer.appendBuffer(this.queue.shift());
|
||||
}
|
||||
}
|
||||
|
||||
let Stream = null;
|
||||
let shouldReconnect = true;
|
||||
|
||||
function Init(_ev) {
|
||||
$(".playbutton").off('click').on('click', OnPlayButtonClick);
|
||||
$("#volumeSlider").off("input").on("input", updateVolume);
|
||||
}
|
||||
|
||||
function createStream() {
|
||||
try {
|
||||
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const audioUrl = `${wsProtocol}//${location.hostname}/audio`;
|
||||
Stream = new WebSocketAudioPlayer(audioUrl);
|
||||
Stream.setVolume($('#volumeSlider').val());
|
||||
} catch (error) {
|
||||
console.error("Initialization Error: ", error);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyStream() {
|
||||
if (Stream) {
|
||||
Stream.stop();
|
||||
Stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function OnPlayButtonClick(_ev) {
|
||||
const $playbutton = $('.playbutton');
|
||||
const isAppleiOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
if (Stream) {
|
||||
console.log("Stopping stream...");
|
||||
shouldReconnect = false;
|
||||
destroyStream();
|
||||
$playbutton.find('.fa-solid').toggleClass('fa-stop fa-play');
|
||||
if (isAppleiOS && 'audioSession' in navigator) navigator.audioSession.type = "none";
|
||||
} else {
|
||||
console.log("Starting stream...");
|
||||
shouldReconnect = true;
|
||||
createStream();
|
||||
Stream.start();
|
||||
$playbutton.find('.fa-solid').toggleClass('fa-play fa-stop');
|
||||
if (isAppleiOS && 'audioSession' in navigator) navigator.audioSession.type = "playback";
|
||||
}
|
||||
|
||||
$playbutton.addClass('bg-gray').prop('disabled', true);
|
||||
setTimeout(() => $playbutton.removeClass('bg-gray').prop('disabled', false), 3000);
|
||||
}
|
||||
|
||||
function updateVolume() {
|
||||
const newVolume = $(this).val();
|
||||
if (Stream) Stream.setVolume(newVolume);
|
||||
else console.warn("Stream is not initialized.");
|
||||
}
|
||||
|
||||
$(document).ready(Init);
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
let Stream;
|
||||
let shouldReconnect = true;
|
||||
let audioStreamRestartInterval;
|
||||
|
||||
function Init() {
|
||||
$('.playbutton').off('click').on('click', OnPlayButtonClick);
|
||||
$('#volumeSlider').off('input').on('input', updateVolume);
|
||||
}
|
||||
|
||||
function createStream() {
|
||||
try {
|
||||
Stream = new AudioWsMp3Player();
|
||||
Stream.Volume = $('#volumeSlider').val();
|
||||
} catch (e) {
|
||||
console.error('Audio init error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyStream() {
|
||||
if (Stream) {
|
||||
Stream.Reset();
|
||||
Stream = null;
|
||||
}
|
||||
if (audioStreamRestartInterval) {
|
||||
clearInterval(audioStreamRestartInterval);
|
||||
audioStreamRestartInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function OnPlayButtonClick() {
|
||||
const $btn = $('.playbutton');
|
||||
const isAppleiOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
if (Stream) {
|
||||
shouldReconnect = false;
|
||||
destroyStream();
|
||||
$btn.find('.fa-solid').toggleClass('fa-stop fa-play');
|
||||
if (isAppleiOS && 'audioSession' in navigator) {
|
||||
navigator.audioSession.type = 'none';
|
||||
}
|
||||
} else {
|
||||
shouldReconnect = true;
|
||||
createStream();
|
||||
Stream.Start();
|
||||
$btn.find('.fa-solid').toggleClass('fa-play fa-stop');
|
||||
if (isAppleiOS && 'audioSession' in navigator) {
|
||||
navigator.audioSession.type = 'playback';
|
||||
}
|
||||
if (audioStreamRestartInterval) clearInterval(audioStreamRestartInterval);
|
||||
audioStreamRestartInterval = setInterval(() => {
|
||||
if (typeof requiresAudioStreamRestart !== 'undefined' && requiresAudioStreamRestart && Stream) {
|
||||
requiresAudioStreamRestart = false;
|
||||
Stream.Reset();
|
||||
Stream.Start();
|
||||
console.log('Audio stream restarted after radio data loss.');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
$btn.addClass('bg-gray').prop('disabled', true);
|
||||
setTimeout(() => $btn.removeClass('bg-gray').prop('disabled', false), 3000);
|
||||
}
|
||||
|
||||
function updateVolume() {
|
||||
if (Stream) {
|
||||
Stream.Volume = $(this).val();
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(Init);
|
||||
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
* Parts of this file are derived from 3LAS (Low Latency Live Audio Streaming)
|
||||
* by JoJoBond — https://github.com/JoJoBond/3LAS
|
||||
* Licensed under GPL-2.0 (https://www.gnu.org/licenses/old-licenses/gpl-2.0.html)
|
||||
*
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
class AudioWsMp3Player {
|
||||
constructor() {
|
||||
this._audioCtx = null;
|
||||
this._gainNode = null;
|
||||
this._ws = null;
|
||||
this._dataBuffer = new Uint8Array(0);
|
||||
this._frames = [];
|
||||
this._frameStartIdx = -1;
|
||||
this._frameEndIdx = -1;
|
||||
this._frameSamples = 0;
|
||||
this._frameSampleRate = 0;
|
||||
this._timeBudget = 0;
|
||||
this._nextPlayTime = 0;
|
||||
this._volume = 1.0;
|
||||
this._watchdogTimer = null;
|
||||
this._shouldReconnect = false;
|
||||
this._decoding = false;
|
||||
this._lastDecodeTime = 0;
|
||||
}
|
||||
|
||||
get Volume() {
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
set Volume(value) {
|
||||
this._volume = Number(value);
|
||||
if (this._gainNode && this._audioCtx) {
|
||||
const clamped = Math.max(1e-20, Math.min(1.0, this._volume));
|
||||
this._gainNode.gain.cancelScheduledValues(this._audioCtx.currentTime);
|
||||
this._gainNode.gain.exponentialRampToValueAtTime(clamped, this._audioCtx.currentTime + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
Start() {
|
||||
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this._gainNode = this._audioCtx.createGain();
|
||||
this._gainNode.gain.value = Math.max(1e-20, Math.min(1.0, this._volume));
|
||||
this._gainNode.connect(this._audioCtx.destination);
|
||||
this._nextPlayTime = 0;
|
||||
this._dataBuffer = new Uint8Array(0);
|
||||
this._frames = [];
|
||||
this._frameStartIdx = -1;
|
||||
this._frameEndIdx = -1;
|
||||
this._timeBudget = 0;
|
||||
this._shouldReconnect = true;
|
||||
this._lastDecodeTime = performance.now();
|
||||
this._mobileUnmute();
|
||||
this._connect();
|
||||
}
|
||||
|
||||
Reset() {
|
||||
this._shouldReconnect = false;
|
||||
this._stopWatchdog();
|
||||
if (this._ws) {
|
||||
const ws = this._ws;
|
||||
this._ws = null;
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
if (this._audioCtx) {
|
||||
this._audioCtx.close();
|
||||
this._audioCtx = null;
|
||||
}
|
||||
this._gainNode = null;
|
||||
this._dataBuffer = new Uint8Array(0);
|
||||
this._frames = [];
|
||||
this._nextPlayTime = 0;
|
||||
this._timeBudget = 0;
|
||||
this._decoding = false;
|
||||
}
|
||||
|
||||
// Mobile workaround - play silent stereo buffer to unblock AudioContext.
|
||||
_mobileUnmute() {
|
||||
if (!this._audioCtx) return;
|
||||
const gain = this._audioCtx.createGain();
|
||||
gain.gain.value = 1.0;
|
||||
gain.connect(this._audioCtx.destination);
|
||||
const silentBuf = this._audioCtx.createBuffer(2, this._audioCtx.sampleRate, this._audioCtx.sampleRate);
|
||||
const src = this._audioCtx.createBufferSource();
|
||||
src.onended = () => { src.disconnect(); gain.disconnect(); };
|
||||
src.buffer = silentBuf;
|
||||
src.connect(gain);
|
||||
src.start();
|
||||
}
|
||||
|
||||
// Open the websocket for audio and set up handlers.
|
||||
_connect() {
|
||||
if (!this._shouldReconnect) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this._ws = new WebSocket(`${protocol}//${location.host}${location.pathname}audio`);
|
||||
this._ws.binaryType = 'arraybuffer';
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this._lastDecodeTime = performance.now();
|
||||
this._startWatchdog();
|
||||
if (this._audioCtx && this._audioCtx.state === 'suspended') {
|
||||
this._audioCtx.resume();
|
||||
}
|
||||
this._mobileUnmute();
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
const chunk = new Uint8Array(event.data);
|
||||
const merged = new Uint8Array(this._dataBuffer.length + chunk.length);
|
||||
merged.set(this._dataBuffer);
|
||||
merged.set(chunk, this._dataBuffer.length);
|
||||
this._dataBuffer = merged;
|
||||
this._extractAndSchedule();
|
||||
};
|
||||
|
||||
this._ws.onclose = () => {
|
||||
this._stopWatchdog();
|
||||
this._ws = null;
|
||||
if (this._shouldReconnect) {
|
||||
this._dataBuffer = new Uint8Array(0);
|
||||
this._frames = [];
|
||||
this._frameStartIdx = -1;
|
||||
this._frameEndIdx = -1;
|
||||
this._nextPlayTime = 0;
|
||||
this._decoding = false;
|
||||
setTimeout(() => this._connect(), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
if (this._ws) this._ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
// Check every 3 seconds if the connection has died, and if so schedule a re-connect.
|
||||
_startWatchdog() {
|
||||
this._stopWatchdog();
|
||||
this._watchdogTimer = setInterval(() => {
|
||||
if (!this._shouldReconnect) { this._stopWatchdog(); return; }
|
||||
if (performance.now() - this._lastDecodeTime > 2000) {
|
||||
this._stopWatchdog();
|
||||
if (this._ws) {
|
||||
const ws = this._ws;
|
||||
this._ws = null;
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
this._dataBuffer = new Uint8Array(0);
|
||||
this._frames = [];
|
||||
this._frameStartIdx = -1;
|
||||
this._frameEndIdx = -1;
|
||||
this._nextPlayTime = 0;
|
||||
this._decoding = false;
|
||||
setTimeout(() => this._connect(), 1000);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Clear any pre-existing re-connect check
|
||||
_stopWatchdog() {
|
||||
if (this._watchdogTimer !== null) {
|
||||
clearInterval(this._watchdogTimer);
|
||||
this._watchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Find an MP3 frame start and its length in the data buffer, by searching byte-by-byte for the MPEG sync word if the start byte is not known.
|
||||
_findFrame() {
|
||||
if (this._frameStartIdx < 0) {
|
||||
for (let i = 0; i + 1 < this._dataBuffer.length; i++) {
|
||||
if (this._dataBuffer[i] === 0xFF && (this._dataBuffer[i + 1] & 0xE0) === 0xE0) {
|
||||
this._frameStartIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._frameStartIdx >= 0 && this._frameEndIdx < 0) {
|
||||
if (this._frameStartIdx + 3 < this._dataBuffer.length) {
|
||||
const b1 = this._dataBuffer[this._frameStartIdx + 1];
|
||||
const b2 = this._dataBuffer[this._frameStartIdx + 2];
|
||||
const ver = (b1 & 0x18) >>> 3;
|
||||
const lyr = (b1 & 0x06) >>> 1;
|
||||
const pad = (b2 & 0x02) >>> 1;
|
||||
const brx = (b2 & 0xF0) >>> 4;
|
||||
const srx = (b2 & 0x0C) >>> 2;
|
||||
const bitrate = AudioWsMp3Player.MPEG_bitrates[ver][lyr][brx] * 1000;
|
||||
const samprate = AudioWsMp3Player.MPEG_srates[ver][srx];
|
||||
const samples = AudioWsMp3Player.MPEG_frame_samples[ver][lyr];
|
||||
const slotSize = AudioWsMp3Player.MPEG_slot_size[lyr];
|
||||
if (bitrate > 0 && samprate > 0) {
|
||||
this._frameSamples = samples;
|
||||
this._frameSampleRate = samprate;
|
||||
this._frameEndIdx = this._frameStartIdx + Math.floor((samples / 8.0 * bitrate) / samprate + (pad ? slotSize : 0));
|
||||
} else {
|
||||
this._frameStartIdx = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a complete frame boundary has been found and enough bytes have arrived in the buffer to extract.
|
||||
_canExtractFrame() {
|
||||
return this._frameStartIdx >= 0 && this._frameEndIdx >= 0 && this._frameEndIdx <= this._dataBuffer.length;
|
||||
}
|
||||
|
||||
// Extract the MPEG audio frame from the buffer, and reset the data buffer indexes ready for the next frame.
|
||||
_extractFrame() {
|
||||
const frameData = new Uint8Array(this._dataBuffer.buffer.slice(this._frameStartIdx, this._frameEndIdx));
|
||||
this._dataBuffer = this._frameEndIdx + 1 < this._dataBuffer.length
|
||||
? new Uint8Array(this._dataBuffer.buffer.slice(this._frameEndIdx))
|
||||
: new Uint8Array(0);
|
||||
this._frameStartIdx = 0;
|
||||
this._frameEndIdx = -1;
|
||||
return { data: frameData, sampleCount: this._frameSamples, sampleRate: this._frameSampleRate };
|
||||
}
|
||||
|
||||
// Central processing loop to find all frames within the incoming buffer data each time a websocket message is received.
|
||||
_extractAndSchedule() {
|
||||
if (!this._audioCtx) return;
|
||||
this._findFrame();
|
||||
while (this._canExtractFrame()) {
|
||||
this._frames.push(this._extractFrame());
|
||||
this._findFrame();
|
||||
}
|
||||
if (this._decoding || this._frames.length < 3) return;
|
||||
|
||||
const first = this._frames[0];
|
||||
const last = this._frames[this._frames.length - 1];
|
||||
const firstGranule = first.sampleCount / first.sampleRate / 2.0;
|
||||
const lastGranule = last.sampleCount / last.sampleRate / 2.0;
|
||||
let expectedTime = firstGranule + lastGranule;
|
||||
let bufLen = first.data.length + last.data.length;
|
||||
for (let i = 1; i < this._frames.length - 1; i++) {
|
||||
expectedTime += this._frames[i].sampleCount / this._frames[i].sampleRate;
|
||||
bufLen += this._frames[i].data.length;
|
||||
}
|
||||
if (this._nextPlayTime !== 0 && this._nextPlayTime + expectedTime <= this._audioCtx.currentTime) return;
|
||||
|
||||
const decodeBuffer = new Uint8Array(bufLen);
|
||||
let offset = 0;
|
||||
for (const frame of this._frames) {
|
||||
decodeBuffer.set(frame.data, offset);
|
||||
offset += frame.data.length;
|
||||
}
|
||||
|
||||
this._frames.splice(0, this._frames.length - 1);
|
||||
|
||||
this._decoding = true;
|
||||
this._audioCtx.decodeAudioData(
|
||||
decodeBuffer.buffer,
|
||||
(decoded) => {
|
||||
this._decoding = false;
|
||||
this._onDecoded(decoded, expectedTime, firstGranule, lastGranule);
|
||||
this._extractAndSchedule();
|
||||
},
|
||||
() => {
|
||||
this._decoding = false;
|
||||
this._extractAndSchedule();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Each time we call to decode, trim artefacts created by decoder cold start to stop audio glitches.
|
||||
_onDecoded(decoded, expectedTime, firstGranule, lastGranule) {
|
||||
if (!this._audioCtx || !this._gainNode) return;
|
||||
this._lastDecodeTime = performance.now();
|
||||
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isApple = /iphone|ipod/.test(ua) ||
|
||||
/ipad/.test(ua) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) ||
|
||||
/mac os x/.test(ua);
|
||||
|
||||
const delta = 0.001;
|
||||
let sampleCount, sampleOffset = 0;
|
||||
|
||||
if (expectedTime > decoded.duration) {
|
||||
sampleCount = decoded.length;
|
||||
sampleOffset = 0;
|
||||
this._timeBudget += expectedTime - decoded.duration;
|
||||
} else if (expectedTime < decoded.duration) {
|
||||
sampleCount = Math.ceil(expectedTime * decoded.sampleRate);
|
||||
const budgetSamples = this._timeBudget * decoded.sampleRate;
|
||||
if (budgetSamples > 1.0) {
|
||||
const extra = Math.min(budgetSamples, decoded.length - sampleCount);
|
||||
sampleCount += extra;
|
||||
this._timeBudget -= extra / decoded.sampleRate;
|
||||
}
|
||||
const diff = decoded.duration - expectedTime;
|
||||
if ((diff - firstGranule - lastGranule) >= -delta) {
|
||||
sampleOffset = Math.floor((decoded.length - sampleCount) / 2);
|
||||
}
|
||||
if (Math.abs(firstGranule - lastGranule) <= delta) {
|
||||
sampleOffset = isApple
|
||||
? Math.floor((decoded.length - sampleCount) / 2)
|
||||
: decoded.length - sampleCount;
|
||||
} else if (Math.abs(diff - firstGranule) <= delta) {
|
||||
sampleOffset = decoded.length - sampleCount;
|
||||
} else if (Math.abs(diff - lastGranule) <= delta) {
|
||||
sampleOffset = 0;
|
||||
} else {
|
||||
sampleOffset = Math.floor((decoded.length - sampleCount) / 2);
|
||||
}
|
||||
} else {
|
||||
sampleCount = decoded.length;
|
||||
sampleOffset = 0;
|
||||
}
|
||||
|
||||
const buf = this._audioCtx.createBuffer(decoded.numberOfChannels, sampleCount, decoded.sampleRate);
|
||||
for (let ch = 0; ch < decoded.numberOfChannels; ch++) {
|
||||
buf.getChannelData(ch).set(decoded.getChannelData(ch).subarray(sampleOffset, sampleOffset + sampleCount));
|
||||
}
|
||||
this._scheduleBuffer(buf);
|
||||
}
|
||||
|
||||
// Schedule a decoded buffer for playback at the correct AudioContext time. Reset if it's falled behind (on reconnect etc.).
|
||||
_scheduleBuffer(buffer) {
|
||||
if (!this._audioCtx || !this._gainNode) return;
|
||||
if (this._audioCtx.state === 'suspended') {
|
||||
this._audioCtx.resume();
|
||||
}
|
||||
const INITIAL_OFFSET = 0.33;
|
||||
|
||||
if (this._nextPlayTime === 0) {
|
||||
this._nextPlayTime = this._audioCtx.currentTime + INITIAL_OFFSET;
|
||||
}
|
||||
|
||||
const duration = buffer.duration;
|
||||
if (this._nextPlayTime + duration <= this._audioCtx.currentTime) {
|
||||
this._nextPlayTime = this._audioCtx.currentTime + INITIAL_OFFSET;
|
||||
}
|
||||
|
||||
let skipDuration = 0;
|
||||
if (this._audioCtx.currentTime >= this._nextPlayTime) {
|
||||
skipDuration = this._audioCtx.currentTime - this._nextPlayTime + 0.05;
|
||||
}
|
||||
|
||||
if (skipDuration < duration) {
|
||||
const src = this._audioCtx.createBufferSource();
|
||||
src.loop = false;
|
||||
src.buffer = buffer;
|
||||
src.onended = () => src.disconnect();
|
||||
src.connect(this._gainNode);
|
||||
src.start(this._nextPlayTime + skipDuration, skipDuration);
|
||||
}
|
||||
this._nextPlayTime += duration;
|
||||
}
|
||||
}
|
||||
|
||||
// MPEG bitrate table [version][layer][index] in kbps
|
||||
AudioWsMp3Player.MPEG_bitrates = [
|
||||
[ // MPEG 2.5
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0],
|
||||
[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0],
|
||||
[0,32,48,56,64,80,96,112,128,144,160,176,192,224,256,0]
|
||||
],
|
||||
[ // Reserved
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
],
|
||||
[ // MPEG 2
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0],
|
||||
[0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0],
|
||||
[0,32,48,56,64,80,96,112,128,144,160,176,192,224,256,0]
|
||||
],
|
||||
[ // MPEG 1
|
||||
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
|
||||
[0,32,40,48,56,64,80,96,112,128,160,192,224,256,320,0],
|
||||
[0,32,48,56,64,80,96,112,128,160,192,224,256,320,384,0],
|
||||
[0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,0]
|
||||
]
|
||||
];
|
||||
|
||||
// Sample rates [version][index] in Hz
|
||||
AudioWsMp3Player.MPEG_srates = [
|
||||
[11025,12000,8000,0],
|
||||
[0,0,0,0],
|
||||
[22050,24000,16000,0],
|
||||
[44100,48000,32000,0]
|
||||
];
|
||||
|
||||
// Samples per frame [version][layer]
|
||||
AudioWsMp3Player.MPEG_frame_samples = [
|
||||
[0,576,1152,384],
|
||||
[0,0,0,0],
|
||||
[0,576,1152,384],
|
||||
[0,1152,1152,384]
|
||||
];
|
||||
|
||||
// Slot size [layer]: Reserved, Layer3, Layer2, Layer1
|
||||
AudioWsMp3Player.MPEG_slot_size = [0,1,1,4];
|
||||
+3
-3
@@ -24,7 +24,7 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// Load nickname from localStorage on page load
|
||||
let savedNickname = localStorage.getItem('nickname') || `User ${generateRandomString(5)}`;
|
||||
let savedNickname = localStorage.getItem('nickname') || `User ${generateRandomString(6)}`;
|
||||
chatNicknameInput.val(savedNickname);
|
||||
chatIdentityNickname.text(savedNickname);
|
||||
|
||||
@@ -60,7 +60,7 @@ $(document).ready(function() {
|
||||
|
||||
$('.chat-send-message-btn').click(sendMessage);
|
||||
chatNicknameSave.click(function() {
|
||||
const currentNickname = chatNicknameInput.val().trim() || `Anonymous User ${generateRandomString(5)}`;
|
||||
const currentNickname = chatNicknameInput.val().trim() || `Anonymous User ${generateRandomString(6)}`;
|
||||
localStorage.setItem('nickname', currentNickname);
|
||||
savedNickname = currentNickname;
|
||||
chatIdentityNickname.text(savedNickname);
|
||||
@@ -87,7 +87,7 @@ $(document).ready(function() {
|
||||
});
|
||||
|
||||
function sendMessage() {
|
||||
const nickname = savedNickname || `Anonymous User ${generateRandomString(5)}`;
|
||||
const nickname = savedNickname || `Anonymous User ${generateRandomString(6)}`;
|
||||
const message = chatSendInput.val().trim();
|
||||
|
||||
if (message) {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
let configData = {}; // Store the original data structure globally
|
||||
|
||||
$(document).ready(function() {
|
||||
fetchConfig();
|
||||
});
|
||||
|
||||
function submitConfig() {
|
||||
updateConfigData(configData);
|
||||
if ($("#password-adminPass").val().length < 1) {
|
||||
alert('You need to fill in the admin password before continuing further.');
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: './saveData',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(configData),
|
||||
success: function (message) {
|
||||
sendToast('success', 'Data saved!', message, true, true);
|
||||
},
|
||||
error: function (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchConfig() {
|
||||
$.getJSON("./getData")
|
||||
.done(data => {
|
||||
configData = data;
|
||||
populateFields(configData);
|
||||
initVolumeSlider();
|
||||
initConnectionToggle();
|
||||
})
|
||||
.fail(error => console.error("Error fetching data:", error.message));
|
||||
}
|
||||
|
||||
function populateFields(data, prefix = "") {
|
||||
$.each(data, (key, value) => {
|
||||
if (value === null) value = ""; // Convert null to an empty string
|
||||
|
||||
let id = `${prefix}${prefix ? "-" : ""}${key}`;
|
||||
const $element = $(`#${id}`);
|
||||
|
||||
if (key === "plugins" && $element.is('select[multiple]')) {
|
||||
if (Array.isArray(value)) {
|
||||
$element.find('option').each(function() {
|
||||
const $option = $(this);
|
||||
const dataName = $option.data('name');
|
||||
if (value.includes(dataName)) $option.prop('selected', true);
|
||||
else $option.prop('selected', false);
|
||||
});
|
||||
|
||||
$element.trigger('change');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
const arrayId = `${id}-${index + 1}`;
|
||||
const $arrayElement = $(`#${arrayId}`);
|
||||
|
||||
if ($arrayElement.length) $arrayElement.val(item);
|
||||
else console.log(`Element with id ${arrayId} not found`);
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
populateFields(value, id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$element.length) {
|
||||
console.log(`Element with id ${id} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
$element.prop("checked", value);
|
||||
} else if ($element.is('input[type="text"]') && $element.closest('.dropdown').length) {
|
||||
const $dropdownOption = $element.siblings('ul.options').find(`li[data-value="${value}"]`);
|
||||
$element.val($dropdownOption.length ? $dropdownOption.text() : value);
|
||||
$element.attr('data-value', value);
|
||||
} else $element.val(value);
|
||||
});
|
||||
}
|
||||
|
||||
function updateConfigData(data, prefix = "") {
|
||||
$.each(data, (key, value) => {
|
||||
const id = `${prefix}${prefix ? "-" : ""}${key}`;
|
||||
const $element = $(`#${id}`);
|
||||
|
||||
if (key === "presets") {
|
||||
data[key] = [];
|
||||
let index = 1;
|
||||
while (true) {
|
||||
const $presetElement = $(`#${prefix}${prefix ? "-" : ""}${key}-${index}`);
|
||||
if ($presetElement.length) {
|
||||
data[key].push($presetElement.val() || null); // Allow null if necessary
|
||||
index++;
|
||||
} else break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "plugins") {
|
||||
data[key] = [];
|
||||
const $selectedOptions = $element.find('option:selected');
|
||||
$selectedOptions.each(function() {
|
||||
const dataName = $(this).attr('data-name');
|
||||
if (dataName) data[key].push(dataName);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return updateConfigData(value, id);
|
||||
|
||||
if ($element.length) {
|
||||
const newValue = $element.attr("data-value") ?? $element.val() ?? null;
|
||||
data[key] = typeof value === "boolean" ? $element.is(":checked") : newValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+189
-357
@@ -1,6 +1,5 @@
|
||||
// WebSocket connection located in ./websocket.js
|
||||
|
||||
|
||||
const isMobileDevice = /android|iphone|ipod|ipad/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
var parsedData, signalChart, previousFreq;
|
||||
var data = [];
|
||||
@@ -10,7 +9,6 @@ let lastReconnectAttempt = 0;
|
||||
let messageCounter = 0; // Count for WebSocket data length returning 0
|
||||
let messageData = 800; // Initial value anything above 0
|
||||
let messageLength = 800; // Retain value of messageData until value is updated
|
||||
let pingTimeLimit = false; // WebSocket becomes unresponsive with high ping
|
||||
|
||||
const europe_programmes = [
|
||||
"No PTY", "News", "Current Affairs", "Info",
|
||||
@@ -42,11 +40,8 @@ $(document).ready(function () {
|
||||
var mouseX = e.pageX;
|
||||
var panelLeft = parseInt($panel.css('left'));
|
||||
|
||||
if (mouseX <= 10 || (panelLeft === 4 && mouseX <= 100)) {
|
||||
$panel.css('left', '4px');
|
||||
} else {
|
||||
$panel.css('left', -panelWidth);
|
||||
}
|
||||
if (mouseX <= 10 || (panelLeft === 4 && mouseX <= 100)) $panel.css('left', '4px');
|
||||
else $panel.css('left', -panelWidth);
|
||||
});
|
||||
|
||||
fillPresets();
|
||||
@@ -54,13 +49,9 @@ $(document).ready(function () {
|
||||
signalToggle.on("change", function () {
|
||||
const signalText = localStorage.getItem('signalUnit');
|
||||
|
||||
if (signalText == 'dbuv') {
|
||||
signalText.text('dBµV');
|
||||
} else if (signalText == 'dbf') {
|
||||
signalText.text('dBf');
|
||||
} else {
|
||||
signalText.text('dBm');
|
||||
}
|
||||
if (signalText == 'dbuv') signalText.text('dBµV');
|
||||
else if (signalText == 'dbf') signalText.text('dBf');
|
||||
else signalText.text('dBm');
|
||||
});
|
||||
|
||||
// Check if device is an iPhone to prevent zoom on button press
|
||||
@@ -80,11 +71,8 @@ $(document).ready(function () {
|
||||
let content = $viewportMeta.attr('content');
|
||||
let re = /maximum\-scale=[0-9\.]+/g;
|
||||
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, 'maximum-scale=1.0');
|
||||
} else {
|
||||
content += ', maximum-scale=1.0';
|
||||
}
|
||||
if (re.test(content)) content = content.replace(re, 'maximum-scale=1.0');
|
||||
else content += ', maximum-scale=1.0';
|
||||
|
||||
$viewportMeta.attr('content', content);
|
||||
}
|
||||
@@ -103,7 +91,6 @@ $(document).ready(function () {
|
||||
});
|
||||
|
||||
textInput.on('keyup', function (event) {
|
||||
|
||||
if (event.key !== 'Backspace' && localStorage.getItem('extendedFreqRange') != "true") {
|
||||
let inputValue = textInput.val();
|
||||
inputValue = inputValue.replace(/[^0-9.]/g, '');
|
||||
@@ -123,18 +110,15 @@ $(document).ready(function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
tuneTo(textInput.val());
|
||||
}
|
||||
tuneTo(textInput.val());
|
||||
textInput.val('');
|
||||
}
|
||||
});
|
||||
|
||||
document.onkeydown = function(event) {
|
||||
if (!event.repeat) {
|
||||
checkKey(event);
|
||||
}
|
||||
if (!event.repeat) checkKey(event);
|
||||
};
|
||||
|
||||
|
||||
@@ -144,10 +128,8 @@ $(document).ready(function () {
|
||||
e.preventDefault();
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastExecutionTime < throttleDelay) {
|
||||
// Ignore this event as it's within the throttle delay
|
||||
return;
|
||||
}
|
||||
// Ignore this event as it's within the throttle delay
|
||||
if (now - lastExecutionTime < throttleDelay) return;
|
||||
|
||||
lastExecutionTime = now; // Update the last execution time
|
||||
|
||||
@@ -155,16 +137,11 @@ $(document).ready(function () {
|
||||
var delta = e.originalEvent.deltaY;
|
||||
var adjustment = 0;
|
||||
|
||||
if (e.shiftKey) {
|
||||
adjustment = e.altKey ? 1 : 0.01;
|
||||
} else if (e.ctrlKey) {
|
||||
adjustment = 1;
|
||||
} else {
|
||||
if (delta > 0) {
|
||||
tuneDown();
|
||||
} else {
|
||||
tuneUp();
|
||||
}
|
||||
if (e.shiftKey) adjustment = e.altKey ? 1 : 0.01;
|
||||
else if (e.ctrlKey) adjustment = 1;
|
||||
else {
|
||||
if (delta > 0) tuneDown();
|
||||
else tuneUp();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -173,8 +150,6 @@ $(document).ready(function () {
|
||||
return false;
|
||||
});
|
||||
|
||||
setInterval(getServerTime, 10000);
|
||||
getServerTime();
|
||||
setInterval(sendPingRequest, 5000);
|
||||
sendPingRequest();
|
||||
|
||||
@@ -249,9 +224,7 @@ $(document).ready(function () {
|
||||
if(parsedData.txInfo.dist > 700) sendLog('./log_fmlist?type=tropo');
|
||||
$('.log-fmlist .mini-popup-content').removeClass('show');
|
||||
});
|
||||
} else {
|
||||
sendLog('./log_fmlist');
|
||||
}
|
||||
} else sendLog('./log_fmlist');
|
||||
|
||||
function sendLog(endpoint) {
|
||||
$.ajax({
|
||||
@@ -265,13 +238,13 @@ $(document).ready(function () {
|
||||
|
||||
switch (xhr.status) {
|
||||
case 429:
|
||||
errorMessage = xhr.responseText;
|
||||
break;
|
||||
errorMessage = xhr.responseText;
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = 'Server error: ' + (xhr.responseText || 'Internal Server Error');
|
||||
break;
|
||||
errorMessage = 'Server error: ' + (xhr.responseText || 'Internal Server Error');
|
||||
break;
|
||||
default:
|
||||
errorMessage = xhr.statusText || 'An error occurred';
|
||||
errorMessage = xhr.statusText || 'An error occurred';
|
||||
}
|
||||
|
||||
sendToast('error', 'Log failed', errorMessage, false, true);
|
||||
@@ -284,100 +257,11 @@ $(document).ready(function () {
|
||||
initTooltips();
|
||||
});
|
||||
|
||||
function getServerTime() {
|
||||
$.ajax({
|
||||
url: "./server_time",
|
||||
dataType: "json",
|
||||
success: function(data) {
|
||||
const serverTimeUtc = data.serverTime;
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
const serverOptions = {
|
||||
...options,
|
||||
timeZone: 'Etc/UTC'
|
||||
};
|
||||
|
||||
const formattedServerTime = new Date(serverTimeUtc).toLocaleString(navigator.language ? navigator.language : 'en-US', serverOptions);
|
||||
|
||||
$("#server-time").text(formattedServerTime);
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
console.error("Error fetching server time:", errorThrown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendPingRequest() {
|
||||
const timeoutDuration = 5000;
|
||||
const startTime = new Date().getTime();
|
||||
|
||||
const fetchWithTimeout = (url, options, timeout = timeoutDuration) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timerTimeout = setTimeout(() => {
|
||||
reject(new Error('Request timed out'));
|
||||
}, timeout);
|
||||
|
||||
fetch(url, options)
|
||||
.then(response => {
|
||||
clearTimeout(timerTimeout);
|
||||
resolve(response);
|
||||
})
|
||||
.catch(error => {
|
||||
clearTimeout(timerTimeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
fetchWithTimeout('./ping', { cache: 'no-store' }, timeoutDuration)
|
||||
.then(response => {
|
||||
const endTime = new Date().getTime();
|
||||
const pingTime = endTime - startTime;
|
||||
$('#current-ping').text(`Ping: ${pingTime}ms`);
|
||||
pingTimeLimit = false;
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Ping request failed');
|
||||
$('#current-ping').text(`Ping: unknown`);
|
||||
if (!pingTimeLimit) { // Force reconnection as WebSocket could be unresponsive even though it's reported as OPEN
|
||||
if (messageLength === 0) window.socket.close(1000, 'Normal closure');
|
||||
if (connectionLost) sendToast('warning', 'Connection lost', 'Attempting to reconnect...', false, false);
|
||||
console.log("Reconnecting due to high ping...");
|
||||
pingTimeLimit = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleMessage(message) {
|
||||
messageData = JSON.parse(message.data.length);
|
||||
socket.removeEventListener('message', handleMessage);
|
||||
}
|
||||
socket.addEventListener('message', handleMessage);
|
||||
messageLength = messageData;
|
||||
messageData = 0;
|
||||
|
||||
// Force reconnection if no WebSocket data after several queries
|
||||
if (messageLength === 0) {
|
||||
messageCounter++;
|
||||
if (messageCounter === 5) {
|
||||
messageCounter = 0;
|
||||
window.socket.close(1000, 'Normal closure');
|
||||
if (connectionLost) sendToast('warning', 'Connection lost', 'Attempting to reconnect...', false, false);
|
||||
console.log("Reconnecting due to no data received...");
|
||||
}
|
||||
} else {
|
||||
messageCounter = 0;
|
||||
}
|
||||
|
||||
// Automatic reconnection on WebSocket close with cooldown
|
||||
const now = Date.now();
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(`PING ${now}`);
|
||||
|
||||
if (
|
||||
(socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) &&
|
||||
(now - lastReconnectAttempt > TIMEOUT_DURATION)
|
||||
@@ -389,9 +273,7 @@ function sendPingRequest() {
|
||||
socket.onopen = () => {
|
||||
sendToast('info', 'Connected', 'Reconnected successfully!', false, false);
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
handleWebSocketMessage(event);
|
||||
};
|
||||
socket.onmessage = handleWebSocketMessage;
|
||||
socket.onerror = (error) => {
|
||||
console.error("Main/UI WebSocket error during reconnection:", error);
|
||||
};
|
||||
@@ -399,39 +281,57 @@ function sendPingRequest() {
|
||||
console.warn("Main/UI WebSocket closed during reconnection. Will attempt to reconnect...");
|
||||
};
|
||||
}
|
||||
if (connectionLost) {
|
||||
if (dataTimeout == dataTimeoutPrevious) {
|
||||
connectionLost = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
window.socket.close(1000, 'Normal closure'); // Force reconnection to unfreeze browser UI
|
||||
}, 8000); // Timeout must be higher than TIMEOUT_DURATION
|
||||
connectionLost = false;
|
||||
requiresAudioStreamRestart = true;
|
||||
console.log("Radio data restored.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(event) {
|
||||
if (event.data == 'KICK') {
|
||||
console.log('Kick initiated.')
|
||||
console.log('Kick initiated.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/403';
|
||||
}, 350);
|
||||
window.location.href = `/403?reason=${encodeURIComponent("You have been kicked")}`;
|
||||
}, 100);
|
||||
return;
|
||||
} else if (event.data.startsWith("!")) sendToast('info', 'Info from server', event.data.slice(1), false, false)
|
||||
}
|
||||
if (event.data.startsWith("!")) {
|
||||
sendToast('info', 'Info from server', event.data.slice(1), false, false);
|
||||
return;
|
||||
}
|
||||
if (event.data == "a0") {
|
||||
console.log('Too many users');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/403?reason=${encodeURIComponent("Too many users")}`;
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
if (event.data.startsWith("PING ")) {
|
||||
const sentTime = Number(event.data.substring(5));
|
||||
const ping = Date.now() - sentTime;
|
||||
|
||||
$('#current-ping').text(`Ping: ${ping}ms`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
parsedData = JSON.parse(event.data);
|
||||
|
||||
resetDataTimeout();
|
||||
updatePanels(parsedData);
|
||||
|
||||
const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0);
|
||||
const averageSignal = sum / signalData.length;
|
||||
data.push(averageSignal);
|
||||
data.push(signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0) / signalData.length);
|
||||
|
||||
if(parsedData.timestamp) {
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'Etc/UTC'
|
||||
};
|
||||
const formattedServerTime = new Date(parsedData.timestamp * 1000).toLocaleString(navigator.language ? navigator.language : 'en-US', options);
|
||||
$("#server-time").text(formattedServerTime);
|
||||
}
|
||||
}
|
||||
// Attach the message handler
|
||||
socket.onmessage = handleWebSocketMessage;
|
||||
|
||||
const signalBuffer = [];
|
||||
@@ -476,12 +376,12 @@ function initCanvas() {
|
||||
grid: { display: false, borderWidth: 0, borderColor: "transparent" },
|
||||
realtime: {
|
||||
duration: 30000,
|
||||
refresh: 75,
|
||||
refresh: 100,
|
||||
delay: 150,
|
||||
frameRate: 30, // default is 30
|
||||
frameRate: 24,
|
||||
onRefresh: (chart) => {
|
||||
if (!chart?.data?.datasets || parsedData?.sig === undefined) return;
|
||||
if ((isAndroid || isIOS || isIPadOS) && (document.hidden || !document.hasFocus())) return;
|
||||
if (isMobileDevice && (document.hidden || !document.hasFocus())) return;
|
||||
|
||||
const sig = parsedData.sig;
|
||||
signalBuffer.push(sig);
|
||||
@@ -595,18 +495,16 @@ function initCanvas() {
|
||||
}
|
||||
|
||||
function setRefreshRate(rate) {
|
||||
const rt = signalChart.options.scales.x.realtime;
|
||||
rt.refresh = rate;
|
||||
signalChart.options.scales.x.realtime.refresh = rate;
|
||||
signalChart.update('none');
|
||||
console.log(`Graph refresh rate set to ${rate} ms`);
|
||||
}
|
||||
|
||||
window.addEventListener("focus", () => {
|
||||
if (isAndroid || isIOS || isIPadOS) setRefreshRate(75);
|
||||
if(isMobileDevice) setRefreshRate(100);
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
if (isAndroid || isIOS || isIPadOS) setRefreshRate(3000);
|
||||
if(isMobileDevice) setRefreshRate(3000);
|
||||
});
|
||||
|
||||
let reconnectTimer = null;
|
||||
@@ -626,33 +524,10 @@ const resetDataTimeout = () => {
|
||||
}, TIMEOUT_DURATION);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (event.data === 'KICK') {
|
||||
console.log('Kick initiated.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/403';
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
parsedData = JSON.parse(event.data);
|
||||
|
||||
resetDataTimeout();
|
||||
updatePanels(parsedData);
|
||||
|
||||
const sum = signalData.reduce((acc, strNum) => acc + parseFloat(strNum), 0);
|
||||
const averageSignal = sum / signalData.length;
|
||||
data.push(averageSignal);
|
||||
};
|
||||
|
||||
function compareNumbers(a, b) {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
function escapeHTML(unsafeText) {
|
||||
let div = document.createElement('div');
|
||||
div.innerText = unsafeText;
|
||||
return div.innerHTML.replace(' ', ' ');
|
||||
function escapeHTML(text) {
|
||||
const div = document.createElement("div");
|
||||
div.innerText = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function processString(string, errors) {
|
||||
@@ -664,101 +539,92 @@ function processString(string, errors) {
|
||||
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
alpha = parseInt(errors[i]) * (alpha_range / (max_error + 1));
|
||||
if (alpha) {
|
||||
output += "<span style='opacity: " + (max_alpha - alpha) + "%'>" + escapeHTML(string[i]) + "</span>";
|
||||
} else {
|
||||
output += escapeHTML(string[i]);
|
||||
}
|
||||
if (alpha) output += "<span style='opacity: " + (max_alpha - alpha) + "%'>" + escapeHTML(string[i]) + "</span>";
|
||||
else output += escapeHTML(string[i]);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function checkKey(e) {
|
||||
if (socket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
e = e || window.event;
|
||||
|
||||
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
|
||||
|
||||
if ($('#password:focus').length > 0
|
||||
|| $('#chat-send-message:focus').length > 0
|
||||
|| $('#volumeSlider:focus').length > 0
|
||||
|| $('#chat-nickname:focus').length > 0
|
||||
|| $('.option:focus').length > 0) {
|
||||
return;
|
||||
}
|
||||
|| $('.option:focus').length > 0) return;
|
||||
|
||||
getCurrentFreq();
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
switch (e.keyCode) {
|
||||
case 66: // Back to previous frequency
|
||||
tuneTo(previousFreq);
|
||||
break;
|
||||
case 82: // RDS Reset (R key)
|
||||
tuneTo(Number(currentFreq));
|
||||
break;
|
||||
case 83: // Screenshot (S key)
|
||||
break;
|
||||
case 38:
|
||||
socket.send("T" + (Math.round(currentFreq*1000) + ((currentFreq > 30) ? 10 : 1)));
|
||||
break;
|
||||
case 40:
|
||||
socket.send("T" + (Math.round(currentFreq*1000) - ((currentFreq > 30) ? 10 : 1)));
|
||||
break;
|
||||
case 37:
|
||||
tuneDown();
|
||||
break;
|
||||
case 39:
|
||||
tuneUp();
|
||||
break;
|
||||
case 46:
|
||||
let $dropdown = $(".data-ant");
|
||||
let $input = $dropdown.find("input");
|
||||
let $options = $dropdown.find("ul.options .option");
|
||||
|
||||
if ($options.length === 0) return; // No antennas available
|
||||
|
||||
// Find the currently selected antenna
|
||||
let currentText = $input.val().trim();
|
||||
let currentIndex = $options.index($options.filter(function () {
|
||||
return $(this).text().trim() === currentText;
|
||||
}));
|
||||
|
||||
console.log(currentIndex, currentText);
|
||||
|
||||
// Cycle to the next option
|
||||
let nextIndex = (currentIndex + 1) % $options.length;
|
||||
let $nextOption = $options.eq(nextIndex);
|
||||
|
||||
// Update UI
|
||||
$input.attr("placeholder", $nextOption.text());
|
||||
$input.data("value", $nextOption.data("value"));
|
||||
|
||||
let socketMessage = "Z" + $nextOption.data("value");
|
||||
socket.send(socketMessage);
|
||||
switch (e.keyCode) {
|
||||
case 66: // Back to previous frequency
|
||||
tuneTo(previousFreq);
|
||||
break;
|
||||
case 112: // F1
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset1')));
|
||||
case 82: // RDS Reset (R key)
|
||||
tuneTo(Number(currentFreq));
|
||||
break;
|
||||
case 113: // F2
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset2')));
|
||||
case 83: // Screenshot (S key)
|
||||
break;
|
||||
case 114: // F3
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset3')));
|
||||
case 38:
|
||||
socket.send("T" + (Math.round(currentFreq*1000) + ((currentFreq > 30) ? 10 : 1)));
|
||||
break;
|
||||
case 115: // F4
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset4')));
|
||||
case 40:
|
||||
socket.send("T" + (Math.round(currentFreq*1000) - ((currentFreq > 30) ? 10 : 1)));
|
||||
break;
|
||||
default:
|
||||
// Handle default case if needed
|
||||
case 37:
|
||||
tuneDown();
|
||||
break;
|
||||
case 39:
|
||||
tuneUp();
|
||||
break;
|
||||
case 46:
|
||||
let $dropdown = $(".data-ant");
|
||||
let $input = $dropdown.find("input");
|
||||
let $options = $dropdown.find("ul.options .option");
|
||||
|
||||
if ($options.length === 0) return; // No antennas available
|
||||
|
||||
// Find the currently selected antenna
|
||||
let currentText = $input.val().trim();
|
||||
let currentIndex = $options.index($options.filter(function () {
|
||||
return $(this).text().trim() === currentText;
|
||||
}));
|
||||
|
||||
console.log(currentIndex, currentText);
|
||||
|
||||
// Cycle to the next option
|
||||
let nextIndex = (currentIndex + 1) % $options.length;
|
||||
let $nextOption = $options.eq(nextIndex);
|
||||
|
||||
// Update UI
|
||||
$input.attr("placeholder", $nextOption.text());
|
||||
$input.data("value", $nextOption.data("value"));
|
||||
|
||||
let socketMessage = "Z" + $nextOption.data("value");
|
||||
socket.send(socketMessage);
|
||||
break;
|
||||
case 112: // F1
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset1')));
|
||||
break;
|
||||
case 113: // F2
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset2')));
|
||||
break;
|
||||
case 114: // F3
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset3')));
|
||||
break;
|
||||
case 115: // F4
|
||||
e.preventDefault();
|
||||
tuneTo(Number(localStorage.getItem('preset4')));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,11 +660,8 @@ async function copyTx() {
|
||||
}
|
||||
|
||||
async function copyRt() {
|
||||
var rt0 = $('#data-rt0 span').text();
|
||||
var rt1 = $('#data-rt1 span').text();
|
||||
|
||||
try {
|
||||
await copyToClipboard("[0] RT: " + rt0 + "\n[1] RT: " + rt1);
|
||||
await copyToClipboard("[0] RT: " + $('#data-rt0 span').text() + "\n[1] RT: " + $('#data-rt1 span').text());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -834,17 +697,12 @@ function copyToClipboard(textToCopy) {
|
||||
|
||||
function findOnMaps() {
|
||||
var frequency = parseFloat($('#data-frequency').text());
|
||||
var pi = $('#data-pi').text();
|
||||
var latitude = localStorage.getItem('qthLongitude');
|
||||
var longitude = localStorage.getItem('qthLatitude');
|
||||
|
||||
frequency > 74 ? frequency = frequency.toFixed(1) : null;
|
||||
|
||||
var url = `https://maps.fmdx.org/#qth=${longitude},${latitude}&freq=${frequency}&findPi=${pi}`;
|
||||
window.open(url, "_blank");
|
||||
window.open(`https://maps.fmdx.org/#qth=${localStorage.getItem('qthLatitude')},${localStorage.getItem('qthLongitude')}&freq=${frequency}&findPi=${$('#data-pi').text()}`, "_blank");
|
||||
}
|
||||
|
||||
|
||||
function updateSignalUnits(parsedData, averageSignal) {
|
||||
const signalUnit = localStorage.getItem('signalUnit');
|
||||
let currentSignal;
|
||||
@@ -856,21 +714,19 @@ function updateSignalUnits(parsedData, averageSignal) {
|
||||
|
||||
switch (signalUnit) {
|
||||
case 'dbuv':
|
||||
signalValue = currentSignal - 11.25;
|
||||
highestSignal = highestSignal - 11.25;
|
||||
signalText.text('dBµV');
|
||||
break;
|
||||
|
||||
signalValue = currentSignal - 11.25;
|
||||
highestSignal = highestSignal - 11.25;
|
||||
signalText.text('dBµV');
|
||||
break;
|
||||
case 'dbm':
|
||||
signalValue = currentSignal - 120;
|
||||
highestSignal = highestSignal - 120;
|
||||
signalText.text('dBm');
|
||||
break;
|
||||
|
||||
signalValue = currentSignal - 120;
|
||||
highestSignal = highestSignal - 120;
|
||||
signalText.text('dBm');
|
||||
break;
|
||||
default:
|
||||
signalValue = currentSignal;
|
||||
signalText.text('dBf');
|
||||
break;
|
||||
signalValue = currentSignal;
|
||||
signalText.text('dBf');
|
||||
break;
|
||||
}
|
||||
|
||||
const formatted = (Math.round(signalValue * 10) / 10).toFixed(1);
|
||||
@@ -946,46 +802,38 @@ function buildAltTxList(txList) {
|
||||
}
|
||||
|
||||
function updateTextIfChanged($element, newText) {
|
||||
if ($element.text() !== newText) {
|
||||
$element.text(newText);
|
||||
}
|
||||
if ($element.text() !== newText) $element.text(newText);
|
||||
}
|
||||
|
||||
function updateHtmlIfChanged($element, newHtml) {
|
||||
if ($element.html() !== newHtml) {
|
||||
$element.html(newHtml);
|
||||
}
|
||||
if ($element.html() !== newHtml) $element.html(newHtml);
|
||||
}
|
||||
|
||||
function updateDatasetValIfChanged($element, dataLabel, newVal) {
|
||||
if ($element.attr(dataLabel) !== newVal) {
|
||||
$element.attr(dataLabel, newVal);
|
||||
}
|
||||
if ($element.attr(dataLabel) !== newVal) $element.attr(dataLabel, newVal);
|
||||
}
|
||||
|
||||
// Main function to update data elements, optimized
|
||||
const updateDataElements = throttle(function(parsedData) {
|
||||
updateTextIfChanged($dataFrequency, parsedData.freq);
|
||||
$commandInput.attr("aria-label", "Current frequency: " + parsedData.freq);
|
||||
updateHtmlIfChanged($dataPi, parsedData.pi === '?' ? "<span class='opacity-half'>?</span>" : parsedData.pi);
|
||||
|
||||
const questionCount = (parsedData.pi.match(/\?/g) || []).length;
|
||||
const opacity = 1 - (questionCount * 0.2);
|
||||
|
||||
updateHtmlIfChanged($dataPi,`<span style="opacity:${opacity}">${parsedData.pi}</span>`);
|
||||
|
||||
if ($('#ps-underscores').is(':checked')) {
|
||||
parsedData.ps = parsedData.ps.replace(/\s/g, '_');
|
||||
}
|
||||
updateHtmlIfChanged($dataPs, parsedData.ps === '?' ? "<span class='opacity-half'>?</span>" : processString(parsedData.ps, parsedData.ps_errors));
|
||||
|
||||
if(parsedData.st) {
|
||||
$dataSt.parent().removeClass('opacity-half');
|
||||
} else {
|
||||
$dataSt.parent().addClass('opacity-half');
|
||||
}
|
||||
if(parsedData.st) $dataSt.parent().removeClass('opacity-half');
|
||||
else $dataSt.parent().addClass('opacity-half');
|
||||
|
||||
if(parsedData.stForced) {
|
||||
if (!parsedData.st) {
|
||||
stereoColor = 'gray';
|
||||
} else {
|
||||
stereoColor = 'var(--color-4)';
|
||||
}
|
||||
if (!parsedData.st) stereoColor = 'gray';
|
||||
else stereoColor = 'var(--color-4)';
|
||||
$('.data-st.circle1').css('left', '4px');
|
||||
$('.data-st.circle2').css('display', 'none');
|
||||
} else {
|
||||
@@ -998,11 +846,8 @@ const updateDataElements = throttle(function(parsedData) {
|
||||
|
||||
updateTextIfChanged($dataPty, rdsMode == 'true' ? usa_programmes[parsedData.pty] : europe_programmes[parsedData.pty]);
|
||||
|
||||
if (parsedData.rds === true) {
|
||||
$flagDesktopCointainer.css('background-color', 'var(--color-2-transparent)');
|
||||
} else {
|
||||
$flagDesktopCointainer.css('background-color', 'var(--color-1-transparent)');
|
||||
}
|
||||
if (parsedData.rds === true) $flagDesktopCointainer.css('background-color', 'var(--color-2-transparent)');
|
||||
else $flagDesktopCointainer.css('background-color', 'var(--color-1-transparent)');
|
||||
|
||||
$('.data-flag').html(`<i title="${parsedData.country_name}" class="flag-sm flag-sm-${parsedData.country_iso}"></i>`);
|
||||
$('.data-flag-big').html(`<i title="${parsedData.country_name}" class="flag-md flag-md-${parsedData.country_iso}"></i>`);
|
||||
@@ -1060,13 +905,11 @@ function updatePanels(parsedData) {
|
||||
updateCounter = (updateCounter % 10000) + 1; // Count to 10000 then reset back to 1
|
||||
|
||||
signalData.push(parsedData.sig);
|
||||
if (signalData.length > 8) {
|
||||
signalData.shift(); // Remove the oldest element
|
||||
}
|
||||
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 sortedAf = parsedData.af.sort(function (a, b){return a-b});
|
||||
const scaledArray = sortedAf.map(element => element / 1000);
|
||||
|
||||
const listContainer = $('#af-list');
|
||||
@@ -1090,8 +933,7 @@ function updatePanels(parsedData) {
|
||||
// Add the event listener only once
|
||||
if (!isEventListenerAdded) {
|
||||
ul.on('click', 'a', function () {
|
||||
const frequency = parseFloat($(this).text());
|
||||
tuneTo(frequency);
|
||||
tuneTo(parseFloat($(this).text()));
|
||||
});
|
||||
isEventListenerAdded = true;
|
||||
}
|
||||
@@ -1111,9 +953,7 @@ function createListItem(element) {
|
||||
function updateButtonState(buttonId, value) {
|
||||
var button = $("#" + buttonId);
|
||||
|
||||
if (button.length === 0) {
|
||||
button = $("." + buttonId);
|
||||
}
|
||||
if (button.length === 0) button = $("." + buttonId);
|
||||
|
||||
if (button.length > 0) {
|
||||
if (value == 0) {
|
||||
@@ -1123,9 +963,7 @@ function updateButtonState(buttonId, value) {
|
||||
button.hasClass("btn-disabled") ? button.removeClass("btn-disabled") : null;
|
||||
button.attr('aria-description', 'On');
|
||||
}
|
||||
} else {
|
||||
console.log("Button not found!");
|
||||
}
|
||||
} else console.log("Button not found!");
|
||||
}
|
||||
|
||||
|
||||
@@ -1186,9 +1024,7 @@ function initTooltips(target = null) {
|
||||
tooltips.off('mouseenter mouseleave');
|
||||
|
||||
tooltips.hover(function () {
|
||||
if ($(this).closest('.popup-content').length) {
|
||||
return;
|
||||
}
|
||||
if ($(this).closest('.popup-content').length) return;
|
||||
|
||||
var tooltipText = $(this).data('tooltip');
|
||||
var placement = $(this).data('tooltip-placement') || 'top'; // Default to 'top'
|
||||
@@ -1216,22 +1052,22 @@ function initTooltips(target = null) {
|
||||
var posX, posY;
|
||||
switch (placement) {
|
||||
case 'bottom':
|
||||
posX = targetOffset.left + targetWidth / 2 - tooltipWidth / 2;
|
||||
posY = targetOffset.top + targetHeight + 10;
|
||||
break;
|
||||
posX = targetOffset.left + targetWidth / 2 - tooltipWidth / 2;
|
||||
posY = targetOffset.top + targetHeight + 10;
|
||||
break;
|
||||
case 'left':
|
||||
posX = targetOffset.left - tooltipWidth - 10;
|
||||
posY = targetOffset.top + targetHeight / 2 - tooltipHeight / 2;
|
||||
break;
|
||||
posX = targetOffset.left - tooltipWidth - 10;
|
||||
posY = targetOffset.top + targetHeight / 2 - tooltipHeight / 2;
|
||||
break;
|
||||
case 'right':
|
||||
posX = targetOffset.left + targetWidth + 10;
|
||||
posY = targetOffset.top + targetHeight / 2 - tooltipHeight / 2;
|
||||
break;
|
||||
posX = targetOffset.left + targetWidth + 10;
|
||||
posY = targetOffset.top + targetHeight / 2 - tooltipHeight / 2;
|
||||
break;
|
||||
case 'top':
|
||||
default:
|
||||
posX = targetOffset.left + targetWidth / 2 - tooltipWidth / 2;
|
||||
posY = targetOffset.top - tooltipHeight - 10;
|
||||
break;
|
||||
posX = targetOffset.left + targetWidth / 2 - tooltipWidth / 2;
|
||||
posY = targetOffset.top - tooltipHeight - 10;
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply positioning
|
||||
@@ -1273,12 +1109,8 @@ function initTooltips(target = null) {
|
||||
$(`#preset${i}`).click(function() {
|
||||
tuneTo(Number(presetText));
|
||||
});
|
||||
} else {
|
||||
$(`#preset${i}`).hide();
|
||||
}
|
||||
} else $(`#preset${i}`).hide();
|
||||
}
|
||||
|
||||
if (!hasAnyPreset) {
|
||||
$('#preset1').parent().hide();
|
||||
}
|
||||
if (!hasAnyPreset) $('#preset1').parent().hide();
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ function checkScroll() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
let $container = $(".scrollable-container");
|
||||
let $leftArrow = $(".scroll-left");
|
||||
|
||||
+1259
File diff suppressed because it is too large
Load Diff
+26
-38
@@ -25,12 +25,11 @@ const signalUnits = {
|
||||
};
|
||||
|
||||
$(document).ready(() => {
|
||||
|
||||
getInitialSettings();
|
||||
|
||||
|
||||
$('#login-form').submit(function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: './login',
|
||||
@@ -42,16 +41,14 @@ $(document).ready(() => {
|
||||
}, 1750);
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
if (xhr.status === 403) {
|
||||
sendToast('error', 'Login failed!', xhr.responseJSON.message, false, true);
|
||||
}
|
||||
if (xhr.status === 403) sendToast('error', 'Login failed!', xhr.responseJSON.message, false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
$('.logout-link').click(function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
$.ajax({
|
||||
type: 'GET', // Assuming the logout is a GET request, adjust accordingly
|
||||
url: './logout',
|
||||
@@ -59,12 +56,10 @@ $(document).ready(() => {
|
||||
sendToast('success', 'Logout success!', data.message, false, true);
|
||||
setTimeout(function () {
|
||||
location.reload(true);
|
||||
}, 1000);
|
||||
}, 500);
|
||||
},
|
||||
error: function (xhr, status, error) {
|
||||
if (xhr.status === 403) {
|
||||
sendToast('error', 'Logout failed!', xhr.responseJSON.message, false, true);
|
||||
}
|
||||
if (xhr.status === 403) sendToast('error', 'Logout failed!', xhr.responseJSON.message, false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,7 +97,6 @@ function setTheme(themeName) {
|
||||
// Extracting the RGBA components from themeColors[2] for --color-text-2
|
||||
const rgbaComponentsText = themeColors[2].match(/(\d+(\.\d+)?)/g);
|
||||
const opacityText = parseFloat(rgbaComponentsText[3]);
|
||||
const newOpacityText = opacityText * 0.75;
|
||||
const textColor2 = `rgba(${rgbaComponentsText[0]}, ${rgbaComponentsText[1]}, ${rgbaComponentsText[2]})`;
|
||||
|
||||
// Extracting the RGBA components from themeColors[0] for background color
|
||||
@@ -122,10 +116,8 @@ function setTheme(themeName) {
|
||||
function setBg() {
|
||||
const disableBackgroundParameter = getQueryParameter('disableBackground');
|
||||
if(localStorage.getItem('bgImage').length > 5 && localStorage.getItem('theme') != 'theme9' && disableBackgroundParameter != 'true') {
|
||||
$('body').css('background', 'url(' + localStorage.getItem('bgImage') + ') top center / cover fixed no-repeat var(--color-main)');
|
||||
} else {
|
||||
$('body').css('background', 'var(--color-main)');
|
||||
}
|
||||
$('body').css('background', 'url(' + localStorage.getItem('bgImage') + ') center center / cover fixed no-repeat var(--color-main)');
|
||||
} else $('body').css('background', 'var(--color-main)');
|
||||
}
|
||||
|
||||
function getInitialSettings() {
|
||||
@@ -135,15 +127,11 @@ function getInitialSettings() {
|
||||
success: function (data) {
|
||||
|
||||
['qthLatitude', 'qthLongitude', 'defaultTheme', 'bgImage', 'rdsMode', 'rdsTimeout'].forEach(key => {
|
||||
if (data[key] !== undefined) {
|
||||
localStorage.setItem(key, data[key]);
|
||||
}
|
||||
});
|
||||
|
||||
data.presets.forEach((preset, index) => {
|
||||
localStorage.setItem(`preset${index + 1}`, preset);
|
||||
if (data[key] !== undefined) localStorage.setItem(key, data[key]);
|
||||
});
|
||||
|
||||
data.presets.forEach((preset, index) => localStorage.setItem(`preset${index + 1}`, preset));
|
||||
|
||||
loadInitialSettings();
|
||||
},
|
||||
error: function (error) {
|
||||
@@ -159,7 +147,7 @@ function loadInitialSettings() {
|
||||
const savedUnit = localStorage.getItem('signalUnit');
|
||||
|
||||
themeSelector.find('input').val(themeSelector.find('.option[data-value="' + defaultTheme + '"]').text());
|
||||
|
||||
|
||||
if(defaultTheme && themes[defaultTheme]) {
|
||||
setTheme(defaultTheme);
|
||||
}
|
||||
@@ -169,12 +157,12 @@ function loadInitialSettings() {
|
||||
setTheme(themeParameter);
|
||||
themeSelector.find('input').val(themeSelector.find('.option[data-value="' + themeParameter + '"]').text());
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@@ -182,15 +170,15 @@ function loadInitialSettings() {
|
||||
localStorage.setItem('theme', selectedTheme);
|
||||
setBg();
|
||||
});
|
||||
|
||||
|
||||
const signalSelector = $('#signal-selector');
|
||||
|
||||
|
||||
const signalParameter = getQueryParameter('signalUnits');
|
||||
if(signalParameter && !localStorage.getItem('signalUnit')) {
|
||||
signalSelector.find('input').val(signalSelector.find('.option[data-value="' + signalParameter + '"]').text());
|
||||
localStorage.setItem('signalUnit', signalParameter);
|
||||
} else 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
|
||||
@@ -201,23 +189,23 @@ function loadInitialSettings() {
|
||||
if (extendedFreqRange === "true") {
|
||||
$("#extended-frequency-range").prop("checked", true);
|
||||
}
|
||||
|
||||
|
||||
$("#extended-frequency-range").change(function() {
|
||||
var isChecked = $(this).is(":checked");
|
||||
localStorage.setItem("extendedFreqRange", isChecked);
|
||||
});
|
||||
|
||||
|
||||
const psUnderscoreParameter = getQueryParameter('psUnderscores');
|
||||
if(psUnderscoreParameter) {
|
||||
$("#ps-underscores").prop("checked", JSON.parse(psUnderscoreParameter));
|
||||
}
|
||||
|
||||
|
||||
var psUnderscores = localStorage.getItem("psUnderscores");
|
||||
if (psUnderscores) {
|
||||
$("#ps-underscores").prop("checked", JSON.parse(psUnderscores));
|
||||
localStorage.setItem("psUnderscores", psUnderscores);
|
||||
}
|
||||
|
||||
|
||||
$("#ps-underscores").change(function() {
|
||||
var isChecked = $(this).is(":checked");
|
||||
localStorage.setItem("psUnderscores", isChecked);
|
||||
@@ -228,13 +216,13 @@ function loadInitialSettings() {
|
||||
$("#imperial-units").prop("checked", JSON.parse(imperialUnits));
|
||||
localStorage.setItem("imperialUnits", imperialUnits);
|
||||
}
|
||||
|
||||
|
||||
$("#imperial-units").change(function() {
|
||||
var isChecked = $(this).is(":checked");
|
||||
localStorage.setItem("imperialUnits", isChecked);
|
||||
});
|
||||
|
||||
|
||||
$('.version-string').text(currentVersion);
|
||||
|
||||
|
||||
setBg();
|
||||
}
|
||||
+2
-240
@@ -4,219 +4,9 @@ var tilesURL=' https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
var mapAttrib='© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>';
|
||||
|
||||
$(document).ready(function() {
|
||||
mapCreate();
|
||||
loadConsoleLogs();
|
||||
|
||||
showPanelFromHash();
|
||||
initNav();
|
||||
initBanlist();
|
||||
|
||||
checkTunnelServers();
|
||||
setInterval(checkTunnelServers, 10000);
|
||||
});
|
||||
|
||||
/**
|
||||
* Function to create & handle maps.
|
||||
* Also contains map handling such as reloading / pin click registering.
|
||||
*/
|
||||
|
||||
function mapCreate() {
|
||||
if (!(typeof map == "object")) {
|
||||
map = L.map('map', {
|
||||
center: [40, 0],
|
||||
zoom: 3,
|
||||
worldCopyJump: true
|
||||
});
|
||||
} else map.setZoom(3).panTo([40, 0]);
|
||||
|
||||
L.tileLayer(tilesURL, {
|
||||
attribution: mapAttrib,
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
// Check for initial lat/lon values
|
||||
const latVal = parseFloat($('#identification-lat').val());
|
||||
const lonVal = parseFloat($('#identification-lon').val());
|
||||
|
||||
if (!isNaN(latVal) && !isNaN(lonVal)) {
|
||||
const initialLatLng = L.latLng(latVal, lonVal);
|
||||
pin = L.marker(initialLatLng, { riseOnHover: true, draggable: true }).addTo(map);
|
||||
map.setView(initialLatLng, 8); // Optional: Zoom in closer to the pin
|
||||
|
||||
pin.on('dragend', function(ev) {
|
||||
$('#identification-lat').val(ev.target.getLatLng().lat.toFixed(6));
|
||||
$('#identification-lon').val(ev.target.getLatLng().lng.toFixed(6));
|
||||
});
|
||||
}
|
||||
|
||||
map.on('click', function(ev) {
|
||||
$('#identification-lat').val(ev.latlng.lat.toFixed(6));
|
||||
$('#identification-lon').val(ev.latlng.lng.toFixed(6));
|
||||
|
||||
if (typeof pin == "object") {
|
||||
pin.setLatLng(ev.latlng.wrap());
|
||||
} else {
|
||||
pin = L.marker(ev.latlng.wrap(), { riseOnHover: true, draggable: true }).addTo(map);
|
||||
pin.on('dragend', function(ev) {
|
||||
$('#identification-lat').val(ev.target.getLatLng().lat.toFixed(6));
|
||||
$('#identification-lon').val(ev.target.getLatLng().lng.toFixed(6));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mapReload();
|
||||
}
|
||||
|
||||
function mapReload() {
|
||||
setTimeout(function () {
|
||||
map.invalidateSize();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function showPanelFromHash() {
|
||||
var panelId = window.location.hash.substring(1) || 'dashboard';
|
||||
|
||||
$('.tab-content').hide();
|
||||
$('#' + panelId).show();
|
||||
|
||||
$('.nav li').removeClass('active');
|
||||
$('.nav li[data-panel="' + panelId + '"]').addClass('active');
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
$('.nav li').click(function() {
|
||||
$('.nav li').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
var panelId = $(this).data('panel');
|
||||
window.location.hash = panelId;
|
||||
$('.tab-content').hide();
|
||||
$('#' + panelId).show();
|
||||
|
||||
panelId == 'identification' ? mapReload() : null;
|
||||
});
|
||||
|
||||
$('[role="tab"]').on('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
$(this).find('a').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initBanlist() {
|
||||
$('.banlist-add').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const ipAddress = $('#banlist-add-ip').val();
|
||||
const reason = $('#banlist-add-reason').val();
|
||||
|
||||
$.ajax({
|
||||
url: '/addToBanlist',
|
||||
method: 'GET',
|
||||
data: { ip: ipAddress, reason: reason },
|
||||
success: function(response) {
|
||||
// Refresh the page if the request was successful
|
||||
if (response.success) location.reload();
|
||||
else console.error('Failed to add to banlist');
|
||||
},
|
||||
error: function() {
|
||||
console.error('Error occurred during the request');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('.banlist-remove').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const ipAddress = $(this).closest('tr').find('td').first().text();
|
||||
|
||||
$.ajax({
|
||||
url: '/removeFromBanlist',
|
||||
method: 'GET',
|
||||
data: { ip: ipAddress },
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
console.error('Failed to remove from banlist');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
console.error('Error occurred during the request');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNav() {
|
||||
const navOpen = $("#navigation").css('margin-left') === '0px';
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
if (navOpen) {
|
||||
if (isMobile) {
|
||||
// Do nothing to .admin-wrapper on mobile (since we're overlaying)
|
||||
$(".admin-wrapper").css({
|
||||
'margin-left': '32px',
|
||||
'width': '100%' // Reset content to full width on close
|
||||
});
|
||||
$("#navigation").css('margin-left', 'calc(64px - 100vw)');
|
||||
} else {
|
||||
// On desktop, adjust the content margin and width
|
||||
$(".admin-wrapper").css({
|
||||
'margin-left': '64px',
|
||||
'width': 'calc(100% - 64px)'
|
||||
});
|
||||
$("#navigation").css('margin-left', '-356px');
|
||||
}
|
||||
$(".sidenav-close").html('<i class="fa-solid fa-chevron-right"></i>');
|
||||
} else {
|
||||
$("#navigation").css('margin-left', '0');
|
||||
if (isMobile) {
|
||||
$(".admin-wrapper").css({
|
||||
'margin-left': '0', // Keep content in place when sidenav is open
|
||||
'width': '100%' // Keep content at full width
|
||||
});
|
||||
} else {
|
||||
// On desktop, push the content
|
||||
$(".admin-wrapper").css({
|
||||
'margin-left': '420px',
|
||||
'width': 'calc(100% - 420px)'
|
||||
});
|
||||
}
|
||||
$(".sidenav-close").html('<i class="fa-solid fa-chevron-left"></i>');
|
||||
}
|
||||
}
|
||||
|
||||
function initVolumeSlider() {
|
||||
const $volumeInput = $('#audio-startupVolume');
|
||||
const $percentageValue = $('#volume-percentage-value');
|
||||
|
||||
const updateDisplay = () => {
|
||||
$percentageValue.text(($volumeInput.val() * 100).toFixed(0) + '%');
|
||||
};
|
||||
|
||||
updateDisplay();
|
||||
$volumeInput.on('change', updateDisplay);
|
||||
}
|
||||
|
||||
function initConnectionToggle() {
|
||||
const connectionToggle = $('#xdrd-wirelessConnection');
|
||||
const tunerUSB = $('#tuner-usb');
|
||||
const tunerWireless = $('#tuner-wireless');
|
||||
|
||||
function toggleType() {
|
||||
if (connectionToggle.is(":checked")) {
|
||||
tunerUSB.hide();
|
||||
tunerWireless.show();
|
||||
} else {
|
||||
tunerWireless.hide();
|
||||
tunerUSB.show();
|
||||
}
|
||||
}
|
||||
|
||||
toggleType();
|
||||
connectionToggle.change(toggleType);
|
||||
}
|
||||
|
||||
function stripAnsi(str) {
|
||||
return str.replace(/\u001b\[\d+m/g, '');
|
||||
}
|
||||
@@ -227,11 +17,10 @@ async function loadConsoleLogs() {
|
||||
html = stripAnsi(html);
|
||||
|
||||
const logColors = {
|
||||
INFO: "lime",
|
||||
DEBUG: "cyan",
|
||||
CHAT: "cyan",
|
||||
ERROR: "red",
|
||||
INFO: "lime",
|
||||
WARN: "yellow",
|
||||
ERROR: "red"
|
||||
};
|
||||
|
||||
let firstBracketProcessed = false;
|
||||
@@ -252,30 +41,3 @@ async function loadConsoleLogs() {
|
||||
});
|
||||
$("#console-output").length ? $("#console-output").scrollTop($("#console-output")[0].scrollHeight) : null;
|
||||
}
|
||||
|
||||
function checkTunnelServers() {
|
||||
$.ajax({
|
||||
url: '/tunnelservers',
|
||||
method: 'GET',
|
||||
success: function(servers) {
|
||||
const $options = $('#tunnel-regionselect ul.options');
|
||||
const $input = $('#tunnel-region');
|
||||
const selectedValue = $input.val(); // currently selected value (label or value?)
|
||||
|
||||
servers.forEach(server => {
|
||||
const $li = $options.find(`li[data-value="${server.value}"]`);
|
||||
|
||||
if ($li.length) {
|
||||
$li.text(server.label);
|
||||
|
||||
// If this li is the currently selected one, update input text too
|
||||
// Note: input.val() holds the label, so match by label is safer
|
||||
if ($li.text() === selectedValue || server.value === selectedValue) $input.val(server.label);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function() {
|
||||
console.error('Failed to load server latency data');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+3
-4
@@ -43,11 +43,10 @@ function sendToast(type, title, message, persistent, important) {
|
||||
|
||||
function closeToast($toast) {
|
||||
$toast.removeClass('show');
|
||||
setTimeout(function () {
|
||||
$toast.remove();
|
||||
}, 300);
|
||||
setTimeout(() => $toast.remove(), 300);
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
// JS doesn't have a native capitilzation function? What the actual fuck in the toy language?
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const versionDate = new Date('Feb 24, 2026 15:00:00');
|
||||
const currentVersion = `v1.4.0a [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`;
|
||||
const versionDate = new Date('Jul 10, 2026 21:20:00');
|
||||
const currentVersion = `v1.4.0b [${versionDate.getDate()}/${versionDate.getMonth() + 1}/${versionDate.getFullYear()}]`;
|
||||
+2
-2
@@ -17,9 +17,9 @@ if (!window.socket || window.socket.readyState === WebSocket.CLOSED || window.so
|
||||
reject(error);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket.addEventListener('close', (event) => {
|
||||
setTimeout(() => {
|
||||
console.warn('WebSocket connection closed');
|
||||
console.warn(`WebSocket connection closed (${event.code} '${event.reason}' ${event.wasClean})`);
|
||||
}, 100);
|
||||
reject(new Error('WebSocket connection closed'));
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
$(document).ready(function() {
|
||||
$('.btn-prev').toggle($('.step:visible').index() !== 0);
|
||||
$('.btn-next').click(() => navigateStep(true));
|
||||
$('.btn-prev').click(() => navigateStep(false));
|
||||
});
|
||||
|
||||
function updateProgressBar(currentStep) {
|
||||
var stepIndex = $('.step').index(currentStep) + 1;
|
||||
$('.btn-rounded-cube').removeClass('activated');
|
||||
$('.btn-rounded-cube:lt(' + stepIndex + ')').addClass('activated');
|
||||
}
|
||||
|
||||
function updateWizardContent() {
|
||||
var visibleStepIndex = $('.step:visible').index();
|
||||
|
||||
$('.btn-prev').toggle(visibleStepIndex !== 0);
|
||||
$('.btn-next').text(visibleStepIndex === 4 ? 'Save' : 'Next');
|
||||
|
||||
visibleStepIndex === 3 && mapReload();
|
||||
}
|
||||
|
||||
function navigateStep(isNext) {
|
||||
var currentStep = $('.step:visible');
|
||||
var targetStep = isNext ? currentStep.next('.step') : currentStep.prev('.step');
|
||||
|
||||
if (targetStep.length !== 0) {
|
||||
currentStep.hide();
|
||||
targetStep.show();
|
||||
updateProgressBar(targetStep);
|
||||
} else if (isNext) submitConfig();
|
||||
updateWizardContent();
|
||||
}
|
||||
Reference in New Issue
Block a user