|
|
@@ -587,7 +587,7 @@ input[type="range"]::-webkit-slider-thumb:hover { |
|
|
background: var(--accent); |
|
|
background: var(--accent); |
|
|
transform: scale(1.06); |
|
|
transform: scale(1.06); |
|
|
} |
|
|
} |
|
|
input[type="number"], input[type="text"] { |
|
|
|
|
|
|
|
|
input[type="number"], input[type="text"], select { |
|
|
background: #fff; |
|
|
background: #fff; |
|
|
border: 1px solid var(--border); |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 6px; |
|
|
border-radius: 6px; |
|
|
@@ -601,6 +601,10 @@ input[type="number"] { |
|
|
text-align: right; |
|
|
text-align: right; |
|
|
} |
|
|
} |
|
|
input[type="text"] { width: 100%; } |
|
|
input[type="text"] { width: 100%; } |
|
|
|
|
|
select { |
|
|
|
|
|
min-width: 140px; |
|
|
|
|
|
width: 100%; |
|
|
|
|
|
} |
|
|
input:focus { |
|
|
input:focus { |
|
|
border-color: var(--accent); |
|
|
border-color: var(--accent); |
|
|
box-shadow: 0 0 0 3px rgba(31,77,157,.12); |
|
|
box-shadow: 0 0 0 3px rgba(31,77,157,.12); |
|
|
@@ -737,6 +741,39 @@ input.input-error { |
|
|
flex-wrap: wrap; |
|
|
flex-wrap: wrap; |
|
|
margin-top: 14px; |
|
|
margin-top: 14px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.ingest-grid { |
|
|
|
|
|
display: grid; |
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr)); |
|
|
|
|
|
gap: 10px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.ingest-grid .ctrl-row { |
|
|
|
|
|
align-items: flex-start; |
|
|
|
|
|
padding: 0; |
|
|
|
|
|
border-bottom: none; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.ingest-grid .ctrl-label-wrap { |
|
|
|
|
|
min-width: 0; |
|
|
|
|
|
gap: 4px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.ingest-group { |
|
|
|
|
|
margin-top: 12px; |
|
|
|
|
|
padding: 10px; |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: 6px; |
|
|
|
|
|
background: var(--surface2); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.ingest-group-title { |
|
|
|
|
|
margin-bottom: 8px; |
|
|
|
|
|
font-size: 10px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: .08em; |
|
|
|
|
|
} |
|
|
.apply-btn { |
|
|
.apply-btn { |
|
|
background: var(--accent); |
|
|
background: var(--accent); |
|
|
border-color: transparent; |
|
|
border-color: transparent; |
|
|
@@ -975,6 +1012,7 @@ input.input-error { |
|
|
.ctrl-row { flex-direction: column; align-items: stretch; } |
|
|
.ctrl-row { flex-direction: column; align-items: stretch; } |
|
|
.ctrl-label-wrap { min-width: auto; } |
|
|
.ctrl-label-wrap { min-width: auto; } |
|
|
.ctrl-input { flex-wrap: wrap; } |
|
|
.ctrl-input { flex-wrap: wrap; } |
|
|
|
|
|
.ingest-grid { grid-template-columns: 1fr; } |
|
|
input[type="number"] { width: 100%; text-align: left; } |
|
|
input[type="number"] { width: 100%; text-align: left; } |
|
|
.actions-row, .tx-actions { flex-direction: column; } |
|
|
.actions-row, .tx-actions { flex-direction: column; } |
|
|
.tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; } |
|
|
.tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; } |
|
|
@@ -1244,6 +1282,230 @@ input.input-error { |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="card panel" data-panel-key="ingest"> |
|
|
|
|
|
<div class="panel-head" data-panel> |
|
|
|
|
|
<div class="led on-blue" style="width:6px;height:6px"></div> |
|
|
|
|
|
<h2>Ingest Config</h2> |
|
|
|
|
|
<div class="meta" id="ingest-meta">Saved config</div> |
|
|
|
|
|
<span class="chevron">▼</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="panel-body"> |
|
|
|
|
|
<div class="section-note">Edit ingest source settings, save to config file, then force a hard reload so the runtime restarts with the new ingest path.</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ingest-grid"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"> |
|
|
|
|
|
<span class="ctrl-label">Ingest Kind</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<select id="ing-kind" data-ingest-path="kind"> |
|
|
|
|
|
<option value="none">none</option> |
|
|
|
|
|
<option value="icecast">icecast</option> |
|
|
|
|
|
<option value="srt">srt</option> |
|
|
|
|
|
<option value="aes67">aes67</option> |
|
|
|
|
|
<option value="stdin">stdin</option> |
|
|
|
|
|
<option value="http-raw">http-raw</option> |
|
|
|
|
|
</select> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"> |
|
|
|
|
|
<span class="ctrl-label">Prebuffer</span> |
|
|
|
|
|
<span class="ctrl-sub">ms</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="number" id="ing-prebuffer" data-ingest-path="prebufferMs"> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"> |
|
|
|
|
|
<span class="ctrl-label">Stall Timeout</span> |
|
|
|
|
|
<span class="ctrl-sub">ms</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="number" id="ing-stall-timeout" data-ingest-path="stallTimeoutMs"> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"> |
|
|
|
|
|
<span class="ctrl-label">Reconnect</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<label><input type="checkbox" id="ing-reconnect-enabled" data-ingest-path="reconnect.enabled"> Enabled</label> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"> |
|
|
|
|
|
<span class="ctrl-label">Backoff Initial</span> |
|
|
|
|
|
<span class="ctrl-sub">ms</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="number" id="ing-reconnect-initial" data-ingest-path="reconnect.initialBackoffMs"> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"> |
|
|
|
|
|
<span class="ctrl-label">Backoff Max</span> |
|
|
|
|
|
<span class="ctrl-sub">ms</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="number" id="ing-reconnect-max" data-ingest-path="reconnect.maxBackoffMs"> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ingest-group" id="ing-group-icecast"> |
|
|
|
|
|
<div class="ingest-group-title">Icecast</div> |
|
|
|
|
|
<div class="ingest-grid"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-icecast-url" data-ingest-path="icecast.url" spellcheck="false"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Decoder</span></div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<select id="ing-icecast-decoder" data-ingest-path="icecast.decoder"> |
|
|
|
|
|
<option value="auto">auto</option> |
|
|
|
|
|
<option value="native">native</option> |
|
|
|
|
|
<option value="ffmpeg">ffmpeg</option> |
|
|
|
|
|
<option value="fallback">fallback</option> |
|
|
|
|
|
</select> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">RadioText Relay</span></div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<label><input type="checkbox" id="ing-icecast-rt-enabled" data-ingest-path="icecast.radioText.enabled"> Enabled</label> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">RT Prefix</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-icecast-rt-prefix" data-ingest-path="icecast.radioText.prefix"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">RT MaxLen</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-icecast-rt-maxlen" data-ingest-path="icecast.radioText.maxLen"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">RT Only On Change</span></div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<label><input type="checkbox" id="ing-icecast-rt-only-change" data-ingest-path="icecast.radioText.onlyOnChange"> Enabled</label> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ingest-group" id="ing-group-srt"> |
|
|
|
|
|
<div class="ingest-group-title">SRT</div> |
|
|
|
|
|
<div class="ingest-grid"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-srt-url" data-ingest-path="srt.url" spellcheck="false"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Mode</span></div> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<select id="ing-srt-mode" data-ingest-path="srt.mode"> |
|
|
|
|
|
<option value="listener">listener</option> |
|
|
|
|
|
<option value="caller">caller</option> |
|
|
|
|
|
<option value="rendezvous">rendezvous</option> |
|
|
|
|
|
</select> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-srt-rate" data-ingest-path="srt.sampleRateHz"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-srt-channels" data-ingest-path="srt.channels"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="ingest-group" id="ing-group-aes67"> |
|
|
|
|
|
<div class="ingest-group-title">AES67</div> |
|
|
|
|
|
<div class="ingest-grid"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">SDP Path</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-aes67-sdppath" data-ingest-path="aes67.sdpPath" spellcheck="false"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">SDP Inline</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-aes67-sdp" data-ingest-path="aes67.sdp" spellcheck="false"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Multicast Group</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-aes67-group" data-ingest-path="aes67.multicastGroup" spellcheck="false"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Port</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-port" data-ingest-path="aes67.port"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Payload Type</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-pt" data-ingest-path="aes67.payloadType"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-rate" data-ingest-path="aes67.sampleRateHz"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-channels" data-ingest-path="aes67.channels"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Encoding</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-aes67-encoding" data-ingest-path="aes67.encoding"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Packet Time</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-ptime" data-ingest-path="aes67.packetTimeMs"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Jitter Depth</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-jitter" data-ingest-path="aes67.jitterDepthPackets"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Read Buffer</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-readbuf" data-ingest-path="aes67.readBufferBytes"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Discovery</span></div> |
|
|
|
|
|
<div class="ctrl-input"><label><input type="checkbox" id="ing-aes67-discovery-enabled" data-ingest-path="aes67.discovery.enabled"> Enabled</label></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Discovery Name</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-aes67-discovery-name" data-ingest-path="aes67.discovery.streamName"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">Discovery Timeout</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-discovery-timeout" data-ingest-path="aes67.discovery.timeoutMs"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">SAP Group</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="text" id="ing-aes67-discovery-group" data-ingest-path="aes67.discovery.sapGroup"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<div class="ctrl-label-wrap"><span class="ctrl-label">SAP Port</span></div> |
|
|
|
|
|
<div class="ctrl-input"><input type="number" id="ing-aes67-discovery-port" data-ingest-path="aes67.discovery.sapPort"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="field-error" id="ingest-error"></div> |
|
|
|
|
|
<div class="actions-row"> |
|
|
|
|
|
<button class="apply-btn" id="ingest-save-reload" type="button">Save + Hard Reload</button> |
|
|
|
|
|
<button class="apply-btn secondary" id="ingest-reset" type="button">Reset Draft</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
</div> |
|
|
</div> |
|
|
<div class="stack"> |
|
|
<div class="stack"> |
|
|
<div class="card panel" data-panel-key="shortcuts"> |
|
|
<div class="card panel" data-panel-key="shortcuts"> |
|
|
@@ -1437,6 +1699,10 @@ const state = { |
|
|
toggleBusy: {}, |
|
|
toggleBusy: {}, |
|
|
pollersStarted: false, |
|
|
pollersStarted: false, |
|
|
mobilePanelsApplied: false, |
|
|
mobilePanelsApplied: false, |
|
|
|
|
|
ingestDraft: null, |
|
|
|
|
|
ingestDirty: false, |
|
|
|
|
|
ingestSaving: false, |
|
|
|
|
|
ingestError: '', |
|
|
charts: { |
|
|
charts: { |
|
|
audio: [], |
|
|
audio: [], |
|
|
underruns: [], |
|
|
underruns: [], |
|
|
@@ -1462,6 +1728,68 @@ function nearlyEqual(a, b, eps = 1e-9) { |
|
|
|
|
|
|
|
|
function nowTs() { return Date.now(); } |
|
|
function nowTs() { return Date.now(); } |
|
|
|
|
|
|
|
|
|
|
|
function deepClone(obj) { |
|
|
|
|
|
return JSON.parse(JSON.stringify(obj ?? {})); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function getPathValue(obj, path) { |
|
|
|
|
|
if (!obj) return undefined; |
|
|
|
|
|
const parts = String(path || '').split('.'); |
|
|
|
|
|
let cur = obj; |
|
|
|
|
|
for (const part of parts) { |
|
|
|
|
|
if (!part) continue; |
|
|
|
|
|
if (cur == null || typeof cur !== 'object') return undefined; |
|
|
|
|
|
cur = cur[part]; |
|
|
|
|
|
} |
|
|
|
|
|
return cur; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setPathValue(obj, path, value) { |
|
|
|
|
|
const parts = String(path || '').split('.'); |
|
|
|
|
|
let cur = obj; |
|
|
|
|
|
for (let i = 0; i < parts.length; i += 1) { |
|
|
|
|
|
const part = parts[i]; |
|
|
|
|
|
if (!part) continue; |
|
|
|
|
|
if (i === parts.length - 1) { |
|
|
|
|
|
cur[part] = value; |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
if (!cur[part] || typeof cur[part] !== 'object') cur[part] = {}; |
|
|
|
|
|
cur = cur[part]; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function ingestFromServer() { |
|
|
|
|
|
return state.server.config?.ingest || {}; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function updateIngestDirtyFromServer() { |
|
|
|
|
|
const serverRaw = ingestFromServer(); |
|
|
|
|
|
const draftRaw = state.ingestDraft || {}; |
|
|
|
|
|
state.ingestDirty = JSON.stringify(draftRaw) !== JSON.stringify(serverRaw); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function syncIngestDraftFromConfig(force = false) { |
|
|
|
|
|
if (!state.server.config) return; |
|
|
|
|
|
if (!state.ingestDraft || force || !state.ingestDirty) { |
|
|
|
|
|
state.ingestDraft = deepClone(ingestFromServer()); |
|
|
|
|
|
state.ingestError = ''; |
|
|
|
|
|
} |
|
|
|
|
|
updateIngestDirtyFromServer(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function ingestFieldValue(path) { |
|
|
|
|
|
return getPathValue(state.ingestDraft || {}, path); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setIngestField(path, value) { |
|
|
|
|
|
if (!state.ingestDraft) state.ingestDraft = deepClone(ingestFromServer()); |
|
|
|
|
|
setPathValue(state.ingestDraft, path, value); |
|
|
|
|
|
updateIngestDirtyFromServer(); |
|
|
|
|
|
state.ingestError = ''; |
|
|
|
|
|
render(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
function serverValue(key) { |
|
|
function serverValue(key) { |
|
|
const cfg = state.server.config; |
|
|
const cfg = state.server.config; |
|
|
if (!cfg) return undefined; |
|
|
if (!cfg) return undefined; |
|
|
@@ -1578,6 +1906,7 @@ async function loadConfig({ silent = false } = {}) { |
|
|
state.server.config = cfg; |
|
|
state.server.config = cfg; |
|
|
state.server.configOk = true; |
|
|
state.server.configOk = true; |
|
|
state.server.lastConfigAt = nowTs(); |
|
|
state.server.lastConfigAt = nowTs(); |
|
|
|
|
|
syncIngestDraftFromConfig(); |
|
|
syncFreqPresetIndex(cfg.fm?.frequencyMHz); |
|
|
syncFreqPresetIndex(cfg.fm?.frequencyMHz); |
|
|
setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected'); |
|
|
setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected'); |
|
|
render(); |
|
|
render(); |
|
|
@@ -1790,6 +2119,52 @@ function resetSection(section) { |
|
|
toast('Draft reset', 'info'); |
|
|
toast('Draft reset', 'info'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function saveIngestConfig() { |
|
|
|
|
|
if (state.ingestSaving) return; |
|
|
|
|
|
if (!state.ingestDirty) { |
|
|
|
|
|
toast('No ingest changes to save', 'info'); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
if (!state.ingestDraft) { |
|
|
|
|
|
toast('Ingest draft not ready yet', 'warn'); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
state.ingestSaving = true; |
|
|
|
|
|
state.ingestError = ''; |
|
|
|
|
|
beginRequest(); |
|
|
|
|
|
render(); |
|
|
|
|
|
try { |
|
|
|
|
|
const result = await api('/config/ingest/save', { |
|
|
|
|
|
method: 'POST', |
|
|
|
|
|
headers: { 'Content-Type': 'application/json' }, |
|
|
|
|
|
body: JSON.stringify({ ingest: state.ingestDraft }), |
|
|
|
|
|
}); |
|
|
|
|
|
state.ingestDirty = false; |
|
|
|
|
|
toast(result.reloadScheduled ? 'Ingest saved, hard reload scheduled' : 'Ingest saved', 'ok'); |
|
|
|
|
|
log('INGEST save accepted' + (result.reloadScheduled ? ' [hard-reload]' : ''), 'ok'); |
|
|
|
|
|
if (result.reloadScheduled) { |
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
|
window.location.reload(); |
|
|
|
|
|
}, 1500); |
|
|
|
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
state.ingestError = error.message; |
|
|
|
|
|
toast(error.message, 'err'); |
|
|
|
|
|
log('INGEST save failed: ' + error.message, 'err'); |
|
|
|
|
|
} finally { |
|
|
|
|
|
state.ingestSaving = false; |
|
|
|
|
|
endRequest(); |
|
|
|
|
|
render(); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function resetIngestDraft() { |
|
|
|
|
|
syncIngestDraftFromConfig(true); |
|
|
|
|
|
toast('Ingest draft reset', 'info'); |
|
|
|
|
|
log('INGEST draft reset', 'warn'); |
|
|
|
|
|
render(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function setToggle(key, nextValue) { |
|
|
async function setToggle(key, nextValue) { |
|
|
if (state.toggleBusy[key]) return; |
|
|
if (state.toggleBusy[key]) return; |
|
|
state.toggleBusy[key] = true; |
|
|
state.toggleBusy[key] = true; |
|
|
@@ -1920,6 +2295,21 @@ function syncDirtyInput(id, key, transform = (v) => v) { |
|
|
el.classList.toggle('input-error', !!state.errors[key]); |
|
|
el.classList.toggle('input-error', !!state.errors[key]); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function syncIngestInput(id, path, transform = (v) => v) { |
|
|
|
|
|
const el = $(id); |
|
|
|
|
|
if (!el) return; |
|
|
|
|
|
const value = transform(ingestFieldValue(path)); |
|
|
|
|
|
const asString = value == null ? '' : String(value); |
|
|
|
|
|
const isFocused = document.activeElement === el; |
|
|
|
|
|
if (!isFocused && el.value !== asString) el.value = asString; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function syncIngestCheckbox(id, path) { |
|
|
|
|
|
const el = $(id); |
|
|
|
|
|
if (!el) return; |
|
|
|
|
|
el.checked = !!ingestFieldValue(path); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
function renderFieldErrors() { |
|
|
function renderFieldErrors() { |
|
|
renderFieldError('freq-error', state.errors.frequencyMHz); |
|
|
renderFieldError('freq-error', state.errors.frequencyMHz); |
|
|
renderFieldError('ps-error', state.errors.ps); |
|
|
renderFieldError('ps-error', state.errors.ps); |
|
|
@@ -2075,6 +2465,41 @@ function render() { |
|
|
syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0'); |
|
|
syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0'); |
|
|
syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? '')); |
|
|
syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? '')); |
|
|
syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? '')); |
|
|
syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-kind', 'kind', (v) => String(v ?? 'none')); |
|
|
|
|
|
syncIngestInput('ing-prebuffer', 'prebufferMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); |
|
|
|
|
|
syncIngestInput('ing-stall-timeout', 'stallTimeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); |
|
|
|
|
|
syncIngestCheckbox('ing-reconnect-enabled', 'reconnect.enabled'); |
|
|
|
|
|
syncIngestInput('ing-reconnect-initial', 'reconnect.initialBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); |
|
|
|
|
|
syncIngestInput('ing-reconnect-max', 'reconnect.maxBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); |
|
|
|
|
|
|
|
|
|
|
|
syncIngestInput('ing-icecast-url', 'icecast.url', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-icecast-decoder', 'icecast.decoder', (v) => String(v ?? 'auto')); |
|
|
|
|
|
syncIngestCheckbox('ing-icecast-rt-enabled', 'icecast.radioText.enabled'); |
|
|
|
|
|
syncIngestInput('ing-icecast-rt-prefix', 'icecast.radioText.prefix', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-icecast-rt-maxlen', 'icecast.radioText.maxLen', (v) => Number.isFinite(Number(v)) ? Number(v) : 64); |
|
|
|
|
|
syncIngestCheckbox('ing-icecast-rt-only-change', 'icecast.radioText.onlyOnChange'); |
|
|
|
|
|
|
|
|
|
|
|
syncIngestInput('ing-srt-url', 'srt.url', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-srt-mode', 'srt.mode', (v) => String(v ?? 'listener')); |
|
|
|
|
|
syncIngestInput('ing-srt-rate', 'srt.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000); |
|
|
|
|
|
syncIngestInput('ing-srt-channels', 'srt.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2); |
|
|
|
|
|
|
|
|
|
|
|
syncIngestInput('ing-aes67-sdppath', 'aes67.sdpPath', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-aes67-sdp', 'aes67.sdp', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-aes67-group', 'aes67.multicastGroup', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-aes67-port', 'aes67.port', (v) => Number.isFinite(Number(v)) ? Number(v) : 5004); |
|
|
|
|
|
syncIngestInput('ing-aes67-pt', 'aes67.payloadType', (v) => Number.isFinite(Number(v)) ? Number(v) : 97); |
|
|
|
|
|
syncIngestInput('ing-aes67-rate', 'aes67.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000); |
|
|
|
|
|
syncIngestInput('ing-aes67-channels', 'aes67.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2); |
|
|
|
|
|
syncIngestInput('ing-aes67-encoding', 'aes67.encoding', (v) => String(v ?? 'L24')); |
|
|
|
|
|
syncIngestInput('ing-aes67-ptime', 'aes67.packetTimeMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 1); |
|
|
|
|
|
syncIngestInput('ing-aes67-jitter', 'aes67.jitterDepthPackets', (v) => Number.isFinite(Number(v)) ? Number(v) : 8); |
|
|
|
|
|
syncIngestInput('ing-aes67-readbuf', 'aes67.readBufferBytes', (v) => Number.isFinite(Number(v)) ? Number(v) : 1048576); |
|
|
|
|
|
syncIngestCheckbox('ing-aes67-discovery-enabled', 'aes67.discovery.enabled'); |
|
|
|
|
|
syncIngestInput('ing-aes67-discovery-name', 'aes67.discovery.streamName', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-aes67-discovery-timeout', 'aes67.discovery.timeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 3000); |
|
|
|
|
|
syncIngestInput('ing-aes67-discovery-group', 'aes67.discovery.sapGroup', (v) => String(v ?? '')); |
|
|
|
|
|
syncIngestInput('ing-aes67-discovery-port', 'aes67.discovery.sapPort', (v) => Number.isFinite(Number(v)) ? Number(v) : 0); |
|
|
|
|
|
|
|
|
const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? ''); |
|
|
const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? ''); |
|
|
const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? ''); |
|
|
const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? ''); |
|
|
@@ -2093,9 +2518,20 @@ function render() { |
|
|
const rdsDirty = isDirtySection('rds'); |
|
|
const rdsDirty = isDirtySection('rds'); |
|
|
$('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds'); |
|
|
$('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds'); |
|
|
$('rds-reset').disabled = !rdsDirty; |
|
|
$('rds-reset').disabled = !rdsDirty; |
|
|
|
|
|
const ingestKind = String(ingestFieldValue('kind') || 'none').toLowerCase(); |
|
|
|
|
|
$('ing-group-icecast').style.display = ingestKind === 'icecast' ? '' : 'none'; |
|
|
|
|
|
$('ing-group-srt').style.display = ingestKind === 'srt' ? '' : 'none'; |
|
|
|
|
|
$('ing-group-aes67').style.display = ingestKind === 'aes67' ? '' : 'none'; |
|
|
|
|
|
$('ingest-save-reload').disabled = !state.ingestDirty || state.ingestSaving || !state.server.configOk; |
|
|
|
|
|
$('ingest-save-reload').textContent = state.ingestSaving ? 'Saving...' : 'Save + Hard Reload'; |
|
|
|
|
|
$('ingest-reset').disabled = !state.ingestDirty || state.ingestSaving; |
|
|
|
|
|
const ingestErr = $('ingest-error'); |
|
|
|
|
|
ingestErr.textContent = state.ingestError || ''; |
|
|
|
|
|
ingestErr.classList.toggle('show', !!state.ingestError); |
|
|
|
|
|
|
|
|
updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable')); |
|
|
updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable')); |
|
|
updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT')); |
|
|
updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT')); |
|
|
|
|
|
updateText('ingest-meta', state.ingestSaving ? 'Saving' : (state.ingestDirty ? 'Unsaved changes' : 'Saved config')); |
|
|
|
|
|
|
|
|
updateText('info-backend', cfg.backend?.kind || cfg.backend || '--'); |
|
|
updateText('info-backend', cfg.backend?.kind || cfg.backend || '--'); |
|
|
updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); |
|
|
updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz)); |
|
|
@@ -2532,6 +2968,33 @@ function bindInputs() { |
|
|
$('rds-apply').addEventListener('click', () => applySection('rds')); |
|
|
$('rds-apply').addEventListener('click', () => applySection('rds')); |
|
|
$('freq-reset').addEventListener('click', () => resetSection('freq')); |
|
|
$('freq-reset').addEventListener('click', () => resetSection('freq')); |
|
|
$('rds-reset').addEventListener('click', () => resetSection('rds')); |
|
|
$('rds-reset').addEventListener('click', () => resetSection('rds')); |
|
|
|
|
|
$('ingest-save-reload').addEventListener('click', () => saveIngestConfig()); |
|
|
|
|
|
$('ingest-reset').addEventListener('click', () => resetIngestDraft()); |
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('[data-ingest-path]').forEach((el) => { |
|
|
|
|
|
const path = el.dataset.ingestPath; |
|
|
|
|
|
const tag = (el.tagName || '').toLowerCase(); |
|
|
|
|
|
const type = String(el.getAttribute('type') || '').toLowerCase(); |
|
|
|
|
|
const isCheckbox = type === 'checkbox'; |
|
|
|
|
|
const isNumber = type === 'number'; |
|
|
|
|
|
const evt = isCheckbox ? 'change' : 'input'; |
|
|
|
|
|
el.addEventListener(evt, () => { |
|
|
|
|
|
if (isCheckbox) { |
|
|
|
|
|
setIngestField(path, !!el.checked); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
if (isNumber) { |
|
|
|
|
|
const n = Number(el.value); |
|
|
|
|
|
setIngestField(path, Number.isFinite(n) ? Math.trunc(n) : 0); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
if (tag === 'select') { |
|
|
|
|
|
setIngestField(path, String(el.value || '')); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
setIngestField(path, String(el.value || '')); |
|
|
|
|
|
}); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
$('btn-start').addEventListener('click', () => txAction('start')); |
|
|
$('btn-start').addEventListener('click', () => txAction('start')); |
|
|
$('btn-stop').addEventListener('click', () => txAction('stop')); |
|
|
$('btn-stop').addEventListener('click', () => txAction('stop')); |
|
|
|