commit 07/02/2026
This commit is contained in:
@@ -58,10 +58,6 @@
|
|||||||
margin-right: .5rem!important;
|
margin-right: .5rem!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mb-2 {
|
|
||||||
margin-bottom: .5rem!important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-3 {
|
.mb-3 {
|
||||||
margin-bottom: 1rem!important;
|
margin-bottom: 1rem!important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,22 +25,38 @@
|
|||||||
* @property {JQuery<HTMLElement> | null} ip - The jQuery result should be <h6> element.
|
* @property {JQuery<HTMLElement> | null} ip - The jQuery result should be <h6> element.
|
||||||
* @property {JQuery<HTMLElement> | null} buffer - The jQuery result should be <h6> element.
|
* @property {JQuery<HTMLElement> | null} buffer - The jQuery result should be <h6> element.
|
||||||
* @property {JQuery<HTMLElement> | null} status - The jQuery result should be <p> element.
|
* @property {JQuery<HTMLElement> | null} status - The jQuery result should be <p> element.
|
||||||
|
* @property {JQuery<HTMLElement> | null} filename - The jQuery result should be <h6> element.
|
||||||
|
* @property {JQuery<HTMLElement> | null} duration - The jQuery result should be <h6> element.
|
||||||
|
* @property {JQuery<HTMLElement> | null} elapsed - The jQuery result should be <h6> element.
|
||||||
|
* @property {JQuery<HTMLElement> | null} broadcastzones - The jQuery result should be <h6> element.
|
||||||
* @property {JQuery<HTMLElement> | null} vu - The jQuery result should be <progress-bar> element.
|
* @property {JQuery<HTMLElement> | null} vu - The jQuery result should be <progress-bar> element.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function getCardByIndex(index) {
|
function getCardByIndex(index) {
|
||||||
|
let cardname = "ch" + index.toString().padStart(2, '0');
|
||||||
let obj = {
|
let obj = {
|
||||||
// title is <h4> element wiht id `streamertitle${index}`, with index as two digit number, e.g. 01, 02, 03
|
// title is <h4> element wiht id `streamertitle${index}`, with index as two digit number, e.g. 01, 02, 03
|
||||||
title: $(`#streamertitle${index.toString().padStart(2, '0')}`),
|
//title: $(`#streamertitle${index.toString().padStart(2, '0')}`),
|
||||||
// ip is <h6> element with id `streamerip${index}`, with index as two digit number, e.g. 01, 02, 03
|
// ip is <h6> element with id `streamerip${index}`, with index as two digit number, e.g. 01, 02, 03
|
||||||
ip: $(`#streamerip${index.toString().padStart(2, '0')}`),
|
//ip: $(`#streamerip${index.toString().padStart(2, '0')}`),
|
||||||
// buffer is <h6> element with id `streamerbuffer${index}`, with index as two digit number, e.g. 01, 02, 03
|
// buffer is <h6> element with id `streamerbuffer${index}`, with index as two digit number, e.g. 01, 02, 03
|
||||||
buffer: $(`#streamerbuffer${index.toString().padStart(2, '0')}`),
|
//buffer: $(`#streamerbuffer${index.toString().padStart(2, '0')}`),
|
||||||
// status is <p> element with id `streamerstatus${index}`, with index as two digit number, e.g. 01, 02, 03
|
// status is <p> element with id `streamerstatus${index}`, with index as two digit number, e.g. 01, 02, 03
|
||||||
status: $(`#streamerstatus${index.toString().padStart(2, '0')}`),
|
//status: $(`#streamerstatus${index.toString().padStart(2, '0')}`),
|
||||||
// vu is <progress-bar> element with id `streamervu${index}`, with index as two digit number, e.g. 01, 02, 03
|
// vu is <progress-bar> element with id `streamervu${index}`, with index as two digit number, e.g. 01, 02, 03
|
||||||
vu: $(`#streamervu${index.toString().padStart(2, '0')} .progress-bar`),
|
//vu: $(`#streamervu${index.toString().padStart(2, '0')} .progress-bar`),
|
||||||
|
card: $(`#${cardname}`),
|
||||||
|
title: $(`#${cardname} .streamertitle`),
|
||||||
|
ip: $(`#${cardname} .streamerip`),
|
||||||
|
buffer: $(`#${cardname} .streamerbuffer`),
|
||||||
|
status: $(`#${cardname} .streamerstatus`),
|
||||||
|
vu: $(`#${cardname} .streamervu .progress-bar`),
|
||||||
|
filename: $(`#${cardname} .streamerfile`),
|
||||||
|
duration: $(`#${cardname} .streamerduration`),
|
||||||
|
elapsed: $(`#${cardname} .streamerelapsed`),
|
||||||
|
broadcastzones: $(`#${cardname} .streamerzones`),
|
||||||
}
|
}
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,29 +80,71 @@ function UpdateStreamerCard(values) {
|
|||||||
values = [];
|
values = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let visiblilitychanged = false;
|
||||||
|
|
||||||
for (let i = 1; i <= 64; i++) {
|
for (let i = 1; i <= 64; i++) {
|
||||||
let vv = values.find(v => v.index === i);
|
const vv = values.find(v => v.index === i);
|
||||||
let card = getCardByIndex(i);
|
const cardname = "ch" + i.toString().padStart(2, '0');
|
||||||
|
const $card = $(`#${cardname}`);
|
||||||
|
|
||||||
|
|
||||||
if (vv) {
|
if (vv) {
|
||||||
// there is value for this index
|
// ada data untuk index i
|
||||||
if (card.title) card.title.text(vv.channel ? vv.channel : `Channel ${i.toString().padStart(2, '0')}`);
|
if ($card.length > 0) {
|
||||||
if (card.ip) card.ip.text(`IP Address: ${vv.ipaddress ? vv.ipaddress : 'N/A'}`);
|
// ada card untuk index i, show card
|
||||||
if (card.buffer) card.buffer.text(`Buffer: ${vv.bufferRemain !== undefined && vv.bufferRemain !== null ? vv.bufferRemain.toString() : 'N/A'}`);
|
if ($card.hasClass('d-none')) {
|
||||||
if (card.status) card.status.text(`Status: ${vv.isPlaying ? 'Playing' : 'Idle'}`);
|
visiblilitychanged = true;
|
||||||
if (card.vu) {
|
$card.removeClass('d-none');
|
||||||
setProgress(i, card.vu, vv.vu, 100);
|
$card.closest('.streamercol').removeClass('d-none'); // show the column as well
|
||||||
|
}
|
||||||
|
const $title = $(`#${cardname} .streamertitle`);
|
||||||
|
const $ip = $(`#${cardname} .streamerip`);
|
||||||
|
const $buffer = $(`#${cardname} .streamerbuffer`);
|
||||||
|
const $status = $(`#${cardname} .streamerstatus`);
|
||||||
|
const $vu = $(`#${cardname} .streamervu .progress-bar`);
|
||||||
|
const $filename = $(`#${cardname} .streamerfile`);
|
||||||
|
const $duration = $(`#${cardname} .streamerduration`);
|
||||||
|
const $elapsed = $(`#${cardname} .streamerelapsed`);
|
||||||
|
const $broadcastzones = $(`#${cardname} .streamerzones`);
|
||||||
|
|
||||||
|
//console.log(`Updating card for index ${i}`, vv);
|
||||||
|
$title.text(vv.channel ? vv.channel : `Channel ${i.toString().padStart(2, '0')}`);
|
||||||
|
$ip.text(vv.ipaddress ? vv.ipaddress : 'N/A');
|
||||||
|
$buffer.text(vv.bufferRemain !== undefined && vv.bufferRemain !== null ? vv.bufferRemain.toString() : 'N/A');
|
||||||
|
$status.text(vv.isPlaying ? 'Playing' : 'Idle');
|
||||||
|
setProgress(i, $vu, vv.vu ? vv.vu : 0, 100);
|
||||||
|
$filename.text(vv.filename ? vv.filename : 'N/A');
|
||||||
|
$duration.text(vv.duration ? vv.duration : 'N/A');
|
||||||
|
$elapsed.text(vv.elapsed ? vv.elapsed : 'N/A');
|
||||||
|
$broadcastzones.text(vv.broadcastzones ? vv.broadcastzones : 'N/A');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// no value for this index, disable the card
|
// tidak ada data untuk index i, hide card
|
||||||
if (card.title) card.title.text(`Channel ${i.toString().padStart(2, '0')}`);
|
if ($card.length > 0) {
|
||||||
if (card.ip) card.ip.text(`IP Address: N/A`);
|
// ada card untuk index i, hide card
|
||||||
if (card.buffer) card.buffer.text(`Buffer: N/A`);
|
if (!$card.hasClass('d-none')) {
|
||||||
if (card.status) card.status.text(`Status: Disconnected`);
|
visiblilitychanged = true;
|
||||||
if (card.vu) {
|
$card.addClass('d-none');
|
||||||
setProgress(i, card.vu, 0, 100);
|
$card.closest('.streamercol').addClass('d-none'); // hide the column as well
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide rows that have all cards hidden
|
||||||
|
if (visiblilitychanged) {
|
||||||
|
$('.streamerrow').each(function () {
|
||||||
|
const $row = $(this);
|
||||||
|
const visiblecards = $row.find('.streamercard:not(.d-none)');
|
||||||
|
if (visiblecards.length === 0) {
|
||||||
|
$row.addClass('d-none');
|
||||||
|
} else {
|
||||||
|
$row.removeClass('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,18 +489,18 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.addEventListener('ws_connected', () =>{
|
window.addEventListener('ws_connected', () => {
|
||||||
console.log("overview.js ws_connected event triggered");
|
console.log("overview.js ws_connected event triggered");
|
||||||
runIntervalJob();
|
runIntervalJob();
|
||||||
});
|
});
|
||||||
window.addEventListener('ws_disconnected', ()=>{
|
window.addEventListener('ws_disconnected', () => {
|
||||||
console.log("overview.js ws_disconnected event triggered");
|
console.log("overview.js ws_disconnected event triggered");
|
||||||
if (intervaljob1) clearInterval(intervaljob1);
|
if (intervaljob1) clearInterval(intervaljob1);
|
||||||
if (intervaljob2) clearInterval(intervaljob2);
|
if (intervaljob2) clearInterval(intervaljob2);
|
||||||
intervaljob1 = null;
|
intervaljob1 = null;
|
||||||
intervaljob2 = null;
|
intervaljob2 = null;
|
||||||
});
|
});
|
||||||
window.addEventListener('ws_message', ()=>{
|
window.addEventListener('ws_message', () => {
|
||||||
let rep = event.detail;
|
let rep = event.detail;
|
||||||
let cmd = rep.reply;
|
let cmd = rep.reply;
|
||||||
let data = rep.data;
|
let data = rep.data;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,23 +20,75 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="card" id="streamercard">
|
<div class="card streamercard" id="streamercard">
|
||||||
<div class="card-body card-channel">
|
<div class="card-body card-channel">
|
||||||
<h4 class="card-title" id="streamertitle">Channel 01</h4>
|
<h4 class="card-title streamertitle" id="streamertitle">Channel 01</h4>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-sm-12 col-md-12 col-lg-6 col-xl-6 col-xxl-6">
|
<div class="col-3">
|
||||||
<h6 class="text-muted mb-2" id="streamerip">IP : 192.168.10.10</h6>
|
<p class="w-100 h-100 align-content-center">IP</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-12 col-md-12 col-lg-6 col-xl-6 col-xxl-6">
|
<div class="col">
|
||||||
<h6 class="text-muted mb-2" id="streamerbuffer">Free : 64KB</h6>
|
<p class="w-100 h-100 align-content-center streamerip" id="streamerip">N/A</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-text" id="streamerstatus">Status : Idle</p>
|
<div class="row">
|
||||||
<div class="progress" id="streamervu">
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">Buffer</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="w-100 h-100 align-content-center streamerbuffer" id="streamerbuffer">N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">Status</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="w-100 h-100 align-content-center streamerstatus" id="streamerstatus">N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">File</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="w-100 h-100 align-content-center streamerfile" id="streamerfile">N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">Zones</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="w-100 h-100 align-content-center streamerzones" id="streamerzones">N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">Duration</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="w-100 h-100 align-content-center streamerduration" id="streamerduration">N/A</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">Elapsed</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<p class="w-100 h-100 align-content-center streamerelapsed" id="streamerelapsed">N/A</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<p class="w-100 h-100 align-content-center">VU</p>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="progress w-100 h-50 streamervu" id="streamervu">
|
||||||
<div class="progress-bar" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" style="width: 50%;">50%</div>
|
<div class="progress-bar" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" style="width: 50%;">50%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
|
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
|
||||||
<script src="assets/js/bs-init.js"></script>
|
<script src="assets/js/bs-init.js"></script>
|
||||||
<script src="assets/js/datatables.js"></script>
|
<script src="assets/js/datatables.js"></script>
|
||||||
|
|||||||
24
src/Main.kt
24
src/Main.kt
@@ -177,13 +177,29 @@ fun main(args: Array<String>) {
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
// prioritas 1 , habisin queue paging
|
// prioritas 1 , habisin queue paging
|
||||||
subcode01.Read_Queue_Paging()
|
if (subcode01.Read_Queue_Paging()){
|
||||||
|
// processing paging, skip selanjutnya
|
||||||
|
delay(2000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// prioritas 2, habisin queue shalat
|
// prioritas 2, habisin queue shalat
|
||||||
subcode01.Read_Queue_Shalat()
|
if (subcode01.Read_Queue_Shalat()){
|
||||||
|
// processing shalat, skip selanjutnya
|
||||||
|
delay(2000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// prioritas 3, habisin queue timer
|
// prioritas 3, habisin queue timer
|
||||||
subcode01.Read_Queue_Timer()
|
if (subcode01.Read_Queue_Timer()){
|
||||||
|
// processing timer, skip selanjutnya
|
||||||
|
delay(2000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// prioritas 4, habisin queue soundbank
|
// prioritas 4, habisin queue soundbank
|
||||||
subcode01.Read_Queue_Soundbank()
|
if (subcode01.Read_Queue_Soundbank()){
|
||||||
|
// processing soundbank, skip selanjutnya
|
||||||
|
delay(2000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,4 +18,39 @@ class AudioFileInfo {
|
|||||||
fun isValid() : Boolean {
|
fun isValid() : Boolean {
|
||||||
return fileName.isNotBlank() && fileSize > 0 && duration > 0.0 && bytes.isNotEmpty()
|
return fileName.isNotBlank() && fileSize > 0 && duration > 0.0 && bytes.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the duration to a human-readable string format.
|
||||||
|
* @return Duration as a string in HH:MM:SS or MM:SS format or SS if less than a minute.
|
||||||
|
*/
|
||||||
|
fun DurationToString() : String {
|
||||||
|
val totalSeconds = duration.toInt()
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
return when {
|
||||||
|
hours > 0 -> String.format("%02d h:%02d m:%02d s", hours, minutes, seconds)
|
||||||
|
minutes > 0 -> String.format("%02d m:%02d s", minutes, seconds)
|
||||||
|
else -> String.format("00:%02d s", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the file size to a human-readable string format.
|
||||||
|
* @return File size as a string in Bytes, KB, MB, or GB.
|
||||||
|
*/
|
||||||
|
fun FileSizeToString() : String {
|
||||||
|
val kb = 1024
|
||||||
|
val mb = kb * 1024
|
||||||
|
val gb = mb * 1024
|
||||||
|
|
||||||
|
return when {
|
||||||
|
fileSize >= gb -> String.format("%.2f GB", fileSize.toDouble() / gb)
|
||||||
|
fileSize >= mb -> String.format("%.2f MB", fileSize.toDouble() / mb)
|
||||||
|
fileSize >= kb -> String.format("%.2f KB", fileSize.toDouble() / kb)
|
||||||
|
else -> "$fileSize Bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package barix
|
package barix
|
||||||
|
|
||||||
|
import audio.AudioFileInfo
|
||||||
import audio.Mp3Encoder
|
import audio.Mp3Encoder
|
||||||
import codes.Somecodes
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -16,7 +15,6 @@ import java.nio.ByteBuffer
|
|||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
import kotlin.experimental.or
|
import kotlin.experimental.or
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
class BarixConnection(val index: UInt, var channel: String, val ipaddress: String, val port: Int = 5002) : AutoCloseable {
|
class BarixConnection(val index: UInt, var channel: String, val ipaddress: String, val port: Int = 5002) : AutoCloseable {
|
||||||
private var _bR: Int = 0
|
private var _bR: Int = 0
|
||||||
private var _sd: Int = 0
|
private var _sd: Int = 0
|
||||||
@@ -29,6 +27,89 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin
|
|||||||
private val mp3Consumer = mutableMapOf<String, Consumer<ByteArray>>()
|
private val mp3Consumer = mutableMapOf<String, Consumer<ByteArray>>()
|
||||||
private val udp = DatagramSocket()
|
private val udp = DatagramSocket()
|
||||||
private var _barixmode: Boolean = false
|
private var _barixmode: Boolean = false
|
||||||
|
private var _usedbybroadcastzone = mutableSetOf<String>()
|
||||||
|
private var afi : AudioFileInfo? = null
|
||||||
|
private var starttick: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set audio file information used for playback
|
||||||
|
* @param value The AudioFileInfo object containing audio file details
|
||||||
|
*/
|
||||||
|
fun SetAudioFileInfo(value: AudioFileInfo?){
|
||||||
|
afi = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio file information used for playback
|
||||||
|
* @return The AudioFileInfo object containing audio file details, or null if not set
|
||||||
|
*/
|
||||||
|
fun GetAudioFileInfo() : AudioFileInfo?{
|
||||||
|
return afi
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the start tick for playback timing
|
||||||
|
* @param tick The start tick in milliseconds
|
||||||
|
*/
|
||||||
|
fun SetStartTick(tick: Long?){
|
||||||
|
starttick = tick
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the elapsed time since the start tick
|
||||||
|
* @return Elapsed time as a formatted string or "N/A" if start tick is not set
|
||||||
|
*/
|
||||||
|
fun GetElapsed() : String {
|
||||||
|
if (starttick != null){
|
||||||
|
val elapsedMs = System.currentTimeMillis() - starttick!!
|
||||||
|
val totalSeconds = elapsedMs / 1000
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
return when {
|
||||||
|
hours > 0 -> String.format("%02d h:%02d m:%02d s", hours, minutes, seconds)
|
||||||
|
minutes > 0 -> String.format("%02d m:%02d s", minutes, seconds)
|
||||||
|
else -> String.format("00:%02d s", seconds)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a broadcast zone that uses this Barix device
|
||||||
|
* @param zoneName The name of the broadcast zone
|
||||||
|
*/
|
||||||
|
fun AddUsedByBroadcastZone(zoneName: String){
|
||||||
|
_usedbybroadcastzone.add(zoneName)
|
||||||
|
println("Added used by broadcast zone: $zoneName to Barix device $ipaddress")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple broadcast zones that use this Barix device
|
||||||
|
* @param zoneNames The list of broadcast zone names
|
||||||
|
*/
|
||||||
|
fun AddUsedByBroadcastZone(zoneNames: List<String>){
|
||||||
|
for (zoneName in zoneNames){
|
||||||
|
AddUsedByBroadcastZone(zoneName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all broadcast zones that use this Barix device
|
||||||
|
*/
|
||||||
|
fun ClearUsedByBroadcastZones(){
|
||||||
|
_usedbybroadcastzone.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a comma-separated string of broadcast zones that use this Barix device
|
||||||
|
* @return Comma-separated string of broadcast zones
|
||||||
|
*/
|
||||||
|
fun GetUsedByBroadcastZones() : String{
|
||||||
|
return _usedbybroadcastzone.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Barix mode flag
|
* Barix mode flag
|
||||||
@@ -53,6 +134,7 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin
|
|||||||
mp3Consumer.remove(key)
|
mp3Consumer.remove(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
fun Exists_Mp3_Consumer(key: String) : Boolean{
|
fun Exists_Mp3_Consumer(key: String) : Boolean{
|
||||||
return mp3Consumer.containsKey(key)
|
return mp3Consumer.containsKey(key)
|
||||||
}
|
}
|
||||||
@@ -129,14 +211,59 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin
|
|||||||
return statusData == 1
|
return statusData == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if buffer has enough space (more than 10,000 bytes)
|
||||||
|
* @return true if buffer has enough space
|
||||||
|
*/
|
||||||
|
fun bufferEnough(): Boolean{
|
||||||
|
return isOnline() && (bufferRemain > 10000)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send data to Barix device via UDP
|
* Send data to Barix device via UDP
|
||||||
* @param data The data to send
|
* @param data The data to send
|
||||||
|
* @param cbOK Callback function if sending is successful
|
||||||
|
* @param cbFail Callback function if sending fails
|
||||||
|
* @param cbPlaying Callback function to indicate if device is playing
|
||||||
*/
|
*/
|
||||||
fun SendData(data: ByteArray, cbOK: Consumer<String>, cbFail: Consumer<String>) {
|
fun SendData(data: ByteArray, cbOK: Consumer<String>, cbFail: Consumer<String>, cbPlaying: Consumer<Boolean>) {
|
||||||
if (data.isNotEmpty()) {
|
if (data.isNotEmpty()) {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val bb = ByteBuffer.wrap(data)
|
val bb = ByteBuffer.wrap(data)
|
||||||
|
while(!bufferEnough()){
|
||||||
|
delay(20)
|
||||||
|
Logger.info{"Waiting for StreamerOutput $ipaddress buffer to have enough space: $bufferRemain bytes available, need more than 10000 bytes"}
|
||||||
|
}
|
||||||
|
val bufmax = bufferRemain
|
||||||
|
val bufkosong = 0.2 * bufmax
|
||||||
|
val bufpenuh = 0.8 * bufmax
|
||||||
|
Logger.info{"Starting to send data to StreamerOutput $ipaddress on channel $channel, total data size: ${data.size} bytes, bufferRemain: $bufferRemain bytes, bufkosong: $bufkosong bytes, bufpenuh: $bufpenuh bytes"}
|
||||||
|
// Ide 07/02/2026, kasih buffer dummy 10x1000 byte pertama biar barix kebacanya stabil
|
||||||
|
for (i in 1..10){
|
||||||
|
val chunk = ByteArray(1000){0}
|
||||||
|
udp.send(DatagramPacket(chunk, chunk.size, inet))
|
||||||
|
delay(5)
|
||||||
|
println("Sending dummy buffer $i to $ipaddress")
|
||||||
|
}
|
||||||
|
|
||||||
|
// delay interval awal = 5 ms untuk streamer output dan 10 ms untuk barix
|
||||||
|
// slow down interval = 5 ms untuk streamer output dan 10 ms untuk barix
|
||||||
|
// speed up interval = 2 ms untuk streamer output dan 5 ms untuk barix
|
||||||
|
val slowdowninterval = if (BarixMode) 8L else 5L
|
||||||
|
val speedupinterval = if (BarixMode) 5L else 2L
|
||||||
|
var delayinterval = if (BarixMode) 8L else 5L
|
||||||
|
|
||||||
|
// buat hitung elapsed
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
SetStartTick(System.currentTimeMillis())
|
||||||
|
cbPlaying.accept(true)
|
||||||
|
do{
|
||||||
|
delay(1000)
|
||||||
|
} while (isPlaying())
|
||||||
|
cbPlaying.accept(false)
|
||||||
|
SetStartTick(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
while(bb.hasRemaining()){
|
while(bb.hasRemaining()){
|
||||||
try {
|
try {
|
||||||
@@ -144,51 +271,40 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin
|
|||||||
bb.get(chunk)
|
bb.get(chunk)
|
||||||
while(bufferRemain<chunk.size){
|
while(bufferRemain<chunk.size){
|
||||||
delay(10)
|
delay(10)
|
||||||
|
// gas-rem pengiriman
|
||||||
|
when{
|
||||||
|
bufferRemain <= bufkosong -> {
|
||||||
|
if (delayinterval!=slowdowninterval){
|
||||||
|
delayinterval = slowdowninterval
|
||||||
|
Logger.info{"Sending to $ipaddress on channel $channel, bufferRemain low: $bufferRemain bytes, slowing down to $delayinterval ms"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bufferRemain >= bufpenuh -> {
|
||||||
|
if (delayinterval!=speedupinterval){
|
||||||
|
delayinterval = speedupinterval
|
||||||
|
Logger.info{"Sending to $ipaddress on channel $channel, bufferRemain high: $bufferRemain bytes, speeding up to $delayinterval ms"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
udp.send(DatagramPacket(chunk, chunk.size, inet))
|
udp.send(DatagramPacket(chunk, chunk.size, inet))
|
||||||
|
|
||||||
mp3encoder.PushData(chunk)
|
mp3encoder.PushData(chunk)
|
||||||
if (_barixmode) delay(10) else delay(1)
|
delay(delayinterval)
|
||||||
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.error{"SendData to $ipaddress failed, message: ${e.message}"}
|
||||||
cbFail.accept("SendData to $ipaddress failed, message: ${e.message}")
|
cbFail.accept("SendData to $ipaddress failed, message: ${e.message}")
|
||||||
|
cbPlaying.accept(false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cbOK.accept("SendData to $channel ($ipaddress) succeeded, ${data.size} bytes sent")
|
Logger.info{"SendData to $channel ($ipaddress) ended, ${data.size} bytes sent"}
|
||||||
|
cbOK.accept("SendData to $channel ($ipaddress) ended, ${data.size} bytes sent")
|
||||||
}
|
}
|
||||||
|
|
||||||
} else cbFail.accept("SendData to $ipaddress failed, data is empty")
|
} else cbFail.accept("SendData to $ipaddress failed, data is empty")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert BarixConnection to JsonNode
|
|
||||||
* @return JsonNode representation of BarixConnection
|
|
||||||
*/
|
|
||||||
fun toJsonNode(): JsonNode {
|
|
||||||
// make json node from index, channel, ipaddress, port, bufferRemain, statusData, vu
|
|
||||||
return Somecodes.objectmapper.createObjectNode().apply {
|
|
||||||
put("index", index.toInt())
|
|
||||||
put("channel", channel)
|
|
||||||
put("ipaddress", ipaddress)
|
|
||||||
put("port", port)
|
|
||||||
put("bufferRemain", bufferRemain)
|
|
||||||
put("statusData", statusData)
|
|
||||||
put("vu", vu)
|
|
||||||
put("isOnline", isOnline())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert BarixConnection to JSON string
|
|
||||||
* @return JSON string representation of BarixConnection
|
|
||||||
*/
|
|
||||||
fun toJsonString(): String {
|
|
||||||
return Somecodes.toJsonString(toJsonNode())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ActivateRelay(relays: List<Int>){
|
fun ActivateRelay(relays: List<Int>){
|
||||||
if (relays.isNotEmpty()){
|
if (relays.isNotEmpty()){
|
||||||
var value : Byte = 0
|
var value : Byte = 0
|
||||||
@@ -201,22 +317,6 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate relay on Barix device
|
|
||||||
* @param relays The relay numbers to activate (1-8)
|
|
||||||
*/
|
|
||||||
fun ActivateRelay(vararg relays: Int){
|
|
||||||
if (relays.isNotEmpty()){
|
|
||||||
var value : Byte = 0
|
|
||||||
for (r in relays){
|
|
||||||
if (r in 1..8){
|
|
||||||
value = value or (1 shl (r - 1)).toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SendSimpleCommand(byteArrayOf(0x1A, value, 0x61))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deactivate relay on Barix device
|
* Deactivate relay on Barix device
|
||||||
*/
|
*/
|
||||||
@@ -247,41 +347,7 @@ class BarixConnection(val index: UInt, var channel: String, val ipaddress: Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send command to Barix device
|
|
||||||
* @param command The command to send
|
|
||||||
* @return true if successful
|
|
||||||
*/
|
|
||||||
fun SendCommand(command: String): Boolean {
|
|
||||||
try {
|
|
||||||
if (_tcp!=null){
|
|
||||||
if (!_tcp!!.isClosed){
|
|
||||||
val bb = command.toByteArray()
|
|
||||||
val size = bb.size + 4
|
|
||||||
val b4 = byteArrayOf(
|
|
||||||
(size shr 24 and 0xFF).toByte(),
|
|
||||||
(size shr 16 and 0xFF).toByte(),
|
|
||||||
(size shr 8 and 0xFF).toByte(),
|
|
||||||
(size and 0xFF).toByte()
|
|
||||||
)
|
|
||||||
val out = _tcp!!.getOutputStream()
|
|
||||||
out.write(b4)
|
|
||||||
out.write(bb)
|
|
||||||
out.flush()
|
|
||||||
Logger.info { "SendCommand to $ipaddress : $command" }
|
|
||||||
return true
|
|
||||||
}else {
|
|
||||||
Logger.error { "Socket to $ipaddress is not connected" }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Logger.error { "Socket to $ipaddress is null" }
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.error { "Failed to SendCommand to $ipaddress, Message : ${e.message}" }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -7,17 +7,15 @@ import org.tinylog.Logger
|
|||||||
import java.io.DataInputStream
|
import java.io.DataInputStream
|
||||||
import java.net.ServerSocket
|
import java.net.ServerSocket
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
class TCP_Barix_Command_Server {
|
class TCP_Barix_Command_Server {
|
||||||
lateinit var tcpserver: ServerSocket
|
lateinit var tcpserver: ServerSocket
|
||||||
lateinit var job: Job
|
lateinit var job: Job
|
||||||
private val socketMap = mutableMapOf<String, Socket>()
|
private val socketMap = mutableMapOf<String, Socket>()
|
||||||
|
|
||||||
private val regex = """STATUSBARIX;(\d+);(\d+)(;(\d+))?"""
|
//private val regex = """STATUSBARIX;(\d+);(\d+)(;(\d+))?"""
|
||||||
private val pattern = Regex(regex)
|
//private val pattern = Regex(regex)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start TCP Command Server
|
* Start TCP Command Server
|
||||||
@@ -43,6 +41,7 @@ class TCP_Barix_Command_Server {
|
|||||||
try{
|
try{
|
||||||
|
|
||||||
val din = DataInputStream(socket.getInputStream())
|
val din = DataInputStream(socket.getInputStream())
|
||||||
|
var VuZeroCounter = 0L
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
|
||||||
val bb = ByteArray(128)
|
val bb = ByteArray(128)
|
||||||
@@ -60,25 +59,56 @@ class TCP_Barix_Command_Server {
|
|||||||
Logger.error { "Error reading length from Streamer Output with IP $key, Message : ${ex.message}" }
|
Logger.error { "Error reading length from Streamer Output with IP $key, Message : ${ex.message}" }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val str = String(bb,4, stringlength).trim()
|
var str = String(bb,4, stringlength).trim()
|
||||||
if (str.isBlank()) continue
|
if (str.isBlank()) continue
|
||||||
if (!str.startsWith("STATUSBARIX")) continue
|
if (!str.startsWith("STATUSBARIX")) continue
|
||||||
|
if (str.endsWith("@")) str = str.removeSuffix("@")
|
||||||
if (ValidString(str)) {
|
if (ValidString(str)) {
|
||||||
// Valid command from Barix is in format $"STATUSBARIX;VU;BuffRemain;StatusData"$
|
// Valid command from StreamerOutput is in format $"STATUSBARIX;VU;BuffRemain;StatusData"$
|
||||||
pattern.find(str)?.let { matchResult ->
|
// Valid command from Barix is in format $"STATUSBARIX;VU;BuffRemain"$
|
||||||
val (vu, buffremain, statusdata) = matchResult.destructured
|
val values = str.split(";")
|
||||||
val status = BarixStatus(
|
if (values.size<3) continue
|
||||||
socket.inetAddress.hostAddress,
|
if ("STATUSBARIX" != values[0]) continue
|
||||||
vu.toInt(),
|
val vu = values[1].toIntOrNull() ?: continue
|
||||||
buffremain.toInt(),
|
|
||||||
statusdata.toIntOrNull() ?: 0,
|
val buffremain = values[2].toIntOrNull() ?: continue
|
||||||
statusdata.isNullOrEmpty() // barix tidak ada statusdata , Q-AG1 ada
|
var status: BarixStatus
|
||||||
)
|
when(values.size){
|
||||||
//Logger.info { "Received valid command from $key : $status" }
|
3 ->{
|
||||||
cb.accept(status)
|
// mode barix
|
||||||
} ?: run {
|
// kadang vu stuck tidak di 0 saat idle,
|
||||||
Logger.warn { "Invalid command format from $key : $str" }
|
// jadi kalau vu <512 selama 10 kali berturut2
|
||||||
|
// dan buffer lebih dari 16000, anggap idle
|
||||||
|
if ((vu < 512) && (buffremain>=16000)){
|
||||||
|
VuZeroCounter++
|
||||||
|
} else {
|
||||||
|
VuZeroCounter = 0
|
||||||
}
|
}
|
||||||
|
// statusdata = isplaying = , if VuZeroCounter >=10 then idle (0) else playing (1)
|
||||||
|
val statusdata = if (VuZeroCounter>=10) 0 else 1
|
||||||
|
status = BarixStatus(
|
||||||
|
socket.inetAddress.hostAddress,
|
||||||
|
vu,
|
||||||
|
buffremain,
|
||||||
|
statusdata,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
4 ->{
|
||||||
|
// mode Q-AG1
|
||||||
|
val statusdata = values[3].toIntOrNull() ?: 0
|
||||||
|
status = BarixStatus(
|
||||||
|
socket.inetAddress.hostAddress,
|
||||||
|
vu,
|
||||||
|
buffremain,
|
||||||
|
statusdata,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
cb.accept(status)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ class Somecodes {
|
|||||||
} else cb.accept(-1,-1)
|
} else cb.accept(-1,-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string of numbers separated by commas or semicolons into a list of integers.
|
||||||
|
*/
|
||||||
|
fun StringToListInt(value : String?) : List<Int>{
|
||||||
|
if (value.isNullOrBlank()) return listOf()
|
||||||
|
return value.split(",",";").mapNotNull { it.trim().toIntOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all files in PagingResult directory.
|
* Clear all files in PagingResult directory.
|
||||||
* If PagingResult directory does not exist, callback with -1,-1.
|
* If PagingResult directory does not exist, callback with -1,-1.
|
||||||
|
|||||||
@@ -72,14 +72,13 @@ class TCP_Android_Command_Server {
|
|||||||
//println("Received command from $key : $str")
|
//println("Received command from $key : $str")
|
||||||
str.split("@").map { it.trim() }.filter { ValidString(it) }
|
str.split("@").map { it.trim() }.filter { ValidString(it) }
|
||||||
.forEach {
|
.forEach {
|
||||||
Logger.info{"Receive command from $key : $it"}
|
|
||||||
process_command(key,it) { reply ->
|
process_command(key,it) { reply ->
|
||||||
try {
|
try {
|
||||||
val cc = String_to_Byte_Android(reply)
|
val cc = String_to_Byte_Android(reply)
|
||||||
if (cc.isNotEmpty()){
|
if (cc.isNotEmpty()){
|
||||||
dout.write(cc)
|
dout.write(cc)
|
||||||
dout.flush()
|
dout.flush()
|
||||||
Logger.info{"Sent reply ${cc.size} bytes to $key : $reply"}
|
//Logger.info{"Sent reply ${cc.size} bytes to $key : $reply"}
|
||||||
} else Logger.error { "Empty reply to send to $key" }
|
} else Logger.error { "Empty reply to send to $key" }
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -149,7 +148,7 @@ class TCP_Android_Command_Server {
|
|||||||
* @param cb Callback to send reply string
|
* @param cb Callback to send reply string
|
||||||
*/
|
*/
|
||||||
private fun process_command(key: String, cmd: String, cb: Consumer<String>) {
|
private fun process_command(key: String, cmd: String, cb: Consumer<String>) {
|
||||||
Logger.info { "Command from $key : $cmd" }
|
if ("PING" != cmd) Logger.info { "Command from $key : $cmd" }
|
||||||
val parts = cmd.split(";").map { it.trim() }.filter { it.isNotBlank() }
|
val parts = cmd.split(";").map { it.trim() }.filter { it.isNotBlank() }
|
||||||
when (parts[0]) {
|
when (parts[0]) {
|
||||||
"GETLOGIN" -> {
|
"GETLOGIN" -> {
|
||||||
|
|||||||
@@ -25,4 +25,28 @@ data class BroadcastZones(var index: UInt, var description: String, var SoundCha
|
|||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "BroadcastZones(index=$index, description='$description', SoundChannel='$SoundChannel', id='$id', bp='$bp')"
|
return "BroadcastZones(index=$index, description='$description', SoundChannel='$SoundChannel', id='$id', bp='$bp')"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of relay numbers from the broadcast zone's bp field.
|
||||||
|
* Currently, supports relays 1 to 8.
|
||||||
|
* @param bz The BroadcastZones object
|
||||||
|
* @return List of relay numbers (Int) extracted from the bp field
|
||||||
|
*/
|
||||||
|
fun getRelaysFromBroadcastZone(bz : BroadcastZones) : List<Int>{
|
||||||
|
val result = ArrayList<Int>()
|
||||||
|
// delimiters either comma or semicolon
|
||||||
|
val parts = bz.bp.split(",", ";")
|
||||||
|
for (part in parts){
|
||||||
|
val relay = part.trim().toIntOrNull()
|
||||||
|
if (relay != null){
|
||||||
|
if (relay in 1..8){
|
||||||
|
result.add(relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
class StreamerOutputData(val index: UInt, val channel: String, val ipaddress: String, val vu: Int, val bufferRemain: Int, var isPlaying: Boolean) {
|
data class StreamerOutputData(val index: UInt, val channel: String, val ipaddress: String, val vu: Int, val bufferRemain: Int, val isPlaying: Boolean, val filename: String, val duration: String, val elapsed: String, val broadcastzones: String) {
|
||||||
companion object{
|
companion object{
|
||||||
fun fromBarixConnection(bc: barix.BarixConnection): StreamerOutputData {
|
fun fromBarixConnection(bc: barix.BarixConnection): StreamerOutputData {
|
||||||
return StreamerOutputData(bc.index, bc.channel, bc.ipaddress, bc.vu, bc.bufferRemain, bc.isPlaying())
|
return StreamerOutputData(bc.index, bc.channel, bc.ipaddress, bc.vu, bc.bufferRemain, bc.isPlaying(), bc.GetAudioFileInfo()?.fileName ?: "", bc.GetAudioFileInfo()?.DurationToString() ?: "", bc.GetElapsed(), bc.GetUsedByBroadcastZones())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user