fix(audio,storage,ui): allow PTT binding before audio is running

Save-binding before joining a voice channel used to return
BridgeError.invalidCommand(audio not started) because the
PttController only exists after start_audio runs and
set_ptt_binding required a live controller. Users naturally want
to bind their PTT key once on first launch, not every time they
join a channel — fix:

* chanora_storage::IdentityFileStore::set_ptt_binding /
  get_ptt_binding persist the privacy-safe binding triple
  (input_class, platform_key, key_label) into audio_meta.json
  next to transmit_mode and release_tail_ms.
* ChanoraSession holds pending_binding: Arc<Mutex<Option<PttBinding>>>.
  set_ptt_binding now (1) persists to storage best-effort, (2)
  stashes into pending_binding, (3) forwards live to the
  controller only if one exists. No more AudioNotStarted.
* init_storage loads the persisted binding into pending_binding
  so it survives app restarts.
* start_audio applies pending_binding immediately after constructing
  the PttController so the first key-press after join already works.
* supervisor_loop carries pending_binding and re-applies it after
  any reconnect-driven audio engine restart, so reconnects don't
  silently drop the hotkey.
* New bridge call get_ptt_binding() -> (input_class, key_label) plus
  a matching Flutter _hydratePttBinding() in initState lets the
  Voice Bar show the user's saved hotkey label on launch (e.g.
  'PTT: Space') before any voice channel is joined.

cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
FRB bindings regenerated.
This commit is contained in:
EdisonJwa
2026-05-15 23:45:20 +08:00
parent 6a41a0b4db
commit 8cd919cffc
9 changed files with 394 additions and 55 deletions
+46
View File
@@ -102,6 +102,20 @@ struct AudioMeta {
/// Release-tail in milliseconds (SDD-096). Default 200.
#[serde(default = "default_release_tail_ms")]
release_tail_ms: u32,
/// Bound PTT input class as the privacy-safe string accepted by
/// the bridge (`""`, `"keyboard"`, `"mouse-side-button"`).
/// Default empty (no binding).
#[serde(default)]
ptt_input_class: String,
/// Opaque platform key string the binding dialog produced
/// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Default
/// empty.
#[serde(default)]
ptt_platform_key: String,
/// Display-only platform-neutral key label the UI shows next
/// to the binding (e.g. `"Space"`). Default empty.
#[serde(default)]
ptt_key_label: String,
}
fn default_release_tail_ms() -> u32 {
@@ -113,6 +127,9 @@ impl Default for AudioMeta {
Self {
transmit_mode: 0,
release_tail_ms: 200,
ptt_input_class: String::new(),
ptt_platform_key: String::new(),
ptt_key_label: String::new(),
}
}
}
@@ -505,6 +522,35 @@ impl IdentityFileStore {
self.read_meta().release_tail_ms
}
/// Persist the user's PTT binding (SDD-094 follow-up). The
/// three fields together carry the privacy-safe binding
/// surface — `input_class` is a stable category string
/// (`""`, `"keyboard"`, `"mouse-side-button"`), `platform_key`
/// is the opaque key identifier the platform backend
/// understands, and `key_label` is the display string the UI
/// renders next to the binding. None of these are raw key
/// codes or scan codes per DEC-027.
pub fn set_ptt_binding(
&self,
input_class: &str,
platform_key: &str,
key_label: &str,
) -> Result<(), StorageError> {
let mut m = self.read_meta();
m.ptt_input_class = input_class.to_string();
m.ptt_platform_key = platform_key.to_string();
m.ptt_key_label = key_label.to_string();
self.write_meta(&m)
}
/// Read the persisted PTT binding. Returns
/// `(input_class, platform_key, key_label)` with empty strings
/// meaning "no binding".
pub fn get_ptt_binding(&self) -> (String, String, String) {
let m = self.read_meta();
(m.ptt_input_class, m.ptt_platform_key, m.ptt_key_label)
}
/// Remove any persisted identity. No-op if none exists. Leaves
/// the DEK in place so future saves don't generate a new one.
pub fn clear(&self) -> Result<(), StorageError> {