mirror of
https://github.com/KubaPro010/fm-dx-webserver.git
synced 2026-08-02 10:19:16 +02:00
Compare commits
136
Commits
c0d1fee257
...
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
|
||
|
|
94710dbd9e
|
||
|
|
20f5090be0
|
||
|
|
9e7ef5d96c
|
||
|
|
8816ffedd1
|
||
|
|
7a44fb4215
|
||
|
|
fcc95414e6
|
||
|
|
8762d4042c
|
||
|
|
2c3d2a4137
|
||
|
|
72bbd9fcbd
|
||
|
|
fb6a1d00f7
|
||
|
|
f17e845c1d
|
||
|
|
2a1cf2b3f5
|
||
|
|
c3a74867cc
|
||
|
|
2aeb81697a
|
||
|
|
b08a57a5e8
|
||
|
|
c67d8f7311
|
||
|
|
e14b3f8d11
|
||
|
|
42a20330af
|
@@ -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');
|
||||
Generated
+290
-35
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "fm-dx-webserver",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.0a",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fm-dx-webserver",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.0a",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "2.0.3",
|
||||
@@ -16,8 +16,8 @@
|
||||
"express-session": "1.19.0",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"figlet": "^1.10.0",
|
||||
"geoip-lite": "^1.4.10",
|
||||
"http": "0.0.1-security",
|
||||
"koffi": "2.7.2",
|
||||
"net": "1.0.2",
|
||||
"serialport": "13.0.0",
|
||||
"ws": "8.19.0"
|
||||
@@ -444,6 +444,21 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -518,6 +533,15 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -566,6 +590,22 @@
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -575,6 +615,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
@@ -584,6 +642,12 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
@@ -868,6 +932,15 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
@@ -968,6 +1041,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -977,6 +1056,45 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/geoip-lite": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/geoip-lite/-/geoip-lite-1.4.10.tgz",
|
||||
"integrity": "sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "2.1 - 2.6.4",
|
||||
"chalk": "4.1 - 4.1.2",
|
||||
"iconv-lite": "0.4.13 - 0.6.3",
|
||||
"ip-address": "5.8.9 - 5.9.4",
|
||||
"lazy": "1.0.11",
|
||||
"rimraf": "2.5.2 - 2.7.1",
|
||||
"yauzl": "2.9.2 - 2.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geoip-lite/node_modules/async": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
|
||||
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.14"
|
||||
}
|
||||
},
|
||||
"node_modules/geoip-lite/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1014,6 +1132,55 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1026,6 +1193,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -1132,11 +1308,36 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "5.9.4",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.9.4.tgz",
|
||||
"integrity": "sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"lodash": "^4.17.15",
|
||||
"sprintf-js": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1168,11 +1369,25 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/koffi": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.7.2.tgz",
|
||||
"integrity": "sha512-AWcsEKETQuELxK0Wq/aXDkDiNFFY41TxZQSrKm2Nd6HO/KTHeohPOOIlh2OfQnBXJbRjx5etpWt8cbqMUZo2sg==",
|
||||
"hasInstallScript": true,
|
||||
"node_modules/jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazy": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/lazy/-/lazy-1.0.11.tgz",
|
||||
"integrity": "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
@@ -1238,9 +1453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
@@ -1262,9 +1477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
|
||||
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
@@ -1273,21 +1488,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
|
||||
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "dist/cjs/src/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -1415,6 +1615,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
@@ -1424,6 +1633,12 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1510,6 +1725,19 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -1776,6 +2004,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
|
||||
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1793,17 +2027,28 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
|
||||
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
|
||||
"license": "ISC",
|
||||
"version": "7.5.12",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz",
|
||||
"integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.0.1",
|
||||
"mkdirp": "^3.0.1",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1929,6 +2174,16 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -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,9 +18,8 @@
|
||||
"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",
|
||||
"koffi": "2.7.2",
|
||||
"net": "1.0.2",
|
||||
"serialport": "13.0.0",
|
||||
"ws": "8.19.0"
|
||||
|
||||
@@ -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.');
|
||||
+18
-18
@@ -1,14 +1,15 @@
|
||||
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) {
|
||||
if (!serverConfig.webserver.chatEnabled) return null;
|
||||
function createChatServer() {
|
||||
if (!serverConfig.webserver.chatEnabled) return;
|
||||
|
||||
const chatWss = new WebSocket.Server({ noServer: true });
|
||||
|
||||
@@ -16,26 +17,21 @@ function createChatServer(storage) {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', heartbeat);
|
||||
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
||||
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));
|
||||
}
|
||||
@@ -118,7 +117,8 @@ function createChatServer(storage) {
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
return chatWss;
|
||||
storage.websocket_delegation.set("/chat", chatWss);
|
||||
}
|
||||
|
||||
module.exports = { createChatServer };
|
||||
if(!configExists()) return;
|
||||
createChatServer();
|
||||
+10
-18
@@ -1,24 +1,21 @@
|
||||
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 = 5000;
|
||||
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",
|
||||
};
|
||||
@@ -26,7 +23,7 @@ const MESSAGE_PREFIX = {
|
||||
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`;
|
||||
};
|
||||
|
||||
@@ -35,26 +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 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;
|
||||
@@ -87,4 +79,4 @@ process.on('exit', flushLogBuffer);
|
||||
process.on('SIGINT', gracefulExit);
|
||||
process.on('SIGTERM', gracefulExit);
|
||||
|
||||
module.exports = { logError, logDebug, logFfmpeg, logInfo, logWarn, logs, logChat };
|
||||
module.exports = { logError, logDebug, logInfo, logWarn, logs };
|
||||
+38
-38
@@ -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,21 +27,22 @@ var dataToSend = {
|
||||
rt0: '',
|
||||
rt1: '',
|
||||
rt_flag: '',
|
||||
lps: '',
|
||||
ims: 0,
|
||||
eq: 0,
|
||||
agc: 0,
|
||||
ant: 0,
|
||||
txInfo: {
|
||||
tx: '',
|
||||
pol: '',
|
||||
erp: '',
|
||||
tx: '', // Name
|
||||
pol: '', // Polarization
|
||||
erp: '', // Power
|
||||
city: '',
|
||||
itu: '',
|
||||
dist: '',
|
||||
azi: '',
|
||||
id: '',
|
||||
itu: '', // Country
|
||||
dist: '', // Distance
|
||||
azi: '', // Azimuth
|
||||
id: 0, // Some number
|
||||
reg: false,
|
||||
pi: '',
|
||||
pi: '', // PI
|
||||
},
|
||||
country_name: '',
|
||||
country_iso: 'UN',
|
||||
@@ -56,7 +58,6 @@ const filterMappings = {
|
||||
'G00': { eq: 0, ims: 0 }
|
||||
};
|
||||
|
||||
|
||||
var legacyRdsPiBuffer = null;
|
||||
var lastUpdateTime = Date.now();
|
||||
const initialData = { ...dataToSend };
|
||||
@@ -85,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
|
||||
@@ -106,8 +109,8 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
case receivedLine.startsWith('T'): // Frequency
|
||||
modifiedData = receivedLine.substring(1).split(",")[0];
|
||||
|
||||
rdsReset();
|
||||
if((modifiedData / 1000).toFixed(3) == dataToSend.freq) return; // Prevent tune spamming using scrollwheel
|
||||
rdsReset();
|
||||
|
||||
parsedValue = parseFloat(modifiedData);
|
||||
|
||||
@@ -117,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
|
||||
@@ -131,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;
|
||||
@@ -150,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;
|
||||
@@ -185,26 +186,31 @@ function handleData(wss, receivedData, rdsWss) {
|
||||
modifiedData += errorsNew.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
const a = modifiedData.slice(0, 4);
|
||||
const b = modifiedData.slice(4, 8);
|
||||
const c = modifiedData.slice(8, 12);
|
||||
const d = modifiedData.slice(12, 16);
|
||||
const errors = parseInt(modifiedData.slice(-2), 16);
|
||||
rdsWss.clients.forEach((client) => {
|
||||
const errors = parseInt(modifiedData.slice(-2), 16);
|
||||
let data = (((errors & 0xC0) == 0) ? modifiedData.slice(0, 4) : '----');
|
||||
data += (((errors & 0x30) == 0) ? modifiedData.slice(4, 8) : '----');
|
||||
data += (((errors & 0x0C) == 0) ? modifiedData.slice(8, 12) : '----');
|
||||
data += (((errors & 0x03) == 0) ? modifiedData.slice(12, 16) : '----');
|
||||
let data = ((((errors >> 6) & 3) < 3) ? a : '----');
|
||||
data += ((((errors >> 4) & 3) < 3) ? b : '----');
|
||||
data += ((((errors >> 2) & 3) < 3) ? c : '----');
|
||||
data += (((errors & 3) < 3) ? d : '----');
|
||||
|
||||
const newDataString = "G:\r\n" + data + "\r\n\r\n";
|
||||
client.send(newDataString);
|
||||
client.send("G:\r\n" + data + "\r\n\r\n");
|
||||
});
|
||||
|
||||
rdsdec.decodeGroup(parseInt(modifiedData.slice(0, 4), 16), parseInt(modifiedData.slice(4, 8), 16), parseInt(modifiedData.slice(8, 12), 16), parseInt(modifiedData.slice(12, 16), 16));
|
||||
rdsdec.decodeGroup(parseInt(a, 16), parseInt(b, 16), parseInt(c, 16), parseInt(d, 16), errors);
|
||||
legacyRdsPiBuffer = null;
|
||||
break;
|
||||
case receivedLine.startsWith("!"):
|
||||
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,
|
||||
@@ -221,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();
|
||||
}
|
||||
@@ -270,6 +271,7 @@ checkSerialPortStatus();
|
||||
function showOnlineUsers(currentUsers) {
|
||||
dataToSend.users = currentUsers;
|
||||
initialData.users = currentUsers;
|
||||
send_xdr_online(currentUsers);
|
||||
}
|
||||
|
||||
let prevFreq = initialData.freq || '87.500';
|
||||
@@ -300,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);
|
||||
});
|
||||
+86
-205
@@ -6,62 +6,36 @@ 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 = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||
let requestIp = helpers.getIpAddress(req);
|
||||
|
||||
const normalizedIp = requestIp?.replace(/^::ffff:/, '');
|
||||
const ipList = (normalizedIp || '').split(',').map(ip => ip.trim()).filter(Boolean); // in case there are multiple IPs (proxy), we need to check all of them
|
||||
|
||||
const isBanned = ipList.some(ip => serverConfig.webserver.banlist.some(banEntry => banEntry[0] === ip));
|
||||
|
||||
if (isBanned) {
|
||||
res.render('403');
|
||||
logInfo(`Web client (${normalizedIp}) is banned`);
|
||||
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 });
|
||||
logInfo(`Web client (${requestIp}) is banned`);
|
||||
return;
|
||||
}
|
||||
|
||||
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', {
|
||||
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,100 +61,39 @@ 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() {
|
||||
function loadConfig() {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configFileContents = fs.readFileSync(configPath, 'utf8');
|
||||
return JSON.parse(configFileContents);
|
||||
const configFileContents = fs.readFileSync(configPath, 'utf8');
|
||||
return JSON.parse(configFileContents);
|
||||
}
|
||||
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 || '')
|
||||
}))
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
router.get('/rds', (req, res) => {
|
||||
res.send('Please connect using a WebSocket compatible app to obtain the RDS stream.');
|
||||
});
|
||||
const processUptimeInSeconds = Math.floor(process.uptime());
|
||||
const formattedProcessUptime = helpers.formatUptime(processUptimeInSeconds);
|
||||
|
||||
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,13 +107,12 @@ router.get('/api', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const loginAttempts = {}; // Format: { 'ip': { count: 1, lastAttempt: 1234567890 } }
|
||||
const MAX_ATTEMPTS = 15;
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
const authenticate = (req, res, next) => {
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const ip = helpers.getIpAddress(req);
|
||||
const now = Date.now();
|
||||
|
||||
if (!loginAttempts[ip]) loginAttempts[ip] = { count: 0, lastAttempt: now };
|
||||
@@ -225,6 +137,8 @@ const authenticate = (req, res, next) => {
|
||||
loginAttempts[ip].count = 0;
|
||||
next();
|
||||
} else {
|
||||
req.session.isAdminAuthenticated = false;
|
||||
req.session.isTuneAuthenticated = false;
|
||||
loginAttempts[ip].count += 1;
|
||||
res.status(403).json({ message: 'Login failed. Wrong password?' });
|
||||
}
|
||||
@@ -238,28 +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) => {
|
||||
const ipAddress = req.query.ip;
|
||||
// Terminate the WebSocket connection for the specified IP address
|
||||
if(req.session.isAdminAuthenticated) helpers.kickClient(ipAddress);
|
||||
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) return;
|
||||
const ipAddress = req.query.ip;
|
||||
const location = 'Unknown';
|
||||
const date = Date.now();
|
||||
const reason = req.query.reason;
|
||||
if (!helpers.isAdmin(req)) {
|
||||
res.status(403);
|
||||
return;
|
||||
}
|
||||
|
||||
userBanData = [ipAddress, location, date, reason];
|
||||
userBanData = [req.query.ip, 'Unknown', Date.now(), req.query.reason];
|
||||
|
||||
if (typeof serverConfig.webserver.banlist !== 'object') serverConfig.webserver.banlist = [];
|
||||
|
||||
@@ -270,7 +184,10 @@ router.get('/addToBanlist', (req, res) => {
|
||||
});
|
||||
|
||||
router.get('/removeFromBanlist', (req, res) => {
|
||||
if (!req.session.isAdminAuthenticated) return;
|
||||
if (!helpers.isAdmin(req)) {
|
||||
res.status(403);
|
||||
return;
|
||||
}
|
||||
|
||||
const ipAddress = req.query.ip;
|
||||
|
||||
@@ -286,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.');
|
||||
@@ -301,12 +217,10 @@ router.post('/saveData', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/getData', (req, res) => {
|
||||
if (configExists() === false) {
|
||||
res.json(serverConfig);
|
||||
}
|
||||
|
||||
if(req.session.isAdminAuthenticated) {
|
||||
router.get('/getData', (req, res) => {
|
||||
if (configExists() === false) res.json(serverConfig);
|
||||
|
||||
if(helpers.isAdmin(req)) {
|
||||
// Check if the file exists
|
||||
fs.access(configPath, fs.constants.F_OK, (err) => {
|
||||
if (err) console.log(err);
|
||||
@@ -318,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({
|
||||
@@ -338,53 +244,57 @@ 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"
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/server_time', (req, res) => {
|
||||
const serverTime = new Date(); // Get current server time
|
||||
const serverTimeUTC = new Date(serverTime.getTime() - (serverTime.getTimezoneOffset() * 60000)); // Adjust server time to UTC
|
||||
res.json({
|
||||
serverTime: serverTimeUTC,
|
||||
});
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverConfig.extras.fmlistIntegration === false || (serverConfig.extras.fmlistAdminOnly && !req.session.isTuneAuthenticated)) {
|
||||
res.status(500).send('FMLIST Integration is not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
|
||||
if (dataHandler.dataToSend.txInfo.tx.length === 0) {
|
||||
res.status(500).send('No suitable transmitter to log.');
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = helpers.getIpAddress(req);
|
||||
const txId = dataHandler.dataToSend.txInfo.id; // Extract the ID
|
||||
|
||||
if (!canLog(txId)) {
|
||||
@@ -430,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
|
||||
@@ -453,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.fmtuner.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;
|
||||
|
||||
+222
-92
@@ -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,25 @@ 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');
|
||||
} catch (e) {
|
||||
geoip = null;
|
||||
}
|
||||
|
||||
function parseMarkdown(parsed) {
|
||||
parsed = parsed.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
|
||||
@@ -24,9 +42,7 @@ function parseMarkdown(parsed) {
|
||||
var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
|
||||
parsed = parsed.replace(linkRegex, '<a href="$2" target="_blank">$1</a>');
|
||||
|
||||
parsed = parsed.replace(/\n/g, '<br>');
|
||||
|
||||
return parsed;
|
||||
return parsed.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function removeMarkdown(parsed) {
|
||||
@@ -42,9 +58,8 @@ function removeMarkdown(parsed) {
|
||||
parsed = parsed.replace(italicRegex, '$1');
|
||||
|
||||
var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g;
|
||||
parsed = parsed.replace(linkRegex, '$1');
|
||||
|
||||
return parsed;
|
||||
return parsed.replace(linkRegex, '$1');
|
||||
}
|
||||
|
||||
function authenticateWithXdrd(client, salt, password) {
|
||||
@@ -54,134 +69,215 @@ 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');
|
||||
}
|
||||
|
||||
const ipCache = new Map();
|
||||
const ipInfoInFlight = new Map();
|
||||
|
||||
function handleConnect(clientIp, currentUsers, ws, callback) {
|
||||
if (ipCache.has(clientIp)) {
|
||||
// Use cached location info
|
||||
processConnection(clientIp, ipCache.get(clientIp), currentUsers, ws, callback);
|
||||
return;
|
||||
}
|
||||
function fetchIpWhoisInfo(ip, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!ip || !net.isIP(ip)) return resolve({});
|
||||
|
||||
http.get(`http://ip-api.com/json/${clientIp}`, (response) => {
|
||||
let data = "";
|
||||
|
||||
response.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
response.on("end", () => {
|
||||
try {
|
||||
const locationInfo = JSON.parse(data);
|
||||
ipCache.set(clientIp, locationInfo); // Store in cache
|
||||
processConnection(clientIp, locationInfo, currentUsers, ws, callback);
|
||||
} catch (error) {
|
||||
console.error("Error parsing location data:", error);
|
||||
callback("User allowed");
|
||||
const url = `https://ipwho.is/${encodeURIComponent(ip)}`;
|
||||
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return resolve({});
|
||||
}
|
||||
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (!data || data.success === false) return resolve({});
|
||||
|
||||
const connection = data.connection || {};
|
||||
const isp = connection.isp || connection.org;
|
||||
const asnRaw = connection.asn;
|
||||
const as =
|
||||
typeof asnRaw === 'string'
|
||||
? asnRaw
|
||||
: typeof asnRaw === 'number'
|
||||
? `AS${asnRaw}`
|
||||
: undefined;
|
||||
|
||||
resolve({
|
||||
isp: typeof isp === 'string' && isp.trim() ? isp.trim() : undefined,
|
||||
as,
|
||||
});
|
||||
} catch (e) {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
consoleCmd.logError("Error fetching location data:", err.code);
|
||||
callback("User allowed");
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({});
|
||||
});
|
||||
|
||||
req.on('error', () => resolve({}));
|
||||
});
|
||||
}
|
||||
|
||||
let bannedASCache = { data: null, timestamp: 0 };
|
||||
|
||||
function handleConnect(clientIp, currentUsers, ws, request, callback) {
|
||||
if (ipCache.has(clientIp)) {
|
||||
processConnection(clientIp, ipCache.get(clientIp), currentUsers, ws, request, callback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ipInfoInFlight.has(clientIp)) {
|
||||
ipInfoInFlight.get(clientIp)
|
||||
.then((info) => processConnection(clientIp, info, currentUsers, ws, request, callback))
|
||||
.catch(() => processConnection(clientIp, { country: undefined }, currentUsers, ws, request, callback));
|
||||
return;
|
||||
}
|
||||
|
||||
let locationInfo = { country: undefined };
|
||||
if (geoip && clientIp && net.isIP(clientIp)) {
|
||||
const geo = geoip.lookup(clientIp);
|
||||
if (geo) {
|
||||
locationInfo = {
|
||||
country: geo.country,
|
||||
countryCode: geo.country,
|
||||
city: geo.city,
|
||||
regionName: geo.region,
|
||||
};
|
||||
}
|
||||
} else if (!geoip) consoleCmd.logWarn('geoip-lite is not installed; location will be Unknown.');
|
||||
|
||||
const inFlightPromise = fetchIpWhoisInfo(clientIp)
|
||||
.then((whoisInfo) => {
|
||||
const merged = { ...locationInfo, ...whoisInfo };
|
||||
ipCache.set(clientIp, merged);
|
||||
ipInfoInFlight.delete(clientIp);
|
||||
return merged;
|
||||
})
|
||||
.catch(() => {
|
||||
ipCache.set(clientIp, locationInfo);
|
||||
ipInfoInFlight.delete(clientIp);
|
||||
return locationInfo;
|
||||
});
|
||||
|
||||
ipInfoInFlight.set(clientIp, inFlightPromise);
|
||||
inFlightPromise.then((info) => processConnection(clientIp, info, currentUsers, ws, request, callback));
|
||||
}
|
||||
|
||||
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(null, 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(null, 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(null, []); // 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(null, []); // Default to allowing user
|
||||
callback([]); // Default to allowing user
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
console.error("Error fetching banned AS list:", err);
|
||||
callback(null, []); // Default to allowing user
|
||||
callback([]); // Default to allowing user
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
|
||||
|
||||
fetchBannedAS((error, bannedAS) => {
|
||||
if (error) console.error("Error fetching banned AS list:", error);
|
||||
|
||||
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(normalizedClientIp) || 0;
|
||||
const lastSeen = recentBannedIps.get(clientIp) || 0;
|
||||
|
||||
if (now - lastSeen > 300 * 1000) {
|
||||
consoleCmd.logWarn(`Banned AS list client kicked (${normalizedClientIp})`);
|
||||
recentBannedIps.set(normalizedClientIp, now);
|
||||
consoleCmd.logWarn(`Banned AS list client kicked (${clientIp})`);
|
||||
recentBannedIps.set(clientIp, now);
|
||||
}
|
||||
|
||||
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.country === undefined ? "Unknown" : `${locationInfo.city}, ${locationInfo.regionName}, ${locationInfo.countryCode}`;
|
||||
locationInfo && locationInfo.country !== undefined
|
||||
? [
|
||||
locationInfo.city,
|
||||
locationInfo.regionName && /\p{L}/u.test(String(locationInfo.regionName))
|
||||
? locationInfo.regionName
|
||||
: undefined,
|
||||
locationInfo.countryCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: 'Unknown';
|
||||
const userLocationForLog = locationInfo?.isp ? `${userLocation} (${locationInfo.isp})` : userLocation;
|
||||
|
||||
storage.connectedUsers.push({
|
||||
ip: clientIp,
|
||||
location: userLocation,
|
||||
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 (${normalizedClientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${userLocation}`);
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(uptimeInSeconds) {
|
||||
const secondsInMinute = 60;
|
||||
const secondsInHour = secondsInMinute * 60;
|
||||
const secondsInHour = 60 ** 2;
|
||||
const secondsInDay = secondsInHour * 24;
|
||||
|
||||
const days = Math.floor(uptimeInSeconds / secondsInDay);
|
||||
const hours = Math.floor((uptimeInSeconds % secondsInDay) / secondsInHour);
|
||||
const minutes = Math.floor((uptimeInSeconds % secondsInHour) / secondsInMinute);
|
||||
const minutes = Math.floor((uptimeInSeconds % secondsInHour) / 60);
|
||||
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -198,33 +294,22 @@ 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.`);
|
||||
}
|
||||
|
||||
function checkIPv6Support(callback) {
|
||||
const server = net.createServer();
|
||||
|
||||
server.listen(0, '::1', () => {
|
||||
server.close(() => callback(true));
|
||||
}).on('error', (error) => {
|
||||
if (error.code === 'EADDRNOTAVAIL') callback(false);
|
||||
else callback(false);
|
||||
});
|
||||
}
|
||||
|
||||
function checkLatency(host, port = 80, timeout = 2000) {
|
||||
return new Promise(resolve => {
|
||||
const start = Date.now();
|
||||
@@ -241,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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,11 +337,10 @@ function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userC
|
||||
const rawCommand = message.toString();
|
||||
const command = rawCommand.replace(/[\r\n]+/g, '');
|
||||
const now = Date.now();
|
||||
const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
|
||||
if (endpointName === 'text') consoleCmd.logDebug(`Command received from \x1b[90m${clientIp}\x1b[0m: ${command}`);
|
||||
|
||||
if (command.length > maxPayloadSize) {
|
||||
consoleCmd.logWarn(`Command from \x1b[90m${normalizedClientIp}\x1b[0m on \x1b[90m/${endpointName}\x1b[0m exceeded maximum payload size (${parseInt(command.length / 1024)} KB / ${parseInt(maxPayloadSize / 1024)} KB).`);
|
||||
consoleCmd.logWarn(`Command from \x1b[90m${clientIp}\x1b[0m on \x1b[90m/${endpointName}\x1b[0m exceeded maximum payload size (${parseInt(command.length / 1024)} KB / ${parseInt(maxPayloadSize / 1024)} KB).`);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -276,12 +358,12 @@ function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userC
|
||||
consoleCmd.logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming with rapid commands. Connection will be terminated and user will be banned.`);
|
||||
|
||||
// Check if the normalized IP is already in the banlist
|
||||
const isAlreadyBanned = serverConfig.webserver.banlist.some(banEntry => banEntry[0] === normalizedClientIp);
|
||||
const isAlreadyBanned = serverConfig.webserver.banlist.some(banEntry => banEntry[0] === clientIp);
|
||||
|
||||
if (!isAlreadyBanned) {
|
||||
// Add the normalized IP to the banlist
|
||||
serverConfig.webserver.banlist.push([normalizedClientIp, 'Unknown', Date.now(), '[Auto ban] Spam']);
|
||||
consoleCmd.logInfo(`User \x1b[90m${normalizedClientIp}\x1b[0m has been added to the banlist due to extreme spam.`);
|
||||
serverConfig.webserver.banlist.push([clientIp, 'Unknown', Date.now(), '[Auto ban] Spam']);
|
||||
consoleCmd.logInfo(`User \x1b[90m${clientIp}\x1b[0m has been added to the banlist due to extreme spam.`);
|
||||
configSave();
|
||||
}
|
||||
|
||||
@@ -341,19 +423,67 @@ 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;
|
||||
}
|
||||
|
||||
function normalizeIp(ip) {
|
||||
if(ip && ip.startsWith('::ffff:')) return ip.substring(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
function isLocalhost(ip) {
|
||||
const normalized = normalizeIp(ip);
|
||||
return normalized === '127.0.0.1' || normalized === '::1';
|
||||
}
|
||||
|
||||
function isTrustedProxy(ip) {
|
||||
return serverConfig.trustedProxies.includes(normalizeIp(ip));
|
||||
}
|
||||
|
||||
function getIpAddress(request) {
|
||||
const remoteIpRaw = request.socket.remoteAddress;
|
||||
const remoteIp = normalizeIp(remoteIpRaw);
|
||||
const xff = request.headers['x-forwarded-for'];
|
||||
|
||||
if (xff && !isLocalhost(remoteIp) && !isTrustedProxy(remoteIp)) {
|
||||
consoleCmd.logWarn(`Untrusted proxy tried to set X-Forwarded-For: ${xff} (remote: ${remoteIpRaw})`);
|
||||
return remoteIp;
|
||||
}
|
||||
|
||||
if (xff) return normalizeIp(xff.split(',')[0].trim());
|
||||
|
||||
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, checkIPv6Support, checkLatency,
|
||||
kickClient, checkLatency,
|
||||
antispamProtection, escapeHtml, findServerFiles,
|
||||
startPluginsWithDelay
|
||||
startPluginsWithDelay, getIpAddress, isAdmin
|
||||
}
|
||||
+10
-570
@@ -1,576 +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, perMessageDeflate: true });
|
||||
const rdsWss = new WebSocket.Server({ noServer: true });
|
||||
const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true });
|
||||
|
||||
// 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\x1b[0m');
|
||||
console.log("v" + pjson.version)
|
||||
console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m');
|
||||
|
||||
const audioWss = require('./stream/ws.js');
|
||||
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());
|
||||
const chatWss = createChatServer(storage);
|
||||
|
||||
connectToXdrd();
|
||||
connectToSerial();
|
||||
tunnel.connect();
|
||||
|
||||
// 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, '../web'));
|
||||
app.use('/', endpoints);
|
||||
|
||||
const tunerLockTracker = new WeakMap();
|
||||
const ipConnectionCounts = new Map(); // Per-IP limit variables
|
||||
const ipLogTimestamps = new Map();
|
||||
const MAX_CONNECTIONS_PER_IP = 5;
|
||||
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 = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
||||
const userCommandHistory = {};
|
||||
const normalizedClientIp = clientIp?.replace(/^::ffff:/, '');
|
||||
|
||||
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 (${normalizedClientIp}) \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 (${normalizedClientIp}) \x1b[90m[${currentUsers}]`);
|
||||
});
|
||||
|
||||
ws.on('error', console.error);
|
||||
});
|
||||
|
||||
// Additional web socket for using plugins
|
||||
pluginsWss.on('connection', (ws, request) => {
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
||||
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
|
||||
const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins');
|
||||
|
||||
let messageData;
|
||||
|
||||
try { // JS Requires the try statement to have braces, unlike the if statement. This extends the huge list of proofs that this is a fucking toy language
|
||||
messageData = JSON.parse(message); // Attempt to parse the JSON
|
||||
} catch (error) {
|
||||
// console.error("Failed to parse message:", error); // Log the error
|
||||
return; // Exit if parsing fails
|
||||
}
|
||||
|
||||
const modifiedMessage = JSON.stringify(messageData);
|
||||
|
||||
// Broadcast the message to all other clients
|
||||
pluginsWss.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(modifiedMessage); // Send the message to all clients
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('error', error => {
|
||||
logError('WebSocket Extra error: ' + error); // Use custom logError function
|
||||
});
|
||||
});
|
||||
|
||||
// Websocket register for /text, /audio and /chat paths
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
||||
if (serverConfig.webserver.banlist?.includes(clientIp)) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
var upgradeWss = undefined;
|
||||
if (request.url === '/text') upgradeWss = wss;
|
||||
else if (request.url === '/audio') upgradeWss = audioWss;
|
||||
else if (request.url === '/chat' && serverConfig.webserver.chatEnabled === true) upgradeWss = chatWss;
|
||||
else if (request.url === '/rds' || request.url === '/rdsspy') upgradeWss = rdsWss;
|
||||
else if (request.url === '/data_plugins') upgradeWss = pluginsWss;
|
||||
|
||||
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
|
||||
fmdxList.update();
|
||||
|
||||
helpers.checkIPv6Support((isIPv6Supported) => {
|
||||
const ipv4Address = serverConfig.webserver.webserverIp === '0.0.0.0' ? 'localhost' : serverConfig.webserver.webserverIp;
|
||||
const ipv6Address = '::'; // This will bind to all available IPv6 interfaces
|
||||
const port = serverConfig.webserver.webserverPort;
|
||||
|
||||
const logServerStart = (address, isIPv6) => {
|
||||
const formattedAddress = isIPv6 ? `[${address}]` : address;
|
||||
logInfo(`Web server has started on address \x1b[34mhttp://${formattedAddress}:${port}\x1b[0m.`);
|
||||
};
|
||||
|
||||
const startServer = (address, isIPv6) => {
|
||||
httpServer.listen(port, address, () => {
|
||||
if (!isIPv6 && !configExists()) logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${port}\x1b[0m to continue with setup.`);
|
||||
else logServerStart(address, isIPv6);
|
||||
});
|
||||
};
|
||||
|
||||
if (isIPv6Supported) {
|
||||
startServer(ipv4Address, false); // Start on IPv4
|
||||
startServer(ipv6Address, true); // Start on IPv6
|
||||
} else startServer(ipv4Address, false); // Start only on IPv4
|
||||
});
|
||||
|
||||
pluginsApi.registerServerContext({ wss, pluginsWss, httpServer, serverConfig });
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+244
-36
@@ -1,5 +1,148 @@
|
||||
const { rdsEccLookup, iso, countries } = require("./rds_country.js")
|
||||
|
||||
function decode_charset(input) {
|
||||
var character = input;
|
||||
switch (character) {
|
||||
case 0x0A: character = ' '; break;
|
||||
case 0x20: character = ' '; break;
|
||||
case 0x5E: character = '―'; break;
|
||||
case 0x5F: character = '_'; break;
|
||||
case 0x60: character = '`'; break;
|
||||
case 0x7E: character = '¯'; break;
|
||||
case 0x7F: character = ' '; break;
|
||||
case 0x80: character = 'á'; break;
|
||||
case 0x81: character = 'à'; break;
|
||||
case 0x82: character = 'é'; break;
|
||||
case 0x83: character = 'è'; break;
|
||||
case 0x84: character = 'í'; break;
|
||||
case 0x85: character = 'ì'; break;
|
||||
case 0x86: character = 'ó'; break;
|
||||
case 0x87: character = 'ò'; break;
|
||||
case 0x88: character = 'ú'; break;
|
||||
case 0x89: character = 'ù'; break;
|
||||
case 0x8A: character = 'Ñ'; break;
|
||||
case 0x8B: character = 'Ç'; break;
|
||||
case 0x8C: character = 'Ş'; break;
|
||||
case 0x8D: character = 'β'; break;
|
||||
case 0x8E: character = '¡'; break;
|
||||
case 0x8F: character = 'IJ'; break;
|
||||
case 0x90: character = 'â'; break;
|
||||
case 0x91: character = 'ä'; break;
|
||||
case 0x92: character = 'ê'; break;
|
||||
case 0x93: character = 'ë'; break;
|
||||
case 0x94: character = 'î'; break;
|
||||
case 0x95: character = 'ï'; break;
|
||||
case 0x96: character = 'ô'; break;
|
||||
case 0x97: character = 'ö'; break;
|
||||
case 0x98: character = 'û'; break;
|
||||
case 0x99: character = 'ü'; break;
|
||||
case 0x9A: character = 'ñ'; break;
|
||||
case 0x9B: character = 'ç'; break;
|
||||
case 0x9C: character = 'ş'; break;
|
||||
case 0x9D: character = 'ǧ'; break;
|
||||
case 0x9E: character = 'ı'; break;
|
||||
case 0x9F: character = 'ij'; break;
|
||||
case 0xA0: character = 'ª'; break;
|
||||
case 0xA1: character = 'α'; break;
|
||||
case 0xA2: character = '©'; break;
|
||||
case 0xA3: character = '‰'; break;
|
||||
case 0xA4: character = 'Ǧ'; break;
|
||||
case 0xA5: character = 'ě'; break;
|
||||
case 0xA6: character = 'ň'; break;
|
||||
case 0xA7: character = 'ő'; break;
|
||||
case 0xA8: character = 'π'; break;
|
||||
case 0xA9: character = '€'; break;
|
||||
case 0xAA: character = '£'; break;
|
||||
case 0xAB: character = '$'; break;
|
||||
case 0xAC: character = '←'; break;
|
||||
case 0xAD: character = '↑'; break;
|
||||
case 0xAE: character = '→'; break;
|
||||
case 0xAF: character = '↓'; break;
|
||||
case 0xB0: character = 'º'; break;
|
||||
case 0xB1: character = '¹'; break;
|
||||
case 0xB2: character = '²'; break;
|
||||
case 0xB3: character = '³'; break;
|
||||
case 0xB4: character = '±'; break;
|
||||
case 0xB5: character = 'İ'; break;
|
||||
case 0xB6: character = 'ń'; break;
|
||||
case 0xB7: character = 'ű'; break;
|
||||
case 0xB8: character = 'µ'; break;
|
||||
case 0xB9: character = '¿'; break;
|
||||
case 0xBA: character = '÷'; break;
|
||||
case 0xBB: character = '°'; break;
|
||||
case 0xBC: character = '¼'; break;
|
||||
case 0xBD: character = '½'; break;
|
||||
case 0xBE: character = '¾'; break;
|
||||
case 0xBF: character = '§'; break;
|
||||
case 0xC0: character = 'Á'; break;
|
||||
case 0xC1: character = 'À'; break;
|
||||
case 0xC2: character = 'É'; break;
|
||||
case 0xC3: character = 'È'; break;
|
||||
case 0xC4: character = 'Í'; break;
|
||||
case 0xC5: character = 'Ì'; break;
|
||||
case 0xC6: character = 'Ó'; break;
|
||||
case 0xC7: character = 'Ò'; break;
|
||||
case 0xC8: character = 'Ú'; break;
|
||||
case 0xC9: character = 'Ù'; break;
|
||||
case 0xCA: character = 'Ř'; break;
|
||||
case 0xCB: character = 'Č'; break;
|
||||
case 0xCC: character = 'Š'; break;
|
||||
case 0xCD: character = 'Ž'; break;
|
||||
case 0xCE: character = 'Ð'; break;
|
||||
case 0xCF: character = 'Ŀ'; break;
|
||||
case 0xD0: character = 'Â'; break;
|
||||
case 0xD1: character = 'Ä'; break;
|
||||
case 0xD2: character = 'Ê'; break;
|
||||
case 0xD3: character = 'Ë'; break;
|
||||
case 0xD4: character = 'Î'; break;
|
||||
case 0xD5: character = 'Ï'; break;
|
||||
case 0xD6: character = 'Ô'; break;
|
||||
case 0xD7: character = 'Ö'; break;
|
||||
case 0xD8: character = 'Û'; break;
|
||||
case 0xD9: character = 'Ü'; break;
|
||||
case 0xDA: character = 'ř'; break;
|
||||
case 0xDB: character = 'č'; break;
|
||||
case 0xDC: character = 'š'; break;
|
||||
case 0xDD: character = 'ž'; break;
|
||||
case 0xDE: character = 'đ'; break;
|
||||
case 0xDF: character = 'ŀ'; break;
|
||||
case 0xE0: character = 'Ã'; break;
|
||||
case 0xE1: character = 'Å'; break;
|
||||
case 0xE2: character = 'Æ'; break;
|
||||
case 0xE3: character = 'Œ'; break;
|
||||
case 0xE4: character = 'ŷ'; break;
|
||||
case 0xE5: character = 'Ý'; break;
|
||||
case 0xE6: character = 'Õ'; break;
|
||||
case 0xE7: character = 'Ø'; break;
|
||||
case 0xE8: character = 'Þ'; break;
|
||||
case 0xE9: character = 'Ŋ'; break;
|
||||
case 0xEA: character = 'Ŕ'; break;
|
||||
case 0xEB: character = 'Ć'; break;
|
||||
case 0xEC: character = 'Ś'; break;
|
||||
case 0xED: character = 'Ź'; break;
|
||||
case 0xEE: character = 'Ŧ'; break;
|
||||
case 0xEF: character = 'ð'; break;
|
||||
case 0xF0: character = 'ã'; break;
|
||||
case 0xF1: character = 'å'; break;
|
||||
case 0xF2: character = 'æ'; break;
|
||||
case 0xF3: character = 'œ'; break;
|
||||
case 0xF4: character = 'ŵ'; break;
|
||||
case 0xF5: character = 'ý'; break;
|
||||
case 0xF6: character = 'õ'; break;
|
||||
case 0xF7: character = 'ø'; break;
|
||||
case 0xF8: character = 'þ'; break;
|
||||
case 0xF9: character = 'ŋ'; break;
|
||||
case 0xFA: character = 'ŕ'; break;
|
||||
case 0xFB: character = 'ć'; break;
|
||||
case 0xFC: character = 'ś'; break;
|
||||
case 0xFD: character = 'ź'; break;
|
||||
case 0xFE: character = 'ŧ'; break;
|
||||
case 0xFF: character = ' '; break;
|
||||
default: character = String.fromCharCode(input); break;
|
||||
}
|
||||
return character
|
||||
}
|
||||
|
||||
class RDSDecoder {
|
||||
constructor(data) {
|
||||
this.data = data;
|
||||
@@ -9,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;
|
||||
@@ -25,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;
|
||||
|
||||
@@ -36,17 +184,17 @@ class RDSDecoder {
|
||||
}
|
||||
|
||||
decodeGroup(blockA, blockB, blockC, blockD, error) {
|
||||
const a_error = (error & 0xC0) >> 6;
|
||||
const b_error = (error & 0x30) >> 4;
|
||||
const c_error = (error & 0xc) >> 2;
|
||||
const a_error = (error >> 6) & 3;
|
||||
const b_error = (error >> 4) & 3;
|
||||
const c_error = (error >> 2) & 3;
|
||||
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;
|
||||
}
|
||||
|
||||
if(b_error != 0) return; // B chooses what group this is, if this has errors, we are screwed
|
||||
if(b_error !== 0) return; // B chooses what group this is, if this has errors, we are screwed
|
||||
|
||||
const group = (blockB >> 12) & 0xF;
|
||||
const version = (blockB >> 11) & 0x1;
|
||||
@@ -66,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)) {
|
||||
@@ -91,10 +240,19 @@ class RDSDecoder {
|
||||
|
||||
const idx = blockB & 0x3;
|
||||
|
||||
this.ps[idx * 2] = String.fromCharCode(blockD >> 8);
|
||||
this.ps[idx * 2 + 1] = String.fromCharCode(blockD & 0xFF);
|
||||
this.ps_errors[idx * 2] = Math.ceil(d_error * (10/3));
|
||||
this.ps_errors[idx * 2 + 1] = Math.ceil(d_error * (10/3));
|
||||
const last_err = this.ps_errors[idx * 2];
|
||||
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);
|
||||
|
||||
// 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;
|
||||
this.ps_errors[idx * 2] = cur_err;
|
||||
this.ps_errors[idx * 2 + 1] = cur_err;
|
||||
|
||||
this.data.ps = this.ps.join('');
|
||||
this.data.ps_errors = this.ps_errors.join(',');
|
||||
@@ -103,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;
|
||||
}
|
||||
@@ -117,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] = String.fromCharCode(blockC >> 8);
|
||||
this.rt1[idx * multiplier + 1] = String.fromCharCode(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] = String.fromCharCode(blockD >> 8);
|
||||
this.rt1[idx * multiplier + offset + 1] = String.fromCharCode(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")
|
||||
@@ -148,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] = String.fromCharCode(blockC >> 8);
|
||||
this.rt0[idx * multiplier + 1] = String.fromCharCode(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] = String.fromCharCode(blockD >> 8);
|
||||
this.rt0[idx * multiplier + offset + 1] = String.fromCharCode(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");
|
||||
@@ -177,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)
|
||||
}
|
||||
|
||||
+169
-317
@@ -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",
|
||||
@@ -105,7 +82,7 @@ var countries = [
|
||||
"Turkey",
|
||||
"Palestine",
|
||||
"Turkmenistan",
|
||||
"Poland",
|
||||
"Rzeczpospolita Polska",
|
||||
"Uganda",
|
||||
"Portugal",
|
||||
"Ukraine",
|
||||
@@ -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", null, "USA/VI/PR", "USA/VI/PR", null
|
||||
"USA/VI/PR", "", "USA/VI/PR", "USA/VI/PR", ""
|
||||
],
|
||||
// A1
|
||||
[
|
||||
null, null, null, null, null,
|
||||
null, null, null, null, null,
|
||||
[ "", "", "", "", "", "", "", "", "", "",
|
||||
"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", null,
|
||||
"Paraguay", "Nicaragua", null, "Panama", "Dominica",
|
||||
[ "Bolivia", "Colombia", "Jamaica", "Martinique", "",
|
||||
"Paraguay", "Nicaragua", "", "Panama", "Dominica",
|
||||
"Dominican Republic", "Chile", "Grenada", "Turks and Caicos islands", "Guyana"
|
||||
],
|
||||
// A4
|
||||
[
|
||||
"Guatemala", "Honduras", "Aruba", null, "Montserrat",
|
||||
[ "Guatemala", "Honduras", "Aruba", "", "Montserrat",
|
||||
"Trinidad and Tobago", "Peru", "Suriname", "Uruguay", "St. Kitts",
|
||||
"St. Lucia", "El Salvador", "Haiti", "Venezuela", "Virgin Islands"
|
||||
],
|
||||
// A5
|
||||
[
|
||||
null, null, null, null, null,
|
||||
null, null, null, null, null,
|
||||
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"
|
||||
],
|
||||
[ "", "", "", "", "", "", "", "", "", "",
|
||||
"Mexico", "St. Vincent", "Mexico", "Mexico", "Mexico"],
|
||||
// A6
|
||||
[
|
||||
null, null, null, null, null,
|
||||
null, null, null, null, null,
|
||||
null, null, null, null, "St. Pierre and Miquelon"
|
||||
]
|
||||
["", "", "", "", "", "", "", "", "", "", "", "", "", "", "St. Pierre and Miquelon"]
|
||||
];
|
||||
|
||||
const rdsEccD0D4Lut = [
|
||||
@@ -508,28 +360,28 @@ const rdsEccD0D4Lut = [
|
||||
[
|
||||
"Sierra Leone", "Zimbabwe", "Mozambique", "Uganda", "Swaziland",
|
||||
"Kenya", "Somalia", "Niger", "Chad", "Guinea-Bissau",
|
||||
"DR Congo", "Cote d'Ivoire", null, "Zambia", "Eritrea"
|
||||
"DR Congo", "Cote d'Ivoire", "", "Zambia", "Eritrea"
|
||||
],
|
||||
// D3
|
||||
[
|
||||
null, null, "Western Sahara", "Cabinda", "Rwanda",
|
||||
"Lesotho", null, "Seychelles", null, "Mauritius",
|
||||
null, "Sudan", null, null, null
|
||||
"", "", "Western Sahara", "Cabinda", "Rwanda",
|
||||
"Lesotho", "", "Seychelles", "", "Mauritius",
|
||||
"", "Sudan", "", "", ""
|
||||
],
|
||||
// D4
|
||||
[
|
||||
null, null, null, null, null,
|
||||
null, null, null, null, "South Sudan",
|
||||
null, null, null, null, null
|
||||
"", "", "", "", "",
|
||||
"", "", "", "", "South Sudan",
|
||||
"", "", "", "", ""
|
||||
]
|
||||
];
|
||||
|
||||
const rdsEccE0E5Lut = [
|
||||
// E0
|
||||
[
|
||||
"Germany", "Algeria", "Andorra", "Israel", "Italy",
|
||||
"Germany", "Algeria", "Andorra", "-", "Italy",
|
||||
"Belgium", "Russia", "Palestine", "Albania", "Austria",
|
||||
"Hungary", "Malta", "Germany", null, "Egypt"
|
||||
"Hungary", "Malta", "Germany", "", "Egypt"
|
||||
],
|
||||
// E1
|
||||
[
|
||||
@@ -539,27 +391,27 @@ const rdsEccE0E5Lut = [
|
||||
],
|
||||
// E2
|
||||
[
|
||||
"Morocco", "Czechia", "Poland", "Vatican", "Slovakia",
|
||||
"Syria", "Tunisia", null, "Liechtenstein", "Iceland",
|
||||
"Morocco", "Czechia", "Rzeczpospolita Polska", "Vatican", "Slovakia",
|
||||
"Syria", "Tunisia", "", "Liechtenstein", "Iceland",
|
||||
"Monaco", "Lithuania", "Serbia", "Spain", "Norway"
|
||||
],
|
||||
// E3
|
||||
[
|
||||
"Montenegro", "Ireland", "Turkey", null, "Tajikistan",
|
||||
null, null, "Netherlands", "Latvia", "Lebanon",
|
||||
"Montenegro", "Ireland", "Turkey", "", "Tajikistan",
|
||||
"", "", "Netherlands", "Latvia", "Lebanon",
|
||||
"Azerbaijan", "Croatia", "Kazakhstan", "Sweden", "Belarus"
|
||||
],
|
||||
// E4
|
||||
[
|
||||
"Moldova", "Estonia", "Macedonia", null, null,
|
||||
"Moldova", "Estonia", "Macedonia", "", "",
|
||||
"Ukraine", "Kosovo", "Portugal", "Slovenia", "Armenia",
|
||||
"Uzbekistan", "Georgia", null, "Turkmenistan", "Bosnia Herzegovina"
|
||||
"Uzbekistan", "Georgia", "", "Turkmenistan", "Bosnia Herzegovina"
|
||||
],
|
||||
// E5
|
||||
[
|
||||
null, null, "Kyrgyzstan", null, null,
|
||||
null, null, null, null, null,
|
||||
null, null, null, null, null
|
||||
"", "", "Kyrgyzstan", "", "",
|
||||
"", "", "", "", "",
|
||||
"", "", "", "", ""
|
||||
]
|
||||
];
|
||||
|
||||
@@ -584,15 +436,15 @@ const rdsEccF0F4Lut = [
|
||||
],
|
||||
// F3
|
||||
[
|
||||
"Laos", "Thailand", "Tonga", null, null,
|
||||
null, null, "China", "Papua New Guinea", null,
|
||||
"Yemen", null, null, "Micronesia", "Mongolia"
|
||||
"Laos", "Thailand", "Tonga", "", "",
|
||||
"", "", "China", "Papua New Guinea", "",
|
||||
"Yemen", "", "", "Micronesia", "Mongolia"
|
||||
],
|
||||
// F4
|
||||
[
|
||||
null, null, null, null, null,
|
||||
null, null, null, "China", null,
|
||||
"Marshall Islands", null, null, null, null
|
||||
"", "", "", "", "",
|
||||
"", "", "", "China", "",
|
||||
"Marshall Islands", "", "", "", ""
|
||||
]
|
||||
];
|
||||
|
||||
|
||||
+13
-30
@@ -1,7 +1,7 @@
|
||||
/* Libraries / Imports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('./console');
|
||||
const { logError, logInfo } = require('./console');
|
||||
|
||||
let configName = 'config';
|
||||
|
||||
@@ -20,7 +20,7 @@ let serverConfig = {
|
||||
banlist: [],
|
||||
chatEnabled: true,
|
||||
tuningLimit: false,
|
||||
tuningLowerLimit: "100",
|
||||
tuningLowerLimit: "65",
|
||||
tuningUpperLimit: "108",
|
||||
presets: [
|
||||
"87.5",
|
||||
@@ -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: "0.95",
|
||||
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,
|
||||
@@ -126,7 +108,12 @@ let serverConfig = {
|
||||
stereoNoUsers: "0",
|
||||
antennaStartup: "0",
|
||||
antennaNoUsers: "0",
|
||||
antennaNoUsersDelay: false
|
||||
antennaNoUsersDelay: false,
|
||||
trustedProxies: [],
|
||||
webRtc: {
|
||||
flags: {disablews: false},
|
||||
audio: {}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to add missing fields without overwriting existing values
|
||||
@@ -135,9 +122,7 @@ function addMissingFields(target, source) {
|
||||
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
|
||||
if (!target[key]) target[key] = {}; // Create missing object
|
||||
addMissingFields(target[key], source[key]); // Recursively add missing fields
|
||||
} else {
|
||||
if (target[key] === undefined) target[key] = source[key]; // Add missing fields only
|
||||
}
|
||||
} else if(target[key] === undefined) target[key] = source[key]; // Add missing fields only
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,7 +143,7 @@ function configUpdate(newConfig) {
|
||||
serverConfig.plugins = newConfig.plugins;
|
||||
delete newConfig.webserver.banlist;
|
||||
}
|
||||
|
||||
|
||||
deepMerge(serverConfig, newConfig); // Overwrite with newConfig values
|
||||
configSave();
|
||||
}
|
||||
@@ -192,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,12 +17,11 @@ 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) {
|
||||
if (!serverConfig.identification.token)
|
||||
{
|
||||
if (!serverConfig.identification.token) {
|
||||
logInfo("Registered to FM-DX Server Map successfully.");
|
||||
serverConfig.identification.token = data.token;
|
||||
configSave();
|
||||
@@ -84,4 +81,4 @@ function update() {
|
||||
timeoutID = setInterval(sendKeepalive, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports.update = update;
|
||||
module.exports = update;
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
let connectedUsers = [];
|
||||
let chatHistory = [];
|
||||
let websocket_delegation = new Map();
|
||||
var ctl_output;
|
||||
var tx_search_hook = {};
|
||||
|
||||
module.exports = { connectedUsers, chatHistory };
|
||||
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;
|
||||
+30
-18
@@ -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", "+flush_packets",
|
||||
"-fflags", "+nobuffer+flush_packets",
|
||||
"-flags", "low_delay",
|
||||
"-rtbufsize", "4096",
|
||||
"-rtbufsize", "2048",
|
||||
"-probesize", "128",
|
||||
|
||||
...inputArgs,
|
||||
|
||||
"-thread_queue_size", "1536",
|
||||
"-thread_queue_size", "1024",
|
||||
"-ar", String(sampleRate),
|
||||
"-ac", String(channels),
|
||||
|
||||
@@ -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
-14
@@ -1,18 +1,9 @@
|
||||
const WebSocket = require('ws');
|
||||
const { serverConfig } = require('../server_config');
|
||||
const audio_pipe = require('./index.js');
|
||||
const storage = require('../storage');
|
||||
|
||||
const audioWss = new WebSocket.Server({ noServer: true, skipUTF8Validation: true });
|
||||
|
||||
audioWss.on('connection', (ws, request) => {
|
||||
const clientIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
||||
|
||||
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});
|
||||
@@ -20,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,99 +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 connect() {
|
||||
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);
|
||||
}
|
||||
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')) {
|
||||
const reason = line.substring(line.indexOf(': ')+2);
|
||||
logError('Failed to connect to tunnel, reason: ' + reason);
|
||||
} 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
|
||||
};
|
||||
+31
-63
@@ -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;
|
||||
@@ -42,35 +40,6 @@ if (typeof algorithms[algoSetting] !== 'undefined') {
|
||||
// Build the TX database.
|
||||
setTimeout(buildTxDatabase, 3000);
|
||||
|
||||
if (serverConfig.identification.gpsMode) {
|
||||
// 5-second delay before activation of GPS lat/lon websocket
|
||||
setTimeout(() => {
|
||||
const websocket = new WebSocket(externalWsUrl);
|
||||
consoleCmd.logInfo('Set up GPS websocket for lat/lon');
|
||||
// Event listener to receive data
|
||||
websocket.on('message', (data) => {
|
||||
try {
|
||||
// Parse the received data
|
||||
const parsedData = JSON.parse(data);
|
||||
|
||||
// Check if the dataset is of type GPS
|
||||
if (parsedData.type === "GPS" && parsedData.value) {
|
||||
const gpsData = parsedData.value;
|
||||
const { status, time, lat, lon, alt, mode } = gpsData;
|
||||
|
||||
if (status === "active") {
|
||||
Latitude = parseFloat(lat);
|
||||
Longitude = parseFloat(lon);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
consoleCmd.logError("Error processing WebSocket data:", error);
|
||||
}
|
||||
});
|
||||
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Function to build local TX database from FMDX Maps endpoint.
|
||||
async function buildTxDatabase() {
|
||||
if (Latitude.length > 0 && Longitude.length > 0) {
|
||||
@@ -182,16 +151,16 @@ function getStateForCoordinates(lat, lon) {
|
||||
*/
|
||||
function validPsCompare(rdsPs, stationPs) {
|
||||
if (typeof stationPs !== 'string' || typeof rdsPs !== 'string') {
|
||||
consoleCmd.logError(`Invalid TX values. stationPs: ${stationPs}, rdsPs: ${rdsPs}`);
|
||||
consoleCmd.logDebug(`Invalid TX values. stationPs: ${stationPs}, rdsPs: ${rdsPs}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -199,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.
|
||||
@@ -249,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 }) => ({
|
||||
@@ -257,15 +227,6 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
stations: [station]
|
||||
}));
|
||||
|
||||
if (filteredLocations.length > 1) {
|
||||
const extraFilteredLocations = filteredLocations.map(locData => ({
|
||||
...locData,
|
||||
stations: locData.stations.filter(station => (station.ps.toLowerCase() === rdsPs.replace(/ /g, '_').toLowerCase()))
|
||||
})).filter(locData => locData.stations.length > 0);
|
||||
|
||||
if (extraFilteredLocations.length > 0) filteredLocations = extraFilteredLocations;
|
||||
}
|
||||
|
||||
// Only check PS if we have more than one match.
|
||||
if (filteredLocations.length > 1) {
|
||||
filteredLocations = filteredLocations.map(locData => ({
|
||||
@@ -273,7 +234,20 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
stations: locData.stations.filter(station => validPsCompare(rdsPs, station.ps))
|
||||
})).filter(locData => locData.stations.length > 0);
|
||||
}
|
||||
|
||||
|
||||
if (filteredLocations.length > 1) {
|
||||
const extraFilteredLocations = filteredLocations.map(locData => ({
|
||||
...locData,
|
||||
stations: locData.stations.filter(station =>
|
||||
station.ps?.toLowerCase().includes(
|
||||
rdsPs.replace(/ /g, '_').toLowerCase()
|
||||
) ?? false
|
||||
)
|
||||
})).filter(locData => locData.stations.length > 0);
|
||||
|
||||
if (extraFilteredLocations.length > 0) filteredLocations = extraFilteredLocations;
|
||||
}
|
||||
|
||||
for (let loc of filteredLocations) {
|
||||
loc = Object.assign(loc, loc.stations[0]);
|
||||
delete loc.stations;
|
||||
@@ -281,12 +255,10 @@ 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
|
||||
);
|
||||
const tropoPriority = filteredLocations.some(loc => loc.distanceKm < 700 && loc.erp >= 10);
|
||||
let esMode = false;
|
||||
if (!tropoPriority) esMode = checkEs();
|
||||
for (let loc of filteredLocations) loc.score = evaluateStation(loc, esMode);
|
||||
@@ -294,16 +266,14 @@ async function fetchTx(freq, piCode, rdsPs) {
|
||||
filteredLocations.sort((a, b) => b.score - a.score);
|
||||
match = filteredLocations[0];
|
||||
// Have a maximum of 10 extra matches and remove any with less than 1/10 of the winning score
|
||||
multiMatches = filteredLocations
|
||||
.slice(1, 11)
|
||||
.filter(obj => obj.score >= (match.score / 10));
|
||||
multiMatches = filteredLocations.slice(1, 11).filter(obj => obj.score >= (match.score / 10));
|
||||
} else if (filteredLocations.length === 1) {
|
||||
match = filteredLocations[0];
|
||||
match.score = 1;
|
||||
}
|
||||
|
||||
if (match) {
|
||||
if (match.itu == 'USA') { // Also known as Dumbfuckinstan. they should not go to hell, but hell+ (it is NOT better)
|
||||
if (match.itu == 'USA') { // Also known as Dumbfuckinstan. they should not go to hell, but hell+ (it is NOT better). Jews have anti-semitism, but what do americans have?
|
||||
const state = getStateForCoordinates(match.lat, match.lon);
|
||||
if (state) match.state = state; // Add state to matchingCity
|
||||
}
|
||||
@@ -362,17 +332,15 @@ function checkEs() {
|
||||
}
|
||||
|
||||
function haversine(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const dLat = deg2rad(lat2 - lat1);
|
||||
const dLon = deg2rad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const distance = R * c;
|
||||
const distance = 6371 * c;
|
||||
|
||||
const y = Math.sin(dLon) * Math.cos(deg2rad(lat2));
|
||||
const x = Math.cos(deg2rad(lat1)) * Math.sin(deg2rad(lat2)) - Math.sin(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.cos(dLon);
|
||||
const azimuth = Math.atan2(y, x);
|
||||
const azimuthDegrees = (azimuth * 180 / Math.PI + 360) % 360;
|
||||
const azimuthDegrees = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
||||
|
||||
return {
|
||||
distanceKm: distance,
|
||||
|
||||
+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 %>"
|
||||
<% } %>
|
||||
```
|
||||
@@ -15,18 +15,17 @@
|
||||
<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>
|
||||
There's a possibility you were kicked by the system.<br>
|
||||
Please try again later.</p>
|
||||
|
||||
<% if (reason) { %>
|
||||
<p><strong>Reason:</strong> too dig of a bick</p>
|
||||
<% } %>
|
||||
<% if (reason) { %>
|
||||
<p><%= reason %></p>
|
||||
<% } else { %>
|
||||
<p>Service refused, please try again later.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</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>
|
||||
@@ -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,25 +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 %>.">
|
||||
|
||||
<!-- 3LAS Scripts for Audio streaming -->
|
||||
<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/3las/main.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>
|
||||
|
||||
@@ -183,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>
|
||||
@@ -191,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>
|
||||
@@ -463,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>
|
||||
@@ -489,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' },
|
||||
]
|
||||
}) %>
|
||||
@@ -551,18 +544,12 @@
|
||||
</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.com/invite/ZAVNdS74mC')">
|
||||
<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>
|
||||
</div>
|
||||
@@ -573,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>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Setup - 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"></div>
|
||||
<div id="wrapper" class="panel-full">
|
||||
<div class="m-0 no-bg">
|
||||
<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>
|
||||
|
||||
<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>User Agent</th>
|
||||
<th>Online since</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (connectedUsers.length > 0) { %>
|
||||
<% connectedUsers.forEach(user => { %>
|
||||
<tr>
|
||||
<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="4" style="text-align: center">No users online</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/ver.js"></script>
|
||||
<script src="js/toast.js"></script>
|
||||
<script src="js/settings.js"></script>
|
||||
<script src="js/dropdown.js"></script>
|
||||
<script src="js/setup.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,181 +0,0 @@
|
||||
var audioStreamRestartInterval;
|
||||
var elapsedTimeConnectionWatchdog;
|
||||
var _3LAS_Settings = /** @class */ (function () {
|
||||
function _3LAS_Settings() {
|
||||
this.SocketHost = location.hostname ? location.hostname : "127.0.0.1";
|
||||
this.SocketPort = location.port;
|
||||
this.SocketPath = "/";
|
||||
this.Fallback = new Fallback_Settings();
|
||||
}
|
||||
return _3LAS_Settings;
|
||||
}());
|
||||
var _3LAS = /** @class */ (function () {
|
||||
function _3LAS(logger, settings) {
|
||||
this.Logger = logger;
|
||||
if (!this.Logger) {
|
||||
this.Logger = new Logging(null, null);
|
||||
}
|
||||
this.Settings = settings;
|
||||
|
||||
try {
|
||||
this.Fallback = new Fallback(this.Logger, this.Settings.Fallback);
|
||||
this.Fallback.ActivityCallback = this.OnActivity.bind(this);
|
||||
}
|
||||
catch (_b) {
|
||||
this.Fallback = null;
|
||||
}
|
||||
|
||||
if (this.Fallback == null) {
|
||||
this.Logger.Log('3LAS: Browser does not support either media handling methods.');
|
||||
throw new Error();
|
||||
}
|
||||
if (isAndroid) {
|
||||
this.WakeLock = new WakeLock(this.Logger);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(_3LAS.prototype, "Volume", {
|
||||
get: function () {
|
||||
return this.Fallback.Volume;
|
||||
},
|
||||
set: function (value) {
|
||||
this.Fallback.Volume = value;
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
_3LAS.prototype.CanChangeVolume = function () {
|
||||
return true;
|
||||
};
|
||||
_3LAS.prototype.Start = function () {
|
||||
this.ConnectivityFlag = false;
|
||||
this.Stop(); // Attempt to mitigate the 0.5x speed/multiple stream bug
|
||||
|
||||
// Restart audio stream if radio data connection was reestablished
|
||||
// to prevent stuttering audio in some cases
|
||||
if (audioStreamRestartInterval) {
|
||||
clearInterval(audioStreamRestartInterval);
|
||||
}
|
||||
audioStreamRestartInterval = setInterval(() => {
|
||||
if (requiresAudioStreamRestart) {
|
||||
requiresAudioStreamRestart = false;
|
||||
if (Stream) {
|
||||
this.Stop();
|
||||
this.Start();
|
||||
console.log("Audio stream restarted after radio data loss.");
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Stream connection watchdog monitors mp3 frames
|
||||
console.log("Stream connection watchdog active.");
|
||||
let intervalReconnectWatchdog = setInterval(() => {
|
||||
if (Stream) {
|
||||
let endTimeConnectionWatchdog = performance.now();
|
||||
elapsedTimeConnectionWatchdog = endTimeConnectionWatchdog - window.startTimeConnectionWatchdog;
|
||||
//console.log(`Stream frame elapsed time: ${parseInt(elapsedTimeConnectionWatchdog)} ms`);
|
||||
if (elapsedTimeConnectionWatchdog > 2000 && shouldReconnect) {
|
||||
clearInterval(intervalReconnectWatchdog);
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalReconnectWatchdog);
|
||||
console.log("Unstable internet connection detected, reconnecting... (" + parseInt(elapsedTimeConnectionWatchdog) + " ms)");
|
||||
this.Stop();
|
||||
this.Start();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
clearInterval(intervalReconnectWatchdog);
|
||||
this.Stop();
|
||||
console.log("Stream connection watchdog inactive.");
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// This is stupid, but required for Android.... thanks Google :(
|
||||
if (this.WakeLock)
|
||||
this.WakeLock.Begin();
|
||||
try {
|
||||
if (window.location.protocol === 'https:') {
|
||||
this.WebSocket = new WebSocketClient(this.Logger, 'wss://' + this.Settings.SocketHost + ':' + this.Settings.SocketPort.toString() + window.location.pathname + 'audio' , this.OnSocketError.bind(this), this.OnSocketConnect.bind(this), this.OnSocketDataReady.bind(this), this.OnSocketDisconnect.bind(this));
|
||||
}
|
||||
else {
|
||||
this.WebSocket = new WebSocketClient(this.Logger, 'ws://' + this.Settings.SocketHost + ':' + this.Settings.SocketPort.toString() + window.location.pathname + 'audio' , this.OnSocketError.bind(this), this.OnSocketConnect.bind(this), this.OnSocketDataReady.bind(this), this.OnSocketDisconnect.bind(this));
|
||||
}
|
||||
this.Logger.Log("Init of WebSocketClient succeeded");
|
||||
this.Logger.Log("Trying to connect to server.");
|
||||
}
|
||||
catch (e) {
|
||||
this.Logger.Log("Init of WebSocketClient failed: " + e);
|
||||
throw new Error();
|
||||
}
|
||||
};
|
||||
_3LAS.prototype.Stop = function () {
|
||||
try {
|
||||
// Close WebSocket connection
|
||||
if (this.WebSocket) {
|
||||
this.WebSocket.Close();
|
||||
this.WebSocket.OnClose();
|
||||
this.WebSocket = null;
|
||||
this.Logger.Log("WebSocket connection closed.");
|
||||
}
|
||||
|
||||
// Stop WakeLock if it exists and is an Android device
|
||||
if (isAndroid && this.WakeLock) {
|
||||
this.WakeLock.End();
|
||||
this.Logger.Log("WakeLock stopped.");
|
||||
}
|
||||
|
||||
// Reset Fallback if it exists
|
||||
if (this.Fallback) {
|
||||
this.Fallback.OnSocketDisconnect();
|
||||
this.Fallback.Stop();
|
||||
this.Fallback.Reset();
|
||||
this.Logger.Log("Fallback reset.");
|
||||
}
|
||||
|
||||
// Reset connectivity flag
|
||||
if (this.ConnectivityFlag) {
|
||||
this.ConnectivityFlag = null;
|
||||
if (this.ConnectivityCallback) {
|
||||
this.ConnectivityCallback(null);
|
||||
}
|
||||
}
|
||||
|
||||
this.Logger.Log("3LAS stopped successfully.");
|
||||
} catch (e) {
|
||||
this.Logger.Log("Error while stopping 3LAS: " + e);
|
||||
}
|
||||
};
|
||||
_3LAS.prototype.OnActivity = function () {
|
||||
if (this.ActivityCallback)
|
||||
this.ActivityCallback();
|
||||
if (!this.ConnectivityFlag) {
|
||||
this.ConnectivityFlag = true;
|
||||
if (this.ConnectivityCallback)
|
||||
this.ConnectivityCallback(true);
|
||||
}
|
||||
};
|
||||
// Callback function from socket connection
|
||||
_3LAS.prototype.OnSocketError = function (message) {
|
||||
this.Logger.Log("Network error: " + message);
|
||||
this.Fallback.OnSocketError(message);
|
||||
};
|
||||
_3LAS.prototype.OnSocketConnect = function () {
|
||||
this.Logger.Log("Established connection with server.");
|
||||
this.Fallback.OnSocketConnect();
|
||||
this.Fallback.Init(this.WebSocket);
|
||||
};
|
||||
_3LAS.prototype.OnSocketDisconnect = function () {
|
||||
this.Logger.Log("Lost connection to server.");
|
||||
this.Fallback.OnSocketDisconnect();
|
||||
this.Fallback.Reset();
|
||||
if (this.ConnectivityFlag) {
|
||||
this.ConnectivityFlag = false;
|
||||
if (this.ConnectivityCallback)
|
||||
this.ConnectivityCallback(false);
|
||||
}
|
||||
};
|
||||
|
||||
_3LAS.prototype.OnSocketDataReady = function (data) {
|
||||
this.Fallback.OnSocketDataReady(data);
|
||||
};
|
||||
return _3LAS;
|
||||
}());
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
Socket fallback is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var Fallback_Settings = /** @class */ (function () {
|
||||
function Fallback_Settings() {
|
||||
this.Formats = [
|
||||
{ "Mime": "audio/mpeg", "Name": "mp3" },
|
||||
{ "Mime": "audio/wave", "Name": "wav" }
|
||||
];
|
||||
this.MaxVolume = 1.0;
|
||||
this.AutoCorrectSpeed = false;
|
||||
this.InitialBufferLength = 1.0 / 3.0;
|
||||
}
|
||||
return Fallback_Settings;
|
||||
}());
|
||||
var Fallback = /** @class */ (function () {
|
||||
function Fallback(logger, settings) {
|
||||
this.Logger = logger;
|
||||
if (!this.Logger) {
|
||||
this.Logger = new Logging(null, null);
|
||||
}
|
||||
// Create audio context
|
||||
if (typeof AudioContext !== "undefined")
|
||||
this.Audio = new AudioContext();
|
||||
else if (typeof webkitAudioContext !== "undefined")
|
||||
this.Audio = new webkitAudioContext();
|
||||
else if (typeof mozAudioContext !== "undefined")
|
||||
this.Audio = new mozAudioContext();
|
||||
else {
|
||||
this.Logger.Log('3LAS: Browser does not support "AudioContext".');
|
||||
throw new Error();
|
||||
}
|
||||
this.Settings = settings;
|
||||
this.Logger.Log("Detected: " +
|
||||
(OSName == "MacOSX" ? "Mac OSX" : (OSName == "Unknown" ? "Unknown OS" : OSName)) + ", " +
|
||||
(BrowserName == "IE" ? "Internet Explorer" : (BrowserName == "NativeChrome" ? "Chrome legacy" : (BrowserName == "Unknown" ? "Unknown Browser" : BrowserName))));
|
||||
this.SelectedFormatMime = "";
|
||||
this.SelectedFormatName = "";
|
||||
for (var i = 0; i < this.Settings.Formats.length; i++) {
|
||||
if (!AudioFormatReader.CanDecodeTypes([this.Settings.Formats[i].Mime]))
|
||||
continue;
|
||||
this.SelectedFormatMime = this.Settings.Formats[i].Mime;
|
||||
this.SelectedFormatName = this.Settings.Formats[i].Name;
|
||||
break;
|
||||
}
|
||||
if (this.SelectedFormatMime == "" || this.SelectedFormatName == "") {
|
||||
this.Logger.Log("None of the available MIME types are supported.");
|
||||
throw new Error();
|
||||
}
|
||||
this.Logger.Log("Using websocket fallback with MIME: " + this.SelectedFormatMime);
|
||||
try {
|
||||
this.Player = new LiveAudioPlayer(this.Audio, this.Logger, this.Settings.MaxVolume, this.Settings.InitialBufferLength, this.Settings.AutoCorrectSpeed);
|
||||
this.Logger.Log("Init of LiveAudioPlayer succeeded");
|
||||
}
|
||||
catch (e) {
|
||||
this.Logger.Log("Init of LiveAudioPlayer failed: " + e);
|
||||
throw new Error();
|
||||
}
|
||||
try {
|
||||
this.FormatReader = AudioFormatReader.Create(this.SelectedFormatMime, this.Audio, this.Logger, this.OnReaderError.bind(this), this.Player.CheckBeforeDecode, this.OnReaderDataReady.bind(this), AudioFormatReader.DefaultSettings());
|
||||
this.Logger.Log("Init of AudioFormatReader succeeded");
|
||||
}
|
||||
catch (e) {
|
||||
this.Logger.Log("Init of AudioFormatReader failed: " + e);
|
||||
throw new Error();
|
||||
}
|
||||
this.PacketModCounter = 0;
|
||||
this.LastCheckTime = 0;
|
||||
this.FocusChecker = 0;
|
||||
}
|
||||
Fallback.prototype.Init = function (webSocket) {
|
||||
this.MobileUnmute();
|
||||
this.WebSocket = webSocket;
|
||||
this.WebSocket?.Send(JSON.stringify({
|
||||
"type": "fallback",
|
||||
"data": this.SelectedFormatName
|
||||
}));
|
||||
this.StartFocusChecker();
|
||||
};
|
||||
Fallback.prototype.MobileUnmute = function () {
|
||||
var amplification = this.Audio.createGain();
|
||||
// Set volume to max
|
||||
amplification.gain.value = 1.0;
|
||||
// Connect gain node to context
|
||||
amplification.connect(this.Audio.destination);
|
||||
// Create one second buffer with silence
|
||||
var audioBuffer = this.Audio.createBuffer(2, this.Audio.sampleRate, this.Audio.sampleRate);
|
||||
// Create new audio source for the buffer
|
||||
var sourceNode = this.Audio.createBufferSource();
|
||||
// Make sure the node deletes itself after playback
|
||||
sourceNode.onended = function (_ev) {
|
||||
sourceNode.disconnect();
|
||||
amplification.disconnect();
|
||||
};
|
||||
// Pass audio data to source
|
||||
sourceNode.buffer = audioBuffer;
|
||||
// Connect the source to the gain node
|
||||
sourceNode.connect(amplification);
|
||||
// Play source
|
||||
sourceNode.start();
|
||||
};
|
||||
Object.defineProperty(Fallback.prototype, "Volume", {
|
||||
get: function () {
|
||||
return this.Player.Volume / this.Settings.MaxVolume;
|
||||
},
|
||||
set: function (value) {
|
||||
this.Player.Volume = value * this.Settings.MaxVolume;
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
// Callback functions from format reader
|
||||
Fallback.prototype.OnReaderError = function () {
|
||||
this.Logger.Log("Reader error: Decoding failed.");
|
||||
};
|
||||
Fallback.prototype.OnReaderDataReady = function () {
|
||||
while (this.FormatReader.SamplesAvailable()) {
|
||||
this.Player.PushBuffer(this.FormatReader.PopSamples());
|
||||
}
|
||||
};
|
||||
// Callback function from socket connection
|
||||
Fallback.prototype.OnSocketError = function (message) {
|
||||
};
|
||||
Fallback.prototype.OnSocketConnect = function () {
|
||||
};
|
||||
Fallback.prototype.OnSocketDisconnect = function () {
|
||||
};
|
||||
Fallback.prototype.OnSocketDataReady = function (data) {
|
||||
this.PacketModCounter++;
|
||||
if (this.PacketModCounter > 100) {
|
||||
if (this.ActivityCallback)
|
||||
this.ActivityCallback();
|
||||
this.PacketModCounter = 0;
|
||||
}
|
||||
this.FormatReader.PushData(new Uint8Array(data));
|
||||
};
|
||||
Fallback.prototype.StartFocusChecker = function () {
|
||||
if (!this.FocusChecker) {
|
||||
this.LastCheckTime = Date.now();
|
||||
this.FocusChecker = window.setInterval(this.CheckFocus.bind(this), 2000);
|
||||
}
|
||||
};
|
||||
Fallback.prototype.StopFocusChecker = function () {
|
||||
if (this.FocusChecker) {
|
||||
window.clearInterval(this.FocusChecker);
|
||||
this.FocusChecker = 0;
|
||||
}
|
||||
};
|
||||
Fallback.prototype.CheckFocus = function () {
|
||||
var checkTime = Date.now();
|
||||
// Check if focus was lost
|
||||
if (checkTime - this.LastCheckTime > 10000) {
|
||||
// If so, drop all samples in the buffer
|
||||
this.Logger.Log("Focus lost, purging format reader.");
|
||||
this.FormatReader.PurgeData();
|
||||
}
|
||||
this.LastCheckTime = checkTime;
|
||||
};
|
||||
Fallback.prototype.Reset = function () {
|
||||
this.StopFocusChecker();
|
||||
this.FormatReader.Reset();
|
||||
this.Player.Reset();
|
||||
this.WebSocket = null;
|
||||
};
|
||||
return Fallback;
|
||||
}());
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
Audio format reader is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var AudioFormatReader = /** @class */ (function () {
|
||||
function AudioFormatReader(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback) {
|
||||
if (!audio)
|
||||
throw new Error('AudioFormatReader: audio must be specified');
|
||||
// Check callback argument
|
||||
if (typeof errorCallback !== 'function')
|
||||
throw new Error('AudioFormatReader: errorCallback must be specified');
|
||||
if (typeof beforeDecodeCheck !== 'function')
|
||||
throw new Error('AudioFormatReader: beforeDecodeCheck must be specified');
|
||||
if (typeof dataReadyCallback !== 'function')
|
||||
throw new Error('AudioFormatReader: dataReadyCallback must be specified');
|
||||
this.Audio = audio;
|
||||
this.Logger = logger;
|
||||
this.ErrorCallback = errorCallback;
|
||||
this.BeforeDecodeCheck = beforeDecodeCheck;
|
||||
this.DataReadyCallback = dataReadyCallback;
|
||||
this.Id = 0;
|
||||
this.LastPushedId = -1;
|
||||
this.Samples = new Array();
|
||||
this.BufferStore = {};
|
||||
this.DataBuffer = new Uint8Array(0);
|
||||
}
|
||||
// Pushes frame data into the buffer
|
||||
AudioFormatReader.prototype.PushData = function (data) {
|
||||
// Append data to framedata buffer
|
||||
this.DataBuffer = this.ConcatUint8Array(this.DataBuffer, data);
|
||||
// Try to extract frames
|
||||
this.ExtractAll();
|
||||
};
|
||||
// Check if samples are available
|
||||
AudioFormatReader.prototype.SamplesAvailable = function () {
|
||||
return (this.Samples.length > 0);
|
||||
};
|
||||
// Get a single bunch of sampels from the reader
|
||||
AudioFormatReader.prototype.PopSamples = function () {
|
||||
if (this.Samples.length > 0) {
|
||||
// Get first bunch of samples, remove said bunch from the array and hand it back to callee
|
||||
return this.Samples.shift();
|
||||
}
|
||||
else
|
||||
return null;
|
||||
};
|
||||
// Deletes all encoded and decoded data from the reader (does not effect headers, etc.)
|
||||
AudioFormatReader.prototype.PurgeData = function () {
|
||||
this.Id = 0;
|
||||
this.LastPushedId = -1;
|
||||
this.Samples = new Array();
|
||||
this.BufferStore = {};
|
||||
this.DataBuffer = new Uint8Array(0);
|
||||
};
|
||||
// Used to force frame extraction externaly
|
||||
AudioFormatReader.prototype.Poke = function () {
|
||||
this.ExtractAll();
|
||||
};
|
||||
// Deletes all data from the reader (does effect headers, etc.)
|
||||
AudioFormatReader.prototype.Reset = function () {
|
||||
this.PurgeData();
|
||||
};
|
||||
// Extracts and converts the raw data
|
||||
AudioFormatReader.prototype.ExtractAll = function () {
|
||||
};
|
||||
// Checks if a decode makes sense
|
||||
AudioFormatReader.prototype.OnBeforeDecode = function (id, duration) {
|
||||
return true;
|
||||
//TODO Fix this
|
||||
/*
|
||||
if(this.BeforeDecodeCheck(duration)) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
this.OnDataReady(id, this.Audio.createBuffer(1, Math.ceil(duration * this.Audio.sampleRate), this.Audio.sampleRate));
|
||||
return false;
|
||||
}
|
||||
*/
|
||||
};
|
||||
// Stores the converted bnuches of samples in right order
|
||||
AudioFormatReader.prototype.OnDataReady = function (id, audioBuffer) {
|
||||
if (this.LastPushedId + 1 == id) {
|
||||
// Push samples into array
|
||||
this.Samples.push(audioBuffer);
|
||||
this.LastPushedId++;
|
||||
while (this.BufferStore[this.LastPushedId + 1]) {
|
||||
// Push samples we decoded earlier in correct order
|
||||
this.Samples.push(this.BufferStore[this.LastPushedId + 1]);
|
||||
delete this.BufferStore[this.LastPushedId + 1];
|
||||
this.LastPushedId++;
|
||||
}
|
||||
// Callback to tell that data is ready
|
||||
this.DataReadyCallback();
|
||||
}
|
||||
else {
|
||||
// Is out of order, will be pushed later
|
||||
this.BufferStore[id] = audioBuffer;
|
||||
}
|
||||
};
|
||||
// Used to concatenate two Uint8Array (b comes BEHIND a)
|
||||
AudioFormatReader.prototype.ConcatUint8Array = function (a, b) {
|
||||
var tmp = new Uint8Array(a.length + b.length);
|
||||
tmp.set(a, 0);
|
||||
tmp.set(b, a.length);
|
||||
return tmp;
|
||||
};
|
||||
AudioFormatReader.CanDecodeTypes = function (mimeTypes) {
|
||||
var audioTag = new Audio();
|
||||
var result = false;
|
||||
for (var i = 0; i < mimeTypes.length; i++) {
|
||||
var mimeType = mimeTypes[i];
|
||||
var answer = audioTag.canPlayType(mimeType);
|
||||
if (answer != "probably" && answer != "maybe")
|
||||
continue;
|
||||
result = true;
|
||||
break;
|
||||
}
|
||||
audioTag = null;
|
||||
return result;
|
||||
};
|
||||
AudioFormatReader.DefaultSettings = function () {
|
||||
var settings = {};
|
||||
// WAV
|
||||
settings["wav"] = {};
|
||||
// Duration of wave samples to decode together
|
||||
settings["wav"]["BatchDuration"] = 1 / 10; // 0.1 seconds
|
||||
/*
|
||||
if (isAndroid && isNativeChrome)
|
||||
settings["wav"]["BatchDuration"] = 96 / 375;
|
||||
else if (isAndroid && isFirefox)
|
||||
settings["wav"]["BatchDuration"] = 96 / 375;
|
||||
else
|
||||
settings["wav"]["BatchDuration"] = 16 / 375;
|
||||
*/
|
||||
// Duration of addtional samples to decode to account for edge effects
|
||||
settings["wav"]["ExtraEdgeDuration"] = 1 / 300; // 0.00333... seconds
|
||||
/*
|
||||
if (isAndroid && isNativeChrome)
|
||||
settings["wav"]["ExtraEdgeDuration"] = 1 / 1000;
|
||||
else if (isAndroid && isFirefox)
|
||||
settings["wav"]["ExtraEdgeDuration"] = 1 / 1000;
|
||||
else
|
||||
settings["wav"]["ExtraEdgeDuration"] = 1 / 1000;
|
||||
*/
|
||||
// MPEG
|
||||
settings["mpeg"] = {};
|
||||
// Adds a minimal ID3v2 tag before decoding frames.
|
||||
settings["mpeg"]["AddID3Tag"] = false;
|
||||
// Minimum number of frames to decode together
|
||||
// Theoretical minimum is 2.
|
||||
// Recommended value is 3 or higher.
|
||||
if (isAndroid)
|
||||
settings["mpeg"]["MinDecodeFrames"] = 3;
|
||||
else
|
||||
settings["mpeg"]["MinDecodeFrames"] = 3;
|
||||
return settings;
|
||||
};
|
||||
AudioFormatReader.Create = function (mime, audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, settings) {
|
||||
if (settings === void 0) { settings = null; }
|
||||
if (typeof mime !== "string")
|
||||
throw new Error('CreateAudioFormatReader: Invalid MIME-Type, must be string');
|
||||
if (!settings)
|
||||
settings = this.DefaultSettings();
|
||||
var fullMime = mime;
|
||||
if (mime.indexOf("audio/pcm") == 0)
|
||||
mime = "audio/pcm";
|
||||
// Load format handler according to MIME-Type
|
||||
switch (mime.replace(/\s/g, "")) {
|
||||
// MPEG Audio (mp3)
|
||||
case "audio/mpeg":
|
||||
case "audio/MPA":
|
||||
case "audio/mpa-robust":
|
||||
if (!AudioFormatReader.CanDecodeTypes(new Array("audio/mpeg", "audio/MPA", "audio/mpa-robust")))
|
||||
throw new Error('CreateAudioFormatReader: Browser can not decode specified MIME-Type (' + mime + ')');
|
||||
return new AudioFormatReader_MPEG(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, settings["mpeg"]["AddID3Tag"], settings["mpeg"]["MinDecodeFrames"]);
|
||||
break;
|
||||
// Waveform Audio File Format
|
||||
case "audio/vnd.wave":
|
||||
case "audio/wav":
|
||||
case "audio/wave":
|
||||
case "audio/x-wav":
|
||||
if (!AudioFormatReader.CanDecodeTypes(new Array("audio/wav", "audio/wave")))
|
||||
throw new Error('CreateAudioFormatReader: Browser can not decode specified MIME-Type (' + mime + ')');
|
||||
return new AudioFormatReader_WAV(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, settings["wav"]["BatchDuration"], settings["wav"]["ExtraEdgeDuration"]);
|
||||
break;
|
||||
// Unknown codec
|
||||
default:
|
||||
throw new Error('CreateAudioFormatReader: Specified MIME-Type (' + mime + ') not supported');
|
||||
break;
|
||||
}
|
||||
};
|
||||
return AudioFormatReader;
|
||||
}());
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
Live audio player is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var LiveAudioPlayer = /** @class */ (function () {
|
||||
function LiveAudioPlayer(audio, logger, maxVolume, startOffset, variableSpeed) {
|
||||
if (maxVolume === void 0) { maxVolume = 1.0; }
|
||||
if (startOffset === void 0) { startOffset = 0.33; }
|
||||
if (variableSpeed === void 0) { variableSpeed = false; }
|
||||
this.Audio = audio;
|
||||
this.Logger = logger;
|
||||
this.MaxVolume = maxVolume;
|
||||
this.StartOffset = startOffset;
|
||||
this.VariableSpeed = variableSpeed;
|
||||
this.OffsetMin = this.StartOffset - LiveAudioPlayer.OffsetVariance;
|
||||
this.OffsetMax = this.StartOffset + LiveAudioPlayer.OffsetVariance;
|
||||
// Set speed to default
|
||||
this.PlaybackSpeed = 1.0;
|
||||
// Reset variable for scheduling times
|
||||
this.NextScheduleTime = 0.0;
|
||||
// Create gain node for volume control
|
||||
this.Amplification = this.Audio.createGain();
|
||||
// Set volume to max
|
||||
this.Amplification.gain.value = 1.0;
|
||||
// Connect gain node to context
|
||||
this.Amplification.connect(this.Audio.destination);
|
||||
}
|
||||
Object.defineProperty(LiveAudioPlayer.prototype, "Volume", {
|
||||
get: function () {
|
||||
// Get volume from gain node
|
||||
return this.Amplification.gain.value;
|
||||
},
|
||||
set: function (value) {
|
||||
// Clamp value to [1e-20 ; MaxVolume]
|
||||
if (value > 1.0)
|
||||
value = this.MaxVolume;
|
||||
else if (value <= 0.0)
|
||||
value = 1e-20;
|
||||
// Cancel any scheduled ramps
|
||||
this.Amplification.gain.cancelScheduledValues(this.Audio.currentTime);
|
||||
// Change volume following a ramp (more userfriendly)
|
||||
this.Amplification.gain.exponentialRampToValueAtTime(value, this.Audio.currentTime + 0.5);
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
// Recieves an audiobuffer and schedules it for seamless playback
|
||||
LiveAudioPlayer.prototype.PushBuffer = function (buffer) {
|
||||
// Check if this is the first buffer we received
|
||||
if (this.NextScheduleTime == 0.0) {
|
||||
// Start playing [StartOffset] s from now
|
||||
this.NextScheduleTime = this.Audio.currentTime + this.StartOffset;
|
||||
}
|
||||
var duration;
|
||||
if (this.VariableSpeed)
|
||||
duration = buffer.duration / this.PlaybackSpeed; // Use regular duration
|
||||
else
|
||||
duration = buffer.duration; // Use duration adjusted for playback speed
|
||||
// Before creating a buffer and scheduling playback, check if playing this buffer makes sense at all
|
||||
// If a buffer should have been started so far in the past that it would have finished playing by now, we are better of skipping it.
|
||||
// But we still need to move the time forward to keep future timings right.
|
||||
if (this.NextScheduleTime + duration > this.Audio.currentTime) {
|
||||
var skipDurationTime = void 0;
|
||||
// If the playback start time is in the past but the playback end time is in the future, we need to partially play the buffer.
|
||||
if (this.Audio.currentTime >= this.NextScheduleTime) {
|
||||
// Calculate the time we need to skip
|
||||
skipDurationTime = this.Audio.currentTime - this.NextScheduleTime + 0.05;
|
||||
}
|
||||
else {
|
||||
// No skipping needed
|
||||
skipDurationTime = 0.0;
|
||||
}
|
||||
// Check if we'd skip the whole buffer anyway
|
||||
if (skipDurationTime < duration) {
|
||||
// Create new audio source for the buffer
|
||||
var sourceNode_1 = this.Audio.createBufferSource();
|
||||
// Make sure the node deletes itself after playback
|
||||
sourceNode_1.onended = function (_ev) {
|
||||
sourceNode_1.disconnect();
|
||||
};
|
||||
// Prevent looping (the standard says that it should be off by default)
|
||||
sourceNode_1.loop = false;
|
||||
// Pass audio data to source
|
||||
sourceNode_1.buffer = buffer;
|
||||
//Connect the source to the gain node
|
||||
sourceNode_1.connect(this.Amplification);
|
||||
if (this.VariableSpeed) {
|
||||
var scheduleOffset = this.NextScheduleTime - this.Audio.currentTime;
|
||||
// Check if we are to far or too close to target schedule time
|
||||
if (this.NextScheduleTime - this.Audio.currentTime > this.OffsetMax) {
|
||||
if (this.PlaybackSpeed < 1.0 + LiveAudioPlayer.SpeedCorrectionFactor) {
|
||||
// We are too slow, speed up playback (somewhat noticeable)
|
||||
this.Logger.Log("Buffer size too large, speeding up playback.");
|
||||
this.PlaybackSpeed = 1.0 + LiveAudioPlayer.SpeedCorrectionFactor;
|
||||
duration = buffer.duration / this.PlaybackSpeed;
|
||||
}
|
||||
}
|
||||
else if (this.NextScheduleTime - this.Audio.currentTime < this.OffsetMin) {
|
||||
if (this.PlaybackSpeed > 1.0 - LiveAudioPlayer.SpeedCorrectionFactor) {
|
||||
// We are too fast, slow down playback (somewhat noticeable)
|
||||
this.Logger.Log("Buffer size too small, slowing down playback.");
|
||||
this.PlaybackSpeed = 1.0 - LiveAudioPlayer.SpeedCorrectionFactor;
|
||||
duration = buffer.duration / this.PlaybackSpeed;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Check if we are in time
|
||||
if ((this.PlaybackSpeed > 1.0 && (this.NextScheduleTime - this.Audio.currentTime < this.StartOffset)) ||
|
||||
(this.PlaybackSpeed < 1.0 && (this.NextScheduleTime - this.Audio.currentTime > this.StartOffset))) {
|
||||
// We are within our min/max offset, set playpacks to default
|
||||
this.Logger.Log("Buffer size within limits, using normal playback speed.");
|
||||
this.PlaybackSpeed = 1.0;
|
||||
duration = buffer.duration;
|
||||
}
|
||||
}
|
||||
// Set playback speed
|
||||
sourceNode_1.playbackRate.value = this.PlaybackSpeed;
|
||||
}
|
||||
// Schedule playback
|
||||
sourceNode_1.start(this.NextScheduleTime + skipDurationTime, skipDurationTime);
|
||||
}
|
||||
else {
|
||||
this.Logger.Log("Skipped buffer because it became too old.");
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.Logger.Log("Skipped buffer because it was too old.");
|
||||
}
|
||||
// Move time forward
|
||||
this.NextScheduleTime += duration;
|
||||
};
|
||||
LiveAudioPlayer.prototype.Reset = function () {
|
||||
this.NextScheduleTime = 0.0;
|
||||
};
|
||||
LiveAudioPlayer.prototype.CheckBeforeDecode = function (playbackLength) {
|
||||
if (this.NextScheduleTime == 0)
|
||||
return true;
|
||||
return this.NextScheduleTime + playbackLength > this.Audio.currentTime;
|
||||
};
|
||||
// Crystal oscillator have a variance of about +/- 20ppm
|
||||
// So worst case would be a difference of 40ppm between two oscillators.
|
||||
LiveAudioPlayer.SpeedCorrectionFactor = 40 / 1.0e6;
|
||||
// Hystersis value for speed up/down trigger
|
||||
LiveAudioPlayer.OffsetVariance = 0.2;
|
||||
return LiveAudioPlayer;
|
||||
}());
|
||||
@@ -1,319 +0,0 @@
|
||||
/*
|
||||
MPEG audio format reader is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
window.startTimeConnectionWatchdog = performance.now();
|
||||
var __extends = (this && this.__extends) || (function () {
|
||||
var extendStatics = function (d, b) {
|
||||
extendStatics = Object.setPrototypeOf ||
|
||||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
||||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
|
||||
return extendStatics(d, b);
|
||||
};
|
||||
return function (d, b) {
|
||||
if (typeof b !== "function" && b !== null)
|
||||
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
|
||||
extendStatics(d, b);
|
||||
function __() { this.constructor = d; }
|
||||
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
||||
};
|
||||
})();
|
||||
var MPEGFrameInfo = /** @class */ (function () {
|
||||
function MPEGFrameInfo(data, sampleCount, sampleRate) {
|
||||
this.Data = data;
|
||||
this.SampleCount = sampleCount;
|
||||
this.SampleRate = sampleRate;
|
||||
}
|
||||
return MPEGFrameInfo;
|
||||
}());
|
||||
var AudioFormatReader_MPEG = /** @class */ (function (_super) {
|
||||
__extends(AudioFormatReader_MPEG, _super);
|
||||
function AudioFormatReader_MPEG(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, addId3Tag, minDecodeFrames) {
|
||||
var _this = _super.call(this, audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback) || this;
|
||||
_this._OnDecodeSuccess = _this.OnDecodeSuccess.bind(_this);
|
||||
_this._OnDecodeError = _this.OnDecodeError.bind(_this);
|
||||
_this.AddId3Tag = addId3Tag;
|
||||
_this.MinDecodeFrames = minDecodeFrames;
|
||||
_this.Frames = new Array();
|
||||
_this.FrameStartIdx = -1;
|
||||
_this.FrameEndIdx = -1;
|
||||
_this.FrameSamples = 0;
|
||||
_this.FrameSampleRate = 0;
|
||||
_this.TimeBudget = 0;
|
||||
return _this;
|
||||
}
|
||||
// Deletes all frames from the databuffer and framearray and all samples from the samplearray
|
||||
AudioFormatReader_MPEG.prototype.PurgeData = function () {
|
||||
_super.prototype.PurgeData.call(this);
|
||||
this.Frames = new Array();
|
||||
this.FrameStartIdx = -1;
|
||||
this.FrameEndIdx = -1;
|
||||
this.FrameSamples = 0;
|
||||
this.FrameSampleRate = 0;
|
||||
this.TimeBudget = 0;
|
||||
};
|
||||
// Extracts all currently possible frames
|
||||
AudioFormatReader_MPEG.prototype.ExtractAll = function () {
|
||||
// Look for frames
|
||||
this.FindFrame();
|
||||
// Repeat as long as we can extract frames
|
||||
while (this.CanExtractFrame()) {
|
||||
// Extract frame and push into array
|
||||
this.Frames.push(this.ExtractFrame());
|
||||
// Look for frames
|
||||
this.FindFrame();
|
||||
}
|
||||
// Check if we have enough frames to decode
|
||||
if (this.Frames.length >= this.MinDecodeFrames) {
|
||||
// Note:
|
||||
// =====
|
||||
// mp3 frames have an overlap of [granule size] so we can't use the first or last [granule size] samples.
|
||||
// [granule size] is equal to half of a [frame size] in samples (using the mp3's sample rate).
|
||||
// Sum up the playback time of each decoded frame and data buffer lengths
|
||||
// Note: Since mp3-Frames overlap by half of their sample-length we expect the
|
||||
// first and last frame to be only half as long. Some decoders will still output
|
||||
// the full frame length by adding zeros.
|
||||
var bufferLength = 0;
|
||||
var expectedTotalPlayTime_1 = 0;
|
||||
var firstGranulePlayTime_1 = 0;
|
||||
var lastGranulePlayTime_1 = 0;
|
||||
firstGranulePlayTime_1 = this.Frames[0].SampleCount / this.Frames[0].SampleRate / 2.0; // Only half of data is usable due to overlap
|
||||
expectedTotalPlayTime_1 += firstGranulePlayTime_1;
|
||||
bufferLength += this.Frames[0].Data.length;
|
||||
for (var i = 1; i < this.Frames.length - 1; i++) {
|
||||
expectedTotalPlayTime_1 += this.Frames[i].SampleCount / this.Frames[i].SampleRate;
|
||||
bufferLength += this.Frames[i].Data.length;
|
||||
}
|
||||
lastGranulePlayTime_1 = this.Frames[this.Frames.length - 1].SampleCount / this.Frames[this.Frames.length - 1].SampleRate / 2.0; // Only half of data is usable due to overlap
|
||||
expectedTotalPlayTime_1 += lastGranulePlayTime_1;
|
||||
bufferLength += this.Frames[this.Frames.length - 1].Data.length;
|
||||
// If needed, add some space for the ID3v2 tag
|
||||
if (this.AddId3Tag) {
|
||||
bufferLength += AudioFormatReader_MPEG.Id3v2Tag.length;
|
||||
}
|
||||
// Create a buffer long enough to hold everything
|
||||
var decodeBuffer = new Uint8Array(bufferLength);
|
||||
var offset = 0;
|
||||
// If needed, add ID3v2 tag to beginning of buffer
|
||||
if (this.AddId3Tag) {
|
||||
decodeBuffer.set(AudioFormatReader_MPEG.Id3v2Tag, offset);
|
||||
offset += AudioFormatReader_MPEG.Id3v2Tag.length;
|
||||
}
|
||||
// Add the frames to the window
|
||||
for (var i = 0; i < this.Frames.length; i++) {
|
||||
decodeBuffer.set(this.Frames[i].Data, offset);
|
||||
offset += this.Frames[i].Data.length;
|
||||
}
|
||||
// Remove the used frames from the array
|
||||
this.Frames.splice(0, this.Frames.length - 1);
|
||||
// Increment Id
|
||||
var id_1 = this.Id++;
|
||||
// Check if decoded frames might be too far back in the past
|
||||
if (!this.OnBeforeDecode(id_1, expectedTotalPlayTime_1))
|
||||
return;
|
||||
// Push window to the decoder
|
||||
this.Audio.decodeAudioData(decodeBuffer.buffer, (function (decodedData) {
|
||||
var _id = id_1;
|
||||
var _expectedTotalPlayTime = expectedTotalPlayTime_1;
|
||||
var _firstGranulePlayTime = firstGranulePlayTime_1;
|
||||
var _lastGranulePlayTime = lastGranulePlayTime_1;
|
||||
this._OnDecodeSuccess(decodedData, _id, _expectedTotalPlayTime, _firstGranulePlayTime, _lastGranulePlayTime);
|
||||
}).bind(this), this._OnDecodeError.bind(this));
|
||||
}
|
||||
};
|
||||
// Finds frame boundries within the data buffer
|
||||
AudioFormatReader_MPEG.prototype.FindFrame = function () {
|
||||
// Find frame start
|
||||
if (this.FrameStartIdx < 0) {
|
||||
var i = 0;
|
||||
// Make sure we don't exceed array bounds
|
||||
while ((i + 1) < this.DataBuffer.length) {
|
||||
// Look for MPEG sync word
|
||||
if (this.DataBuffer[i] == 0xFF && (this.DataBuffer[i + 1] & 0xE0) == 0xE0) {
|
||||
// Sync found, set frame start
|
||||
this.FrameStartIdx = i;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// Find frame end
|
||||
if (this.FrameStartIdx >= 0 && this.FrameEndIdx < 0) {
|
||||
// Check if we have enough data to process the header
|
||||
if ((this.FrameStartIdx + 2) < this.DataBuffer.length) {
|
||||
// Get header data
|
||||
// Version index
|
||||
var ver = (this.DataBuffer[this.FrameStartIdx + 1] & 0x18) >>> 3;
|
||||
// Layer index
|
||||
var lyr = (this.DataBuffer[this.FrameStartIdx + 1] & 0x06) >>> 1;
|
||||
// Padding? 0/1
|
||||
var pad = (this.DataBuffer[this.FrameStartIdx + 2] & 0x02) >>> 1;
|
||||
// Bitrate index
|
||||
var brx = (this.DataBuffer[this.FrameStartIdx + 2] & 0xF0) >>> 4;
|
||||
// SampRate index
|
||||
var srx = (this.DataBuffer[this.FrameStartIdx + 2] & 0x0C) >>> 2;
|
||||
// Resolve flags to real values
|
||||
var bitrate = AudioFormatReader_MPEG.MPEG_bitrates[ver][lyr][brx] * 1000;
|
||||
var samprate = AudioFormatReader_MPEG.MPEG_srates[ver][srx];
|
||||
var samples = AudioFormatReader_MPEG.MPEG_frame_samples[ver][lyr];
|
||||
var slot_size = AudioFormatReader_MPEG.MPEG_slot_size[lyr];
|
||||
// In-between calculations
|
||||
var bps = samples / 8.0;
|
||||
var fsize = ((bps * bitrate) / samprate) + ((pad == 1) ? slot_size : 0);
|
||||
// Truncate to integer
|
||||
var frameSize = Math.floor(fsize);
|
||||
// Store number of samples and samplerate for frame
|
||||
this.FrameSamples = samples;
|
||||
this.FrameSampleRate = samprate;
|
||||
// Set end frame boundry
|
||||
this.FrameEndIdx = this.FrameStartIdx + frameSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Checks if there is a frame ready to be extracted
|
||||
AudioFormatReader_MPEG.prototype.CanExtractFrame = function () {
|
||||
if (this.FrameStartIdx < 0 || this.FrameEndIdx < 0)
|
||||
return false;
|
||||
else if (this.FrameEndIdx <= this.DataBuffer.length)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
};
|
||||
// Extract a single frame from the buffer
|
||||
AudioFormatReader_MPEG.prototype.ExtractFrame = function () {
|
||||
// Extract frame data from buffer
|
||||
var frameArray = this.DataBuffer.buffer.slice(this.FrameStartIdx, this.FrameEndIdx);
|
||||
// Remove frame from buffer
|
||||
if ((this.FrameEndIdx + 1) < this.DataBuffer.length)
|
||||
this.DataBuffer = new Uint8Array(this.DataBuffer.buffer.slice(this.FrameEndIdx));
|
||||
else
|
||||
this.DataBuffer = new Uint8Array(0);
|
||||
// Reset Start/End indices
|
||||
this.FrameStartIdx = 0;
|
||||
this.FrameEndIdx = -1;
|
||||
return new MPEGFrameInfo(new Uint8Array(frameArray), this.FrameSamples, this.FrameSampleRate);
|
||||
};
|
||||
// Is called if the decoding of the window succeeded
|
||||
AudioFormatReader_MPEG.prototype.OnDecodeSuccess = function (decodedData, id, expectedTotalPlayTime, firstGranulePlayTime, lastGranulePlayTime) {
|
||||
window.startTimeConnectionWatchdog = performance.now();
|
||||
var extractSampleCount;
|
||||
var extractSampleOffset;
|
||||
var delta = 0.001;
|
||||
// Check if we got the expected number of samples
|
||||
if (expectedTotalPlayTime > decodedData.duration) {
|
||||
// We got less samples than expect, we suspect that they were truncated equally at start and end.
|
||||
// This can happen in case of sample rate conversions.
|
||||
extractSampleCount = decodedData.length;
|
||||
extractSampleOffset = 0;
|
||||
this.TimeBudget += (expectedTotalPlayTime - decodedData.duration);
|
||||
}
|
||||
else if (expectedTotalPlayTime < decodedData.duration) {
|
||||
// We got more samples than expect, we suspect that zeros were added equally at start and end.
|
||||
// This can happen in case of sample rate conversions or edge frame handling.
|
||||
extractSampleCount = Math.ceil(expectedTotalPlayTime * decodedData.sampleRate);
|
||||
var budgetSamples = this.TimeBudget * decodedData.sampleRate;
|
||||
if (budgetSamples > 1.0) {
|
||||
if (budgetSamples > decodedData.length - extractSampleCount) {
|
||||
budgetSamples = decodedData.length - extractSampleCount;
|
||||
}
|
||||
extractSampleCount += budgetSamples;
|
||||
this.TimeBudget -= (budgetSamples / decodedData.sampleRate);
|
||||
}
|
||||
var diff = decodedData.duration - expectedTotalPlayTime;
|
||||
if ((diff - firstGranulePlayTime - lastGranulePlayTime) >= -delta) {
|
||||
// Both first and last granule are present. Cut out middle section.
|
||||
extractSampleOffset = Math.floor((decodedData.length - extractSampleCount) / 2);
|
||||
}
|
||||
if (Math.abs(firstGranulePlayTime - lastGranulePlayTime) <= delta) {
|
||||
// First and last granule are equal. We need to make an educated guess which one is present.
|
||||
if (isIOS || isIPadOS || isMacOSX) {
|
||||
// I don't know why, but Apple does things differently.
|
||||
extractSampleOffset = Math.floor((decodedData.length - extractSampleCount) / 2);
|
||||
}
|
||||
else {
|
||||
// Assume last granule is present.
|
||||
extractSampleOffset = decodedData.length - extractSampleCount;
|
||||
}
|
||||
}
|
||||
else if (Math.abs(diff - firstGranulePlayTime) <= delta) {
|
||||
// Last granule is present.
|
||||
extractSampleOffset = decodedData.length - extractSampleCount;
|
||||
}
|
||||
else if (Math.abs(diff - lastGranulePlayTime) <= delta) {
|
||||
// First granule is present.
|
||||
extractSampleOffset = 0;
|
||||
}
|
||||
else {
|
||||
// The difference is not equal to neither the first, the last nor the sum of both granule. So we just use the middle.
|
||||
extractSampleOffset = Math.floor((decodedData.length - extractSampleCount) / 2);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// We got the expected number of samples, no adaption needed
|
||||
extractSampleCount = decodedData.length;
|
||||
extractSampleOffset = 0;
|
||||
}
|
||||
// Create a buffer that can hold the frame to extract
|
||||
var audioBuffer = this.Audio.createBuffer(decodedData.numberOfChannels, extractSampleCount, decodedData.sampleRate);
|
||||
// Fill buffer with the last part of the decoded frame leave out last granule
|
||||
for (var i = 0; i < decodedData.numberOfChannels; i++)
|
||||
audioBuffer.getChannelData(i).set(decodedData.getChannelData(i).subarray(extractSampleOffset, extractSampleOffset + extractSampleCount));
|
||||
this.OnDataReady(id, audioBuffer);
|
||||
};
|
||||
// Is called in case the decoding of the window fails
|
||||
AudioFormatReader_MPEG.prototype.OnDecodeError = function (_error) {
|
||||
this.ErrorCallback();
|
||||
};
|
||||
// MPEG versions - use [version]
|
||||
AudioFormatReader_MPEG.MPEG_versions = new Array(25, 0, 2, 1);
|
||||
// Layers - use [layer]
|
||||
AudioFormatReader_MPEG.MPEG_layers = new Array(0, 3, 2, 1);
|
||||
// Bitrates - use [version][layer][bitrate]
|
||||
AudioFormatReader_MPEG.MPEG_bitrates = new Array(new Array(// Version 2.5
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Reserved
|
||||
new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 3
|
||||
new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 2
|
||||
new Array(0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0) // Layer 1
|
||||
), new Array(// Reserved
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Invalid
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Invalid
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Invalid
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) // Invalid
|
||||
), new Array(// Version 2
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Reserved
|
||||
new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 3
|
||||
new Array(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0), // Layer 2
|
||||
new Array(0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0) // Layer 1
|
||||
), new Array(// Version 1
|
||||
new Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Reserved
|
||||
new Array(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0), // Layer 3
|
||||
new Array(0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0), // Layer 2
|
||||
new Array(0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0) // Layer 1
|
||||
));
|
||||
// Sample rates - use [version][srate]
|
||||
AudioFormatReader_MPEG.MPEG_srates = new Array(new Array(11025, 12000, 8000, 0), // MPEG 2.5
|
||||
new Array(0, 0, 0, 0), // Reserved
|
||||
new Array(22050, 24000, 16000, 0), // MPEG 2
|
||||
new Array(44100, 48000, 32000, 0) // MPEG 1
|
||||
);
|
||||
// Samples per frame - use [version][layer]
|
||||
AudioFormatReader_MPEG.MPEG_frame_samples = new Array(
|
||||
// Rsvd 3 2 1 < Layer v Version
|
||||
new Array(0, 576, 1152, 384), // 2.5
|
||||
new Array(0, 0, 0, 0), // Reserved
|
||||
new Array(0, 576, 1152, 384), // 2
|
||||
new Array(0, 1152, 1152, 384) // 1
|
||||
);
|
||||
AudioFormatReader_MPEG.Id3v2Tag = new Uint8Array(new Array(0x49, 0x44, 0x33, // File identifier: "ID3"
|
||||
0x03, 0x00, // Version 2.3
|
||||
0x00, // Flags: no unsynchronisation, no extended header, no experimental indicator
|
||||
0x00, 0x00, 0x00, 0x0D, // Size of the (tag-)frames, extended header and padding
|
||||
0x54, 0x49, 0x54, 0x32, // Title frame: "TIT2"
|
||||
0x00, 0x00, 0x00, 0x02, // Size of the frame data
|
||||
0x00, 0x00, // Frame Flags
|
||||
0x00, 0x20, 0x00 // Frame data (space character) and padding
|
||||
));
|
||||
// Slot size (MPEG unit of measurement) - use [layer]
|
||||
AudioFormatReader_MPEG.MPEG_slot_size = new Array(0, 1, 1, 4); // Rsvd, 3, 2, 1
|
||||
return AudioFormatReader_MPEG;
|
||||
}(AudioFormatReader));
|
||||
@@ -1,222 +0,0 @@
|
||||
/*
|
||||
WAV audio format reader is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var __extends = (this && this.__extends) || (function () {
|
||||
var extendStatics = function (d, b) {
|
||||
extendStatics = Object.setPrototypeOf ||
|
||||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
||||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
|
||||
return extendStatics(d, b);
|
||||
};
|
||||
return function (d, b) {
|
||||
if (typeof b !== "function" && b !== null)
|
||||
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
|
||||
extendStatics(d, b);
|
||||
function __() { this.constructor = d; }
|
||||
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
||||
};
|
||||
})();
|
||||
var AudioFormatReader_WAV = /** @class */ (function (_super) {
|
||||
__extends(AudioFormatReader_WAV, _super);
|
||||
function AudioFormatReader_WAV(audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback, batchDuration, extraEdgeDuration) {
|
||||
var _this = _super.call(this, audio, logger, errorCallback, beforeDecodeCheck, dataReadyCallback) || this;
|
||||
_this._OnDecodeSuccess = _this.OnDecodeSuccess.bind(_this);
|
||||
_this._OnDecodeError = _this.OnDecodeError.bind(_this);
|
||||
_this.BatchDuration = batchDuration;
|
||||
_this.ExtraEdgeDuration = extraEdgeDuration;
|
||||
_this.GotHeader = false;
|
||||
_this.RiffHeader = null;
|
||||
_this.WaveSampleRate = 0;
|
||||
_this.WaveBitsPerSample = 0;
|
||||
_this.WaveBytesPerSample = 0;
|
||||
_this.WaveBlockAlign = 0;
|
||||
_this.WaveChannels = 0;
|
||||
_this.BatchSamples = 0;
|
||||
_this.BatchBytes = 0;
|
||||
_this.ExtraEdgeSamples = 0;
|
||||
_this.TotalBatchSampleSize = 0;
|
||||
_this.TotalBatchByteSize = 0;
|
||||
_this.SampleBudget = 0;
|
||||
return _this;
|
||||
}
|
||||
// Deletes all samples from the databuffer and the samplearray
|
||||
AudioFormatReader_WAV.prototype.PurgeData = function () {
|
||||
_super.prototype.PurgeData.call(this);
|
||||
this.SampleBudget = 0;
|
||||
};
|
||||
// Deletes all data from the reader (deos effect headers, etc.)
|
||||
AudioFormatReader_WAV.prototype.Reset = function () {
|
||||
_super.prototype.Reset.call(this);
|
||||
this.GotHeader = false;
|
||||
this.RiffHeader = null;
|
||||
this.WaveSampleRate = 0;
|
||||
this.WaveBitsPerSample = 0;
|
||||
this.WaveBytesPerSample = 0;
|
||||
this.WaveBlockAlign = 0;
|
||||
this.WaveChannels = 0;
|
||||
this.BatchSamples = 0;
|
||||
this.BatchBytes = 0;
|
||||
this.ExtraEdgeSamples = 0;
|
||||
this.TotalBatchSampleSize = 0;
|
||||
this.TotalBatchByteSize = 0;
|
||||
this.SampleBudget = 0;
|
||||
};
|
||||
AudioFormatReader_WAV.prototype.ExtractAll = function () {
|
||||
if (!this.GotHeader)
|
||||
this.FindAndExtractHeader();
|
||||
else {
|
||||
var _loop_1 = function () {
|
||||
// Extract samples
|
||||
var tmpSamples = this_1.ExtractIntSamples();
|
||||
// Increment Id
|
||||
var id = this_1.Id++;
|
||||
if (!this_1.OnBeforeDecode(id, this_1.BatchDuration))
|
||||
return "continue";
|
||||
// Note:
|
||||
// =====
|
||||
// When audio data is resampled we get edge-effects at beginnging and end.
|
||||
// We should be able to compensate for that by keeping the last sample of the
|
||||
// previous batch and adding it to the beginning of the current one, but then
|
||||
// cutting it out AFTER the resampling (since the same effects apply to it)
|
||||
// The effects at the end can be compensated by cutting the resampled samples shorter
|
||||
// This is not trivial for non-natural ratios (e.g. 16kHz -> 44.1kHz). Because we would have
|
||||
// to cut out a non-natural number of samples at beginning and end.
|
||||
// TODO: All of the above...
|
||||
// Create a buffer long enough to hold everything
|
||||
var samplesBuffer = new Uint8Array(this_1.RiffHeader.length + tmpSamples.length);
|
||||
var offset = 0;
|
||||
// Add header
|
||||
samplesBuffer.set(this_1.RiffHeader, offset);
|
||||
offset += this_1.RiffHeader.length;
|
||||
// Add samples
|
||||
samplesBuffer.set(tmpSamples, offset);
|
||||
// Push pages to the decoder
|
||||
this_1.Audio.decodeAudioData(samplesBuffer.buffer, (function (decodedData) {
|
||||
var _id = id;
|
||||
this._OnDecodeSuccess(decodedData, _id);
|
||||
}).bind(this_1), this_1._OnDecodeError);
|
||||
};
|
||||
var this_1 = this;
|
||||
while (this.CanExtractSamples()) {
|
||||
_loop_1();
|
||||
}
|
||||
}
|
||||
};
|
||||
// Finds riff header within the data buffer and extracts it
|
||||
AudioFormatReader_WAV.prototype.FindAndExtractHeader = function () {
|
||||
var curpos = 0;
|
||||
// Make sure a whole header can fit
|
||||
if (!((curpos + 4) < this.DataBuffer.length))
|
||||
return;
|
||||
// Check chunkID, should be "RIFF"
|
||||
if (!(this.DataBuffer[curpos] == 0x52 && this.DataBuffer[curpos + 1] == 0x49 && this.DataBuffer[curpos + 2] == 0x46 && this.DataBuffer[curpos + 3] == 0x46))
|
||||
return;
|
||||
curpos += 8;
|
||||
if (!((curpos + 4) < this.DataBuffer.length))
|
||||
return;
|
||||
// Check riffType, should be "WAVE"
|
||||
if (!(this.DataBuffer[curpos] == 0x57 && this.DataBuffer[curpos + 1] == 0x41 && this.DataBuffer[curpos + 2] == 0x56 && this.DataBuffer[curpos + 3] == 0x45))
|
||||
return;
|
||||
curpos += 4;
|
||||
if (!((curpos + 4) < this.DataBuffer.length))
|
||||
return;
|
||||
// Check for format subchunk, should be "fmt "
|
||||
if (!(this.DataBuffer[curpos] == 0x66 && this.DataBuffer[curpos + 1] == 0x6d && this.DataBuffer[curpos + 2] == 0x74 && this.DataBuffer[curpos + 3] == 0x20))
|
||||
return;
|
||||
curpos += 4;
|
||||
if (!((curpos + 4) < this.DataBuffer.length))
|
||||
return;
|
||||
var subChunkSize = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8 | this.DataBuffer[curpos + 2] << 16 | this.DataBuffer[curpos + 3] << 24;
|
||||
if (!((curpos + 4 + subChunkSize) < this.DataBuffer.length))
|
||||
return;
|
||||
curpos += 6;
|
||||
this.WaveChannels = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8;
|
||||
curpos += 2;
|
||||
this.WaveSampleRate = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8 | this.DataBuffer[curpos + 2] << 16 | this.DataBuffer[curpos + 3] << 24;
|
||||
curpos += 8;
|
||||
this.WaveBlockAlign = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8;
|
||||
curpos += 2;
|
||||
this.WaveBitsPerSample = this.DataBuffer[curpos] | this.DataBuffer[curpos + 1] << 8;
|
||||
this.WaveBytesPerSample = this.WaveBitsPerSample / 8;
|
||||
curpos += subChunkSize - 14;
|
||||
while (true) {
|
||||
if ((curpos + 8) < this.DataBuffer.length) {
|
||||
subChunkSize = this.DataBuffer[curpos + 4] | this.DataBuffer[curpos + 5] << 8 | this.DataBuffer[curpos + 6] << 16 | this.DataBuffer[curpos + 7] << 24;
|
||||
// Check for data subchunk, should be "data"
|
||||
if (this.DataBuffer[curpos] == 0x64 && this.DataBuffer[curpos + 1] == 0x61 && this.DataBuffer[curpos + 2] == 0x74 && this.DataBuffer[curpos + 3] == 0x61) // Data chunk found
|
||||
break;
|
||||
else
|
||||
curpos += 8 + subChunkSize;
|
||||
}
|
||||
else
|
||||
return;
|
||||
}
|
||||
curpos += 8;
|
||||
this.RiffHeader = new Uint8Array(this.DataBuffer.buffer.slice(0, curpos));
|
||||
this.BatchSamples = Math.ceil(this.BatchDuration * this.WaveSampleRate);
|
||||
this.ExtraEdgeSamples = Math.ceil(this.ExtraEdgeDuration * this.WaveSampleRate);
|
||||
this.BatchBytes = this.BatchSamples * this.WaveBlockAlign;
|
||||
this.TotalBatchSampleSize = (this.BatchSamples + this.ExtraEdgeSamples);
|
||||
this.TotalBatchByteSize = this.TotalBatchSampleSize * this.WaveBlockAlign;
|
||||
var chunkSize = this.RiffHeader.length + this.TotalBatchByteSize - 8;
|
||||
// Fix header chunksizes
|
||||
this.RiffHeader[4] = chunkSize & 0xFF;
|
||||
this.RiffHeader[5] = (chunkSize & 0xFF00) >>> 8;
|
||||
this.RiffHeader[6] = (chunkSize & 0xFF0000) >>> 16;
|
||||
this.RiffHeader[7] = (chunkSize & 0xFF000000) >>> 24;
|
||||
this.RiffHeader[this.RiffHeader.length - 4] = (this.TotalBatchByteSize & 0xFF);
|
||||
this.RiffHeader[this.RiffHeader.length - 3] = (this.TotalBatchByteSize & 0xFF00) >>> 8;
|
||||
this.RiffHeader[this.RiffHeader.length - 2] = (this.TotalBatchByteSize & 0xFF0000) >>> 16;
|
||||
this.RiffHeader[this.RiffHeader.length - 1] = (this.TotalBatchByteSize & 0xFF000000) >>> 24;
|
||||
this.GotHeader = true;
|
||||
};
|
||||
// Checks if there is a samples ready to be extracted
|
||||
AudioFormatReader_WAV.prototype.CanExtractSamples = function () {
|
||||
if (this.DataBuffer.length >= this.TotalBatchByteSize)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
};
|
||||
// Extract a single batch of samples from the buffer
|
||||
AudioFormatReader_WAV.prototype.ExtractIntSamples = function () {
|
||||
// Extract sample data from buffer
|
||||
var intSampleArray = new Uint8Array(this.DataBuffer.buffer.slice(0, this.TotalBatchByteSize));
|
||||
// Remove samples from buffer
|
||||
this.DataBuffer = new Uint8Array(this.DataBuffer.buffer.slice(this.BatchBytes));
|
||||
return intSampleArray;
|
||||
};
|
||||
// Is called if the decoding of the samples succeeded
|
||||
AudioFormatReader_WAV.prototype.OnDecodeSuccess = function (decodedData, id) {
|
||||
// Calculate the length of the parts
|
||||
var pickSize = this.BatchDuration * decodedData.sampleRate;
|
||||
this.SampleBudget += (pickSize - Math.ceil(pickSize));
|
||||
pickSize = Math.ceil(pickSize);
|
||||
var pickOffset = (decodedData.length - pickSize) / 2.0;
|
||||
if (pickOffset < 0)
|
||||
pickOffset = 0; // This should never happen!
|
||||
else
|
||||
pickOffset = Math.floor(pickOffset);
|
||||
if (this.SampleBudget < -1.0) {
|
||||
var correction = -1.0 * Math.floor(Math.abs(this.SampleBudget));
|
||||
this.SampleBudget -= correction;
|
||||
pickSize += correction;
|
||||
}
|
||||
else if (this.SampleBudget > 1.0) {
|
||||
var correction = Math.floor(this.SampleBudget);
|
||||
this.SampleBudget -= correction;
|
||||
pickSize += correction;
|
||||
}
|
||||
// Create a buffer that can hold a single part
|
||||
var audioBuffer = this.Audio.createBuffer(decodedData.numberOfChannels, pickSize, decodedData.sampleRate);
|
||||
// Fill buffer with the last part of the decoded frame
|
||||
for (var i = 0; i < decodedData.numberOfChannels; i++)
|
||||
audioBuffer.getChannelData(i).set(decodedData.getChannelData(i).slice(pickOffset, -pickOffset));
|
||||
this.OnDataReady(id, audioBuffer);
|
||||
};
|
||||
// Is called in case the decoding of the window fails
|
||||
AudioFormatReader_WAV.prototype.OnDecodeError = function (_error) {
|
||||
this.ErrorCallback();
|
||||
};
|
||||
return AudioFormatReader_WAV;
|
||||
}(AudioFormatReader));
|
||||
@@ -1,70 +0,0 @@
|
||||
let Stream;
|
||||
let shouldReconnect = true;
|
||||
let newVolumeGlobal = 1;
|
||||
|
||||
function Init(_ev) {
|
||||
$(".playbutton").off('click').on('click', OnPlayButtonClick); // Ensure only one event handler is attached
|
||||
$("#volumeSlider").off("input").on("input", updateVolume); // Ensure only one event handler is attached
|
||||
}
|
||||
|
||||
function createStream() {
|
||||
try {
|
||||
const settings = new _3LAS_Settings();
|
||||
Stream = new _3LAS(null, settings);
|
||||
Stream.Volume = $('#volumeSlider').val();
|
||||
Stream.ConnectivityCallback = OnConnectivityCallback;
|
||||
} catch (error) {
|
||||
console.error("Initialization Error: ", error);
|
||||
}
|
||||
}
|
||||
|
||||
function destroyStream() {
|
||||
if (Stream) {
|
||||
Stream.Stop();
|
||||
Stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
function OnConnectivityCallback(isConnected) {
|
||||
console.log("Connectivity changed:", isConnected);
|
||||
if (Stream) Stream.Volume = $('#volumeSlider').val();
|
||||
else console.warn("Stream is not initialized.");
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
if (Stream) {
|
||||
const newVolume = $(this).val();
|
||||
newVolumeGlobal = newVolume;
|
||||
console.log("Volume updated to:", newVolume);
|
||||
Stream.Volume = newVolume;
|
||||
} else console.warn("Stream is not initialized.");
|
||||
}
|
||||
|
||||
$(document).ready(Init);
|
||||
|
||||
@@ -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;
|
||||
}());
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
Logging is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var Logging = /** @class */ (function () {
|
||||
function Logging(parentElement, childElementType) {
|
||||
this.ParentElement = parentElement;
|
||||
this.ChildElementType = childElementType;
|
||||
}
|
||||
Logging.prototype.Log = function (message) {
|
||||
var dateTime = new Date();
|
||||
var lineText = "[" + (dateTime.getHours() > 9 ? dateTime.getHours() : "0" + dateTime.getHours()) + ":" +
|
||||
(dateTime.getMinutes() > 9 ? dateTime.getMinutes() : "0" + dateTime.getMinutes()) + ":" +
|
||||
(dateTime.getSeconds() > 9 ? dateTime.getSeconds() : "0" + dateTime.getSeconds()) +
|
||||
"] " + message;
|
||||
if (this.ParentElement && this.ChildElementType) {
|
||||
var line = document.createElement(this.ChildElementType);
|
||||
line.innerText = lineText;
|
||||
this.ParentElement.appendChild(line);
|
||||
}
|
||||
else {
|
||||
//console.log(lineText);
|
||||
}
|
||||
};
|
||||
return Logging;
|
||||
}());
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
WebSocket client is part of 3LAS (Low Latency Live Audio Streaming)
|
||||
https://github.com/JoJoBond/3LAS
|
||||
*/
|
||||
var WebSocketClient = /** @class */ (function () {
|
||||
function WebSocketClient(logger, uri, errorCallback, connectCallback, dataReadyCallback, disconnectCallback) {
|
||||
this.Logger = logger;
|
||||
this.Uri = uri;
|
||||
// Check callback argument
|
||||
if (typeof errorCallback !== 'function')
|
||||
throw new Error('WebSocketClient: ErrorCallback must be specified');
|
||||
if (typeof connectCallback !== 'function')
|
||||
throw new Error('WebSocketClient: ConnectCallback must be specified');
|
||||
if (typeof dataReadyCallback !== 'function')
|
||||
throw new Error('WebSocketClient: DataReadyCallback must be specified');
|
||||
if (typeof disconnectCallback !== 'function')
|
||||
throw new Error('WebSocketClient: DisconnectCallback must be specified');
|
||||
this.ErrorCallback = errorCallback;
|
||||
this.ConnectCallback = connectCallback;
|
||||
this.DataReadyCallback = dataReadyCallback;
|
||||
this.DisconnectCallback = disconnectCallback;
|
||||
// Client is not yet connected
|
||||
this.IsConnected = false;
|
||||
// Create socket, connect to URI
|
||||
if (typeof WebSocket !== "undefined")
|
||||
this.Socket = new WebSocket(this.Uri);
|
||||
else if (typeof webkitWebSocket !== "undefined")
|
||||
this.Socket = new webkitWebSocket(this.Uri);
|
||||
else if (typeof mozWebSocket !== "undefined")
|
||||
this.Socket = new mozWebSocket(this.Uri);
|
||||
else
|
||||
throw new Error('WebSocketClient: Browser does not support "WebSocket".');
|
||||
this.Socket.binaryType = 'arraybuffer';
|
||||
this.Socket.addEventListener("open", this.OnOpen.bind(this));
|
||||
this.Socket.addEventListener("error", this.OnError.bind(this));
|
||||
this.Socket.addEventListener("close", this.OnClose.bind(this));
|
||||
this.Socket.addEventListener("message", this.OnMessage.bind(this));
|
||||
}
|
||||
Object.defineProperty(WebSocketClient.prototype, "Connected", {
|
||||
get: function () {
|
||||
return this.IsConnected;
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
WebSocketClient.prototype.Send = function (message) {
|
||||
if (!this.IsConnected)
|
||||
return;
|
||||
this.Socket.send(message);
|
||||
};
|
||||
// Handle errors
|
||||
WebSocketClient.prototype.OnError = function (_ev) {
|
||||
if (this.IsConnected == true)
|
||||
this.ErrorCallback("Socket fault.");
|
||||
else
|
||||
this.ErrorCallback("Could not connect to server.");
|
||||
};
|
||||
// Change connetion status once connected
|
||||
WebSocketClient.prototype.OnOpen = function (_ev) {
|
||||
if (this.Socket.readyState == 1) {
|
||||
this.IsConnected = true;
|
||||
this.ConnectCallback();
|
||||
}
|
||||
};
|
||||
// Change connetion status on disconnect
|
||||
WebSocketClient.prototype.OnClose = function (_ev) {
|
||||
if (this.IsConnected == true && (this.Socket.readyState == 2 || this.Socket.readyState == 3)) {
|
||||
this.IsConnected = false;
|
||||
this.DisconnectCallback();
|
||||
}
|
||||
};
|
||||
WebSocketClient.prototype.Close = function () {
|
||||
if (this.Socket) {
|
||||
this.Socket.close();
|
||||
}
|
||||
};
|
||||
// Handle incomping data
|
||||
WebSocketClient.prototype.OnMessage = function (ev) {
|
||||
// Trigger callback
|
||||
this.DataReadyCallback(ev.data);
|
||||
};
|
||||
return WebSocketClient;
|
||||
}());
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
+4
-6
@@ -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);
|
||||
@@ -79,9 +79,7 @@ $(document).ready(function() {
|
||||
});
|
||||
|
||||
chatNicknameInput.keypress(function(event) {
|
||||
if (event.which === 13) {
|
||||
chatNicknameSave.trigger('click');
|
||||
}
|
||||
if (event.which === 13) chatNicknameSave.trigger('click');
|
||||
});
|
||||
|
||||
chatSendInput.keypress(function(event) {
|
||||
@@ -89,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,26 +281,33 @@ 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';
|
||||
}, 500);
|
||||
window.location.href = `/403?reason=${encodeURIComponent("You have been kicked")}`;
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -427,11 +316,22 @@ function handleWebSocketMessage(event) {
|
||||
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;
|
||||
@@ -615,7 +513,7 @@ let dataTimeoutPrevious = null;
|
||||
let connectionLost = false;
|
||||
let requiresAudioStreamRestart = false;
|
||||
|
||||
const TIMEOUT_DURATION = 5000; // 5 seconds timeout for lost connection
|
||||
const TIMEOUT_DURATION = 3500; // 5 seconds timeout for lost connection
|
||||
|
||||
const resetDataTimeout = () => {
|
||||
clearTimeout(dataTimeout);
|
||||
@@ -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();
|
||||
}
|
||||
-751
@@ -1,751 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Setup - 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"></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 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>
|
||||
|
||||
<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 %></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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<p class="text-gray">Leave the IP at 0.0.0.0 unless you explicitly know you have to change it.<br>Don't enter your public IP here.</p>
|
||||
<%- 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>
|
||||
|
||||
<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>
|
||||
</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 => { %>
|
||||
<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>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center">The banlist is empty.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</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'}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/ver.js"></script>
|
||||
<script src="js/toast.js"></script>
|
||||
<script src="js/settings.js"></script>
|
||||
<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>
|
||||
-228
@@ -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>
|
||||
Reference in New Issue
Block a user