[feat](trx-rs): add settings tab and virtual audio plan
Move Scheduler under a new Settings tab in the HTTP frontend. Add the virtual-channel audio implementation plan document. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
+188
@@ -0,0 +1,188 @@
|
|||||||
|
# Virtual-Channel Audio — Implementation Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Each virtual channel (SDR DSP slice) has its own Opus audio stream. When the
|
||||||
|
browser switches to a non-primary virtual channel the `/audio` WebSocket should
|
||||||
|
deliver audio demodulated at that channel's frequency and mode, not the primary
|
||||||
|
channel's audio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture (baseline)
|
||||||
|
|
||||||
|
```
|
||||||
|
SoapySDR HW
|
||||||
|
└─ SdrPipeline (slot 0: primary, slot 1..N: virtual)
|
||||||
|
pcm_tx[0] pcm_tx[1] ... pcm_tx[N] (broadcast::Sender<Vec<f32>>)
|
||||||
|
|
||||||
|
trx-server/src/main.rs
|
||||||
|
subscribe_pcm(slot 0) → Opus encode → rx_audio_tx (broadcast::Sender<Bytes>)
|
||||||
|
|
||||||
|
trx-server/src/audio.rs handle_audio_client()
|
||||||
|
writes [0x00] StreamInfo
|
||||||
|
[0x0a] history blob
|
||||||
|
loop: [0x01] RX frame ← only primary channel
|
||||||
|
|
||||||
|
trx-client/src/audio_client.rs
|
||||||
|
reads all frames → rx_audio_tx.send(bytes) (single broadcast)
|
||||||
|
|
||||||
|
FrontendRuntimeContext.audio_rx (single broadcast::Sender<Bytes>)
|
||||||
|
|
||||||
|
audio.rs / audio_ws()
|
||||||
|
subscribes to audio_rx → WebSocket to browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Only slot 0 (primary) is ever encoded/transmitted. All sessions hear the same
|
||||||
|
audio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
SdrPipeline pcm_tx[0..N]
|
||||||
|
│
|
||||||
|
trx-server/src/audio.rs (extended handle_audio_client)
|
||||||
|
┌── per-rig VChanAudioMixer ──────────────────────────────────┐
|
||||||
|
│ tracks (server_uuid → OpusEncoder + broadcast::Sender<Bytes>) │
|
||||||
|
│ listens for VCHAN_SUB/VCHAN_UNSUB from client │
|
||||||
|
│ Opus-encodes each channel's PCM independently │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│ wire frames:
|
||||||
|
│ [0x01] RX_FRAME (primary channel, unchanged)
|
||||||
|
│ [0x0b] RX_FRAME_CH [16 B UUID][N B Opus] ← NEW
|
||||||
|
│ [0x0c] VCHAN_ALLOCATED [16 B UUID] ← NEW
|
||||||
|
│ client→server:
|
||||||
|
│ [0x0d] VCHAN_SUB [16 B UUID] subscribe to channel
|
||||||
|
│ [0x0e] VCHAN_UNSUB [16 B UUID] unsubscribe
|
||||||
|
|
||||||
|
trx-client/src/audio_client.rs
|
||||||
|
demux 0x0b frames by UUID → per-channel broadcast::Sender<Bytes>
|
||||||
|
on 0x0c (allocated): publish UUID to per-channel map
|
||||||
|
|
||||||
|
FrontendRuntimeContext
|
||||||
|
audio_rx: Option<broadcast::Sender<Bytes>> (primary, unchanged)
|
||||||
|
vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>> ← NEW
|
||||||
|
|
||||||
|
ClientChannelManager (trx-frontend-http/src/vchan.rs)
|
||||||
|
allocate(): after creating local entry, sends VCHAN_SUB via new
|
||||||
|
vchan_audio_tx: mpsc::Sender<VChanAudioCmd> ← NEW
|
||||||
|
delete_channel(): sends VCHAN_UNSUB
|
||||||
|
expose: subscribe_audio(channel_id) → Option<broadcast::Receiver<Bytes>>
|
||||||
|
|
||||||
|
audio_ws() (trx-frontend-http/src/audio.rs)
|
||||||
|
accepts ?channel_id=<uuid> query param
|
||||||
|
if present → lookup context.vchan_audio[uuid] → subscribe
|
||||||
|
else → context.audio_rx (primary, current behaviour)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wire Protocol Additions (trx-core/src/audio.rs)
|
||||||
|
|
||||||
|
```
|
||||||
|
AUDIO_MSG_RX_FRAME_CH = 0x0b
|
||||||
|
AUDIO_MSG_VCHAN_ALLOCATED = 0x0c
|
||||||
|
AUDIO_MSG_VCHAN_SUB = 0x0d
|
||||||
|
AUDIO_MSG_VCHAN_UNSUB = 0x0e
|
||||||
|
```
|
||||||
|
|
||||||
|
Frame layout for `RX_FRAME_CH`:
|
||||||
|
```
|
||||||
|
[0x0b] [4 B BE length = 16 + opus_len] [16 B UUID bytes] [opus_len B Opus]
|
||||||
|
```
|
||||||
|
|
||||||
|
Frame layout for `VCHAN_ALLOCATED`, `VCHAN_SUB`, `VCHAN_UNSUB`:
|
||||||
|
```
|
||||||
|
[type] [4 B BE length = 16] [16 B UUID bytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer-by-Layer Changes
|
||||||
|
|
||||||
|
### 1. `trx-core/src/audio.rs`
|
||||||
|
- Add four new `AUDIO_MSG_*` constants.
|
||||||
|
- Add helper `read_vchan_frame(reader) -> (Uuid, Bytes)` and
|
||||||
|
`write_vchan_frame(writer, msg_type, uuid, payload)`.
|
||||||
|
|
||||||
|
### 2. `trx-server/src/audio.rs` (`handle_audio_client`)
|
||||||
|
- Accept `vchan_manager: Option<SharedVChanManager>` from `RigHandle`.
|
||||||
|
- Spawn a `VChanAudioMixer` task:
|
||||||
|
- Holds `HashMap<Uuid, (JoinHandle, broadcast::Sender<Bytes>)>`.
|
||||||
|
- On `VCHAN_SUB { uuid }`: call `vchan_manager.subscribe_pcm(uuid)`, spawn
|
||||||
|
Opus-encode task, write `VCHAN_ALLOCATED { uuid }` to client.
|
||||||
|
- On `VCHAN_UNSUB { uuid }`: abort encode task, remove from map.
|
||||||
|
- On PCM ready: Opus-encode, write `RX_FRAME_CH { uuid, opus }`.
|
||||||
|
- Add the `vchan_manager` parameter to `run_audio_listener()` and pass it
|
||||||
|
through from `main.rs`.
|
||||||
|
|
||||||
|
### 3. `trx-server/src/main.rs`
|
||||||
|
- Pass `rig_handle.vchan_manager.clone()` to `run_audio_listener()`.
|
||||||
|
|
||||||
|
### 4. `trx-client/src/audio_client.rs`
|
||||||
|
- Add `vchan_audio_tx: mpsc::Sender<VChanAudioEvent>` parameter
|
||||||
|
(where `VChanAudioEvent = Allocated(Uuid, broadcast::Sender<Bytes>) | Frame(Uuid, Bytes)`).
|
||||||
|
- On `RX_FRAME_CH { uuid, opus }`: forward to per-channel sender (create if
|
||||||
|
first frame for that uuid).
|
||||||
|
- On `VCHAN_ALLOCATED { uuid }`: signal that the channel is ready.
|
||||||
|
|
||||||
|
### 5. `trx-client/src/main.rs`
|
||||||
|
- Create `vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>`
|
||||||
|
shared between audio_client task and FrontendRuntimeContext.
|
||||||
|
- Add an `mpsc::Sender<VChanAudioCmd>` that lets the HTTP frontend request
|
||||||
|
SUB/UNSUB over the audio TCP; pass it into `run_audio_client()`.
|
||||||
|
|
||||||
|
### 6. `trx-client/trx-frontend/src/lib.rs` (`FrontendRuntimeContext`)
|
||||||
|
- Add:
|
||||||
|
```rust
|
||||||
|
pub vchan_audio: Arc<RwLock<HashMap<Uuid, broadcast::Sender<Bytes>>>>,
|
||||||
|
pub vchan_audio_cmd: Option<mpsc::Sender<VChanAudioCmd>>,
|
||||||
|
```
|
||||||
|
- Initialise both to empty/None in `new()`.
|
||||||
|
|
||||||
|
### 7. `trx-client/trx-frontend/trx-frontend-http/src/vchan.rs` (`ClientChannelManager`)
|
||||||
|
- `allocate()`: after inserting the local record, if `vchan_audio_cmd` is
|
||||||
|
available, send `VChanAudioCmd::Subscribe(uuid)`.
|
||||||
|
- `delete_channel()`: send `VChanAudioCmd::Unsubscribe(uuid)`.
|
||||||
|
- `subscribe_audio(channel_id, context) -> Option<broadcast::Receiver<Bytes>>`:
|
||||||
|
look up `context.vchan_audio.read()[channel_id].subscribe()`.
|
||||||
|
|
||||||
|
### 8. `trx-client/trx-frontend/trx-frontend-http/src/audio.rs` (`audio_ws`)
|
||||||
|
- Parse optional `channel_id: Option<Uuid>` from query string.
|
||||||
|
- If `Some(uuid)`:
|
||||||
|
- Look up `context.vchan_audio.read()[uuid]` → `broadcast::Sender<Bytes>`.
|
||||||
|
- Subscribe, forward Opus frames exactly as today but from that sender.
|
||||||
|
- Else: current primary-channel path unchanged.
|
||||||
|
|
||||||
|
### 9. `assets/web/plugins/vchan.js`
|
||||||
|
- `vchanSubscribe()` and `vchanAllocate()` call `vchanReconnectAudio()`.
|
||||||
|
- `vchanReconnectAudio()`:
|
||||||
|
- If on virtual channel: `reconnectAudioWs(vchanActiveId)` (pass channel UUID).
|
||||||
|
- If on primary: `reconnectAudioWs(null)`.
|
||||||
|
- `reconnectAudioWs(channelId)` (new in `app.js` or `vchan.js`):
|
||||||
|
- Close existing `audioWs`.
|
||||||
|
- Reopen `new WebSocket('/audio' + (channelId ? '?channel_id=' + channelId : ''))`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (non-SDR rigs)
|
||||||
|
|
||||||
|
Non-SDR rigs (`vchan_manager === None`) are unaffected. The new message types
|
||||||
|
are only exchanged when the server-side vchan manager is present. Primary-
|
||||||
|
channel audio behaviour is 100% backwards-compatible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. `trx-core/src/audio.rs` — add constants and frame helpers *(no breakage)*
|
||||||
|
2. `trx-server/src/audio.rs` — `VChanAudioMixer` + new frame handling
|
||||||
|
3. `trx-server/src/main.rs` — plumb vchan_manager through
|
||||||
|
4. `trx-client/src/audio_client.rs` — demux RX_FRAME_CH
|
||||||
|
5. `trx-client/src/main.rs` — shared vchan_audio map + cmd channel
|
||||||
|
6. `trx-frontend/src/lib.rs` — new FrontendRuntimeContext fields
|
||||||
|
7. `trx-frontend-http/src/vchan.rs` — SUB/UNSUB on allocate/delete
|
||||||
|
8. `trx-frontend-http/src/audio.rs` — channel_id query param routing
|
||||||
|
9. `vchan.js` — reconnect WebSocket on channel switch
|
||||||
@@ -3523,7 +3523,7 @@ if (spectrumBwSweetBtn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Tab navigation ---
|
// --- Tab navigation ---
|
||||||
const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "scheduler", "about"];
|
const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "settings", "about"];
|
||||||
|
|
||||||
function navigateToTab(name) {
|
function navigateToTab(name) {
|
||||||
if (authEnabled && !authRole && name !== "main") return;
|
if (authEnabled && !authRole && name !== "main") return;
|
||||||
@@ -5613,7 +5613,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
|
|||||||
applyMapFilter();
|
applyMapFilter();
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Sub-tab navigation (Decoders tab) ---
|
// --- Sub-tab navigation ---
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
||||||
bar.addEventListener("click", (e) => {
|
bar.addEventListener("click", (e) => {
|
||||||
const btn = e.target.closest(".sub-tab[data-subtab]");
|
const btn = e.target.closest(".sub-tab[data-subtab]");
|
||||||
|
|||||||
@@ -46,9 +46,9 @@
|
|||||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></svg>
|
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="scheduler">
|
<button class="tab" data-tab="settings">
|
||||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 5v3l2 2"/></svg>
|
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="2.1"/><path d="M8 1.8v1.4"/><path d="M8 12.8v1.4"/><path d="M14.2 8h-1.4"/><path d="M3.2 8H1.8"/><path d="M12.4 3.6l-1 1"/><path d="M4.6 11.4l-1 1"/><path d="M12.4 12.4l-1-1"/><path d="M4.6 4.6l-1-1"/></svg>
|
||||||
<span class="tab-label">Scheduler</span>
|
<span class="tab-label">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="about">
|
<button class="tab" data-tab="about">
|
||||||
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7v5"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
<svg class="tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7v5"/><circle cx="8" cy="5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
||||||
@@ -671,107 +671,112 @@
|
|||||||
<div id="aprs-map"></div>
|
<div id="aprs-map"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-scheduler" class="tab-panel" style="display:none;">
|
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||||
<div id="scheduler-panel" class="sch-panel">
|
<div class="sub-tab-bar">
|
||||||
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
|
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
||||||
<div class="sch-row">
|
</div>
|
||||||
<label class="sch-label">Rig
|
<div id="subtab-settings-scheduler" class="sub-tab-panel">
|
||||||
<select id="scheduler-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
|
<div id="scheduler-panel" class="sch-panel">
|
||||||
</label>
|
<div class="sch-toast" id="scheduler-toast" style="display:none;"></div>
|
||||||
<label class="sch-label">Mode
|
|
||||||
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
|
|
||||||
<option value="disabled">Disabled</option>
|
|
||||||
<option value="grayline">Grayline</option>
|
|
||||||
<option value="time_span">Time Span</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grayline section -->
|
|
||||||
<div id="scheduler-grayline-section" class="sch-section" style="display:none;">
|
|
||||||
<div class="sch-section-title">Grayline Settings</div>
|
|
||||||
<div class="sch-row">
|
<div class="sch-row">
|
||||||
<label class="sch-label">Latitude (°)
|
<label class="sch-label">Rig
|
||||||
<input type="number" id="scheduler-gl-lat" class="status-input" step="0.001" placeholder="e.g. 54.352" />
|
<select id="scheduler-rig-select" class="status-input sch-rig-select" aria-label="Select rig"></select>
|
||||||
</label>
|
</label>
|
||||||
<label class="sch-label">Longitude (°)
|
<label class="sch-label">Mode
|
||||||
<input type="number" id="scheduler-gl-lon" class="status-input" step="0.001" placeholder="e.g. 18.646" />
|
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
|
||||||
</label>
|
<option value="disabled">Disabled</option>
|
||||||
<label class="sch-label">Transition window (min)
|
<option value="grayline">Grayline</option>
|
||||||
<input type="number" id="scheduler-gl-window" class="status-input" min="5" max="120" value="20" />
|
<option value="time_span">Time Span</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="sch-row">
|
|
||||||
<label class="sch-label">Dawn bookmark
|
|
||||||
<select id="scheduler-gl-dawn" class="status-input" aria-label="Dawn bookmark"></select>
|
|
||||||
</label>
|
|
||||||
<label class="sch-label">Day bookmark
|
|
||||||
<select id="scheduler-gl-day" class="status-input" aria-label="Day bookmark"></select>
|
|
||||||
</label>
|
|
||||||
<label class="sch-label">Dusk bookmark
|
|
||||||
<select id="scheduler-gl-dusk" class="status-input" aria-label="Dusk bookmark"></select>
|
|
||||||
</label>
|
|
||||||
<label class="sch-label">Night bookmark
|
|
||||||
<select id="scheduler-gl-night" class="status-input" aria-label="Night bookmark"></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Time Span section -->
|
<!-- Grayline section -->
|
||||||
<div id="scheduler-timespan-section" class="sch-section" style="display:none;">
|
<div id="scheduler-grayline-section" class="sch-section" style="display:none;">
|
||||||
<div class="sch-section-title">Time Span Entries (UTC)</div>
|
<div class="sch-section-title">Grayline Settings</div>
|
||||||
<div class="sch-row" style="margin-bottom:0.75rem;">
|
<div class="sch-row">
|
||||||
<label class="sch-label">Interleave time (min)
|
<label class="sch-label">Latitude (°)
|
||||||
<input type="number" id="scheduler-ts-interleave" class="status-input" min="1" max="60" placeholder="off" style="width:7rem;" />
|
<input type="number" id="scheduler-gl-lat" class="status-input" step="0.001" placeholder="e.g. 54.352" />
|
||||||
</label>
|
</label>
|
||||||
<small style="color:var(--text-muted);align-self:flex-end;padding-bottom:0.35rem;">When multiple entries overlap, spend this many minutes at each before cycling. Leave blank to disable.</small>
|
<label class="sch-label">Longitude (°)
|
||||||
|
<input type="number" id="scheduler-gl-lon" class="status-input" step="0.001" placeholder="e.g. 18.646" />
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Transition window (min)
|
||||||
|
<input type="number" id="scheduler-gl-window" class="status-input" min="5" max="120" value="20" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="sch-row">
|
||||||
|
<label class="sch-label">Dawn bookmark
|
||||||
|
<select id="scheduler-gl-dawn" class="status-input" aria-label="Dawn bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Day bookmark
|
||||||
|
<select id="scheduler-gl-day" class="status-input" aria-label="Day bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Dusk bookmark
|
||||||
|
<select id="scheduler-gl-dusk" class="status-input" aria-label="Dusk bookmark"></select>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Night bookmark
|
||||||
|
<select id="scheduler-gl-night" class="status-input" aria-label="Night bookmark"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="sch-ts-table">
|
|
||||||
<thead>
|
<!-- Time Span section -->
|
||||||
<tr><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th></th></tr>
|
<div id="scheduler-timespan-section" class="sch-section" style="display:none;">
|
||||||
</thead>
|
<div class="sch-section-title">Time Span Entries (UTC)</div>
|
||||||
<tbody id="scheduler-ts-tbody"></tbody>
|
<div class="sch-row" style="margin-bottom:0.75rem;">
|
||||||
</table>
|
<label class="sch-label">Interleave time (min)
|
||||||
<div class="sch-row sch-add-row">
|
<input type="number" id="scheduler-ts-interleave" class="status-input" min="1" max="60" placeholder="off" style="width:7rem;" />
|
||||||
<label class="sch-label">Start (UTC)
|
</label>
|
||||||
<input type="time" id="scheduler-ts-start" class="status-input" title="Set both to 00:00 for all-day" />
|
<small style="color:var(--text-muted);align-self:flex-end;padding-bottom:0.35rem;">When multiple entries overlap, spend this many minutes at each before cycling. Leave blank to disable.</small>
|
||||||
</label>
|
</div>
|
||||||
<label class="sch-label">End (UTC)
|
<table class="sch-ts-table">
|
||||||
<input type="time" id="scheduler-ts-end" class="status-input" title="Set both to 00:00 for all-day" />
|
<thead>
|
||||||
</label>
|
<tr><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th></th></tr>
|
||||||
<label class="sch-label" id="scheduler-ts-center-hz-wrap" title="SDR only — sets center frequency before tuning">Center freq (Hz, SDR)
|
</thead>
|
||||||
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" style="width:9rem;" />
|
<tbody id="scheduler-ts-tbody"></tbody>
|
||||||
</label>
|
</table>
|
||||||
<label class="sch-label">Primary bookmark
|
<div class="sch-row sch-add-row">
|
||||||
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
|
<label class="sch-label">Start (UTC)
|
||||||
</label>
|
<input type="time" id="scheduler-ts-start" class="status-input" title="Set both to 00:00 for all-day" />
|
||||||
<label class="sch-label">Extra channels (virtual)
|
</label>
|
||||||
<div id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
|
<label class="sch-label">End (UTC)
|
||||||
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
|
<input type="time" id="scheduler-ts-end" class="status-input" title="Set both to 00:00 for all-day" />
|
||||||
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
|
</label>
|
||||||
<button id="scheduler-ts-extra-bm-add" type="button" class="sch-write" style="padding:0 0.7rem;">+</button>
|
<label class="sch-label" id="scheduler-ts-center-hz-wrap" title="SDR only — sets center frequency before tuning">Center freq (Hz, SDR)
|
||||||
</div>
|
<input type="number" id="scheduler-ts-center-hz" class="status-input" min="0" placeholder="optional" style="width:9rem;" />
|
||||||
</label>
|
</label>
|
||||||
<label class="sch-label">Label (optional)
|
<label class="sch-label">Primary bookmark
|
||||||
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
|
<select id="scheduler-ts-bookmark" class="status-input" aria-label="Entry bookmark"></select>
|
||||||
</label>
|
</label>
|
||||||
<label class="sch-label">Interleave (min, optional)
|
<label class="sch-label">Extra channels (virtual)
|
||||||
<input type="number" id="scheduler-ts-entry-interleave" class="status-input" min="1" max="60" placeholder="default" style="width:6rem;" />
|
<div id="scheduler-ts-extra-bm-list" class="sch-extra-bm-list"></div>
|
||||||
</label>
|
<div style="display:flex;gap:0.4rem;margin-top:0.3rem;">
|
||||||
<button id="scheduler-ts-add-btn" class="sch-write" type="button">+ Add</button>
|
<select id="scheduler-ts-extra-bm-pick" class="status-input" aria-label="Extra bookmark"></select>
|
||||||
|
<button id="scheduler-ts-extra-bm-add" type="button" class="sch-write" style="padding:0 0.7rem;">+</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Label (optional)
|
||||||
|
<input type="text" id="scheduler-ts-label" class="status-input" placeholder="e.g. 40m FT8" />
|
||||||
|
</label>
|
||||||
|
<label class="sch-label">Interleave (min, optional)
|
||||||
|
<input type="number" id="scheduler-ts-entry-interleave" class="status-input" min="1" max="60" placeholder="default" style="width:6rem;" />
|
||||||
|
</label>
|
||||||
|
<button id="scheduler-ts-add-btn" class="sch-write" type="button">+ Add</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="sch-actions">
|
<div class="sch-actions">
|
||||||
<button id="scheduler-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
|
<button id="scheduler-save-btn" class="sch-write sch-save-btn" type="button" style="display:none;">Save</button>
|
||||||
<button id="scheduler-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset to Disabled</button>
|
<button id="scheduler-reset-btn" class="sch-write sch-reset-btn" type="button" style="display:none;">Reset to Disabled</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="sch-section">
|
<div class="sch-section">
|
||||||
<div class="sch-section-title">Last Activity</div>
|
<div class="sch-section-title">Last Activity</div>
|
||||||
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
|
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1983,7 +1983,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.38rem;
|
padding: 0.38rem;
|
||||||
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
||||||
|
|||||||
Reference in New Issue
Block a user