diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index fd330c2..23240f0 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -254,6 +254,7 @@ function applyAuthRestrictions() {
"settings-clear-ft2-history",
"settings-clear-wspr-history",
"settings-clear-sat-history",
+ "header-rec-btn",
"recorder-start-btn",
"recorder-stop-btn"
];
@@ -8862,11 +8863,12 @@ if (headerAudioToggle) {
headerAudioToggle.addEventListener("click", startRxAudio);
}
-// ── Recorder page ──────────────────────────────────────────────────────────
+// ── Recorder ───────────────────────────────────────────────────────────────
let recorderActive = false;
const recorderStartBtn = document.getElementById("recorder-start-btn");
const recorderStopBtn = document.getElementById("recorder-stop-btn");
const recorderStatusInd = document.getElementById("recorder-status-indicator");
+const headerRecBtn = document.getElementById("header-rec-btn");
function syncRecorderUi() {
if (recorderStartBtn) recorderStartBtn.disabled = recorderActive;
@@ -8875,27 +8877,27 @@ function syncRecorderUi() {
recorderStatusInd.textContent = recorderActive ? "Recording" : "";
recorderStatusInd.classList.toggle("rec-active", recorderActive);
}
- // Sync the tab icon indicator.
+ if (headerRecBtn) headerRecBtn.classList.toggle("rec-active", recorderActive);
const tabBtn = document.querySelector('.tab[data-tab="recorder"]');
if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive);
}
if (recorderStartBtn) {
recorderStartBtn.addEventListener("click", async () => {
- try {
- await postPath("/api/recorder/start");
- } catch (e) {
- console.error("Recorder start failed", e);
- }
+ try { await postPath("/api/recorder/start"); } catch (e) { console.error("Recorder start failed", e); }
});
}
if (recorderStopBtn) {
recorderStopBtn.addEventListener("click", async () => {
+ try { await postPath("/api/recorder/stop"); } catch (e) { console.error("Recorder stop failed", e); }
+ });
+}
+if (headerRecBtn) {
+ headerRecBtn.addEventListener("click", async () => {
try {
- await postPath("/api/recorder/stop");
- } catch (e) {
- console.error("Recorder stop failed", e);
- }
+ if (recorderActive) { await postPath("/api/recorder/stop"); }
+ else { await postPath("/api/recorder/start"); }
+ } catch (e) { console.error("Recorder toggle failed", e); }
});
}
@@ -8904,6 +8906,10 @@ window._syncRecorderState = function (enabled) {
syncRecorderUi();
};
+let _recorderFiles = [];
+let _recFilesPage = 0;
+const REC_PAGE_SIZE = 15;
+
async function refreshRecorderStatus() {
try {
const [statusResp, filesResp] = await Promise.all([
@@ -8915,8 +8921,8 @@ async function refreshRecorderStatus() {
renderRecorderActive(active);
}
if (filesResp.ok) {
- const files = await filesResp.json();
- renderRecorderFiles(files);
+ _recorderFiles = await filesResp.json();
+ renderRecorderFiles();
}
} catch (e) {
console.error("Recorder status fetch failed", e);
@@ -8940,22 +8946,109 @@ function renderRecorderActive(list) {
el.innerHTML = html;
}
-function renderRecorderFiles(list) {
+function recorderFormatSize(bytes) {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
+ return (bytes / 1048576).toFixed(1) + " MB";
+}
+
+function recFilterAndSort() {
+ const filterEl = document.getElementById("recorder-filter");
+ const sortEl = document.getElementById("recorder-sort");
+ const filter = (filterEl ? filterEl.value : "").toLowerCase();
+ const sortMode = sortEl ? sortEl.value : "name-desc";
+
+ let filtered = _recorderFiles;
+ if (filter) {
+ filtered = filtered.filter(function (f) {
+ return f.name.toLowerCase().includes(filter);
+ });
+ }
+
+ const sorted = filtered.slice();
+ switch (sortMode) {
+ case "name-asc": sorted.sort(function (a, b) { return a.name.localeCompare(b.name); }); break;
+ case "name-desc": sorted.sort(function (a, b) { return b.name.localeCompare(a.name); }); break;
+ case "size-asc": sorted.sort(function (a, b) { return a.size - b.size; }); break;
+ case "size-desc": sorted.sort(function (a, b) { return b.size - a.size; }); break;
+ }
+ return sorted;
+}
+
+function renderRecorderFiles() {
const el = document.getElementById("recorder-files-list");
if (!el) return;
- if (!list.length) {
- el.innerHTML = '
No recorded files.
';
+
+ const sorted = recFilterAndSort();
+ const total = sorted.length;
+ const totalPages = Math.max(1, Math.ceil(total / REC_PAGE_SIZE));
+ if (_recFilesPage >= totalPages) _recFilesPage = totalPages - 1;
+ if (_recFilesPage < 0) _recFilesPage = 0;
+ const start = _recFilesPage * REC_PAGE_SIZE;
+ const page = sorted.slice(start, start + REC_PAGE_SIZE);
+
+ const summaryEl = document.getElementById("rec-page-summary");
+ const indicatorEl = document.getElementById("rec-page-indicator");
+ const prevBtn = document.getElementById("rec-page-prev");
+ const nextBtn = document.getElementById("rec-page-next");
+
+ if (summaryEl) {
+ summaryEl.textContent = total ? "Showing " + (start + 1) + "-" + Math.min(start + REC_PAGE_SIZE, total) + " of " + total : "Showing 0-0 of 0";
+ }
+ if (indicatorEl) indicatorEl.textContent = "Page " + (_recFilesPage + 1) + " of " + totalPages;
+ if (prevBtn) prevBtn.disabled = _recFilesPage <= 0;
+ if (nextBtn) nextBtn.disabled = _recFilesPage >= totalPages - 1;
+
+ const filterEl = document.getElementById("recorder-filter");
+ const filter = filterEl ? filterEl.value : "";
+
+ if (!page.length) {
+ el.innerHTML = '' + (filter ? "No files match filter." : "No recorded files.") + "
";
return;
}
- let html = '| File | Size |
';
- for (const f of list) {
- const size = f.size < 1048576 ? (f.size / 1024).toFixed(1) + " KB" : (f.size / 1048576).toFixed(1) + " MB";
- html += `| ${escapeMapHtml(f.name)} | ${size} |
`;
+
+ let html = '| File | Size | |
';
+ for (const f of page) {
+ const safeName = escapeMapHtml(f.name);
+ const encodedName = encodeURIComponent(f.name);
+ html += ""
+ + "| " + safeName + " | "
+ + "" + recorderFormatSize(f.size) + " | "
+ + ' |
";
}
html += "
";
el.innerHTML = html;
+
+ el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
+ btn.addEventListener("click", async function () {
+ const name = btn.dataset.name;
+ if (!confirm("Delete recording " + name + "?")) return;
+ try {
+ const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ _recorderFiles = _recorderFiles.filter(function (f) { return f.name !== name; });
+ renderRecorderFiles();
+ } catch (e) {
+ console.error("Delete failed", e);
+ }
+ });
+ });
}
+(function () {
+ const filterEl = document.getElementById("recorder-filter");
+ const sortEl = document.getElementById("recorder-sort");
+ if (filterEl) filterEl.addEventListener("input", function () { _recFilesPage = 0; renderRecorderFiles(); });
+ if (sortEl) sortEl.addEventListener("change", function () { _recFilesPage = 0; renderRecorderFiles(); });
+ const prevBtn = document.getElementById("rec-page-prev");
+ const nextBtn = document.getElementById("rec-page-next");
+ if (prevBtn) prevBtn.addEventListener("click", function () { _recFilesPage--; renderRecorderFiles(); });
+ if (nextBtn) nextBtn.addEventListener("click", function () { _recFilesPage++; renderRecorderFiles(); });
+})();
+
const rxVolPct = document.getElementById("rx-vol-pct");
const txVolPct = document.getElementById("tx-vol-pct");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 0692a19..0ae31f7 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -68,6 +68,7 @@
+
@@ -1076,9 +1077,26 @@
Recorded files
+
+
+
+
+
+
Showing 0-0 of 0
+
+
+ Page 1 of 1
+
+
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 44f4bdb..7004617 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -1118,6 +1118,7 @@ small { color: var(--text-muted); }
align-items: center;
gap: 0.6rem;
min-width: 0;
+ overflow: hidden;
}
.header-bar-btn.header-audio-btn {
width: 2rem;
@@ -1142,6 +1143,26 @@ small { color: var(--text-muted); }
color: #00d17f;
border-color: #00d17f;
}
+.header-bar-btn.header-rec-btn {
+ height: 2rem;
+ min-height: 0;
+ padding: 0 0.45rem;
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ border: 1px solid var(--border-light);
+ border-radius: 6px;
+ background: var(--input-bg);
+ color: var(--text-muted);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.header-rec-btn.rec-active {
+ color: #ff3b30;
+ border-color: #ff3b30;
+ background: rgba(255, 59, 48, 0.12);
+ animation: rec-pulse 1.5s ease-in-out infinite;
+}
/* ── Recorder page ──────────────────────────────────────────────────────── */
.recorder-controls-bar {
display: flex;
@@ -1177,6 +1198,14 @@ small { color: var(--text-muted); }
50% { opacity: 0.6; }
}
.tab.rec-active .tab-icon { color: #ff3b30; }
+.recorder-filter-bar {
+ display: flex;
+ gap: 0.6rem;
+ align-items: center;
+ margin-bottom: 0.6rem;
+}
+.recorder-filter-input { flex: 1; min-width: 0; max-width: 22rem; }
+.recorder-sort-select { width: auto; min-width: 10rem; }
.recorder-section { margin-bottom: 1.5rem; }
.recorder-section-heading {
font-size: 0.85rem;
@@ -1204,6 +1233,28 @@ small { color: var(--text-muted); }
text-transform: uppercase;
letter-spacing: 0.03em;
}
+.recorder-table .rec-file-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+.recorder-table .rec-file-btn {
+ padding: 0.2rem 0.5rem;
+ font-size: 0.75rem;
+ border: 1px solid var(--border-light);
+ border-radius: 4px;
+ background: var(--input-bg);
+ color: var(--text-primary);
+ cursor: pointer;
+}
+.recorder-table .rec-file-btn:hover { border-color: var(--accent-green); color: var(--accent-green); }
+.recorder-table .rec-file-btn.rec-delete-btn:hover { border-color: #ff3b30; color: #ff3b30; }
+.recorder-page-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.8rem;
+ padding: 0.9rem 0.2rem 0;
+}
.header-rig-switch {
display: flex;
align-items: center;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
index 447ad6b..e865973 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs
@@ -612,6 +612,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(recorder::recorder_stop)
.service(recorder::recorder_status)
.service(recorder::recorder_files)
+ .service(recorder::recorder_download)
+ .service(recorder::recorder_delete)
// Static assets
.service(assets::index)
.service(assets::map_index)
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs
index 94b19d1..4979d41 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/recorder.rs
@@ -6,7 +6,9 @@
use std::sync::Arc;
-use actix_web::{get, post, web, Error, HttpResponse};
+use actix_web::http::header;
+use actix_web::{delete, get, post, web, Error, HttpResponse};
+use bytes::Bytes;
use tokio::sync::{mpsc, watch};
use trx_core::{RigCommand, RigState};
@@ -133,6 +135,43 @@ pub async fn recorder_files(
Ok(HttpResponse::Ok().json(files))
}
+/// Download a recorded file.
+#[get("/api/recorder/download/{filename}")]
+pub async fn recorder_download(
+ path: web::Path
,
+ recorder_mgr: web::Data>,
+) -> Result {
+ let filename = path.into_inner();
+ let file_path = recorder_mgr
+ .file_path(&filename)
+ .map_err(actix_web::error::ErrorNotFound)?;
+
+ let data = tokio::fs::read(&file_path)
+ .await
+ .map_err(|e| actix_web::error::ErrorInternalServerError(format!("read error: {e}")))?;
+
+ Ok(HttpResponse::Ok()
+ .insert_header((header::CONTENT_TYPE, "audio/ogg"))
+ .insert_header((
+ header::CONTENT_DISPOSITION,
+ format!("attachment; filename=\"{filename}\""),
+ ))
+ .body(Bytes::from(data)))
+}
+
+/// Delete a recorded file.
+#[delete("/api/recorder/files/{filename}")]
+pub async fn recorder_delete(
+ path: web::Path,
+ recorder_mgr: web::Data>,
+) -> Result {
+ let filename = path.into_inner();
+ match recorder_mgr.delete_file(&filename) {
+ Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": filename }))),
+ Err(e) => Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": e }))),
+ }
+}
+
// ============================================================================
// Helpers
// ============================================================================
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs
index 2c306fd..1f6ac4f 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/recorder.rs
@@ -411,6 +411,35 @@ impl RecorderManager {
files
}
+ /// Resolve and validate a filename, returning the full path.
+ ///
+ /// Rejects path traversal attempts and files outside the output directory.
+ fn validate_filename(&self, filename: &str) -> Result {
+ if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
+ return Err("invalid filename".into());
+ }
+ if !filename.ends_with(".ogg") {
+ return Err("only .ogg files are accessible".into());
+ }
+ let dir = self.config.resolve_output_dir();
+ let path = dir.join(filename);
+ if !path.exists() {
+ return Err(format!("file not found: {filename}"));
+ }
+ Ok(path)
+ }
+
+ /// Get the full path to a recorded file for download.
+ pub fn file_path(&self, filename: &str) -> Result {
+ self.validate_filename(filename)
+ }
+
+ /// Delete a recorded file.
+ pub fn delete_file(&self, filename: &str) -> Result<(), String> {
+ let path = self.validate_filename(filename)?;
+ std::fs::remove_file(&path).map_err(|e| format!("failed to delete: {e}"))
+ }
+
/// Check if a recording is active for the given key.
pub fn is_recording(&self, rig_id: &str, vchan_id: Option<&str>) -> bool {
let key = Self::make_key(rig_id, vchan_id);