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:
+123
-11
@@ -257,6 +257,14 @@ pub struct ChanoraSession {
|
||||
/// Release-tail timer (SDD-096). Drives the selector's
|
||||
/// `ptt_held` input from PTT key edges.
|
||||
release_tail: Arc<ReleaseTailTimer>,
|
||||
/// Last PTT binding the user requested via `set_ptt_binding`.
|
||||
/// Kept here so it survives the gap between user-saving a
|
||||
/// binding (which may happen before any audio is running) and
|
||||
/// the audio engine actually coming up. When the engine starts
|
||||
/// and creates the `PttController`, the controller is seeded
|
||||
/// from this value. Also persisted to the identity store so
|
||||
/// the binding survives app restarts.
|
||||
pending_binding: Arc<Mutex<Option<PttBinding>>>,
|
||||
}
|
||||
|
||||
impl ChanoraSession {
|
||||
@@ -282,6 +290,7 @@ impl ChanoraSession {
|
||||
bookmark_store: Arc::new(Mutex::new(None)),
|
||||
voice_selector: selector,
|
||||
release_tail,
|
||||
pending_binding: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +331,23 @@ impl ChanoraSession {
|
||||
}
|
||||
self.release_tail
|
||||
.set_tail_ms(store.get_release_tail_ms().min(chanora_audio::MAX_TAIL_MS));
|
||||
// Restore persisted PTT binding. The controller doesn't
|
||||
// exist yet (no audio engine running), so we stash the
|
||||
// binding in `pending_binding`; it gets applied when the
|
||||
// controller arms inside `start_audio`.
|
||||
let (input_class_s, platform_key, _key_label) = store.get_ptt_binding();
|
||||
if !input_class_s.is_empty() || !platform_key.is_empty() {
|
||||
let input_class = match input_class_s.as_str() {
|
||||
"keyboard" => PttInputClass::Keyboard,
|
||||
"mouse-side-button" => PttInputClass::MouseSideButton,
|
||||
_ => PttInputClass::None,
|
||||
};
|
||||
let binding = PttBinding {
|
||||
input_class,
|
||||
platform_key,
|
||||
};
|
||||
*self.pending_binding.lock().await = Some(binding);
|
||||
}
|
||||
*self.identity_store.lock().await = Some(store);
|
||||
*self.bookmark_store.lock().await = Some(bookmarks);
|
||||
info!(
|
||||
@@ -454,6 +480,7 @@ impl ChanoraSession {
|
||||
sup_inner.clone(),
|
||||
self.network_tx.subscribe(),
|
||||
self.voice_selector.clone(),
|
||||
self.pending_binding.clone(),
|
||||
));
|
||||
|
||||
let _ = self.events_tx.send(SessionEvent::Connected {
|
||||
@@ -525,6 +552,20 @@ impl ChanoraSession {
|
||||
let controller = ptt::PttController::new(gate);
|
||||
state.ptt_controller = Some(controller.clone());
|
||||
|
||||
// Apply any binding the user saved before audio was running
|
||||
// (SDD-094 follow-up). Persistence + caching happen in
|
||||
// `set_ptt_binding`; here we forward the cached value to
|
||||
// the freshly-armed controller so PTT actually fires.
|
||||
if let Some(pending) = self.pending_binding.lock().await.clone() {
|
||||
if let Err(e) = controller.set_binding(pending).await {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"applying pending PTT binding failed; controller stays at default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Record desired state so the supervisor will re-start audio
|
||||
// after a reconnect.
|
||||
{
|
||||
@@ -576,19 +617,48 @@ impl ChanoraSession {
|
||||
/// `PttBackendDescriptor` is broadcast as
|
||||
/// `SessionEvent::PttCapability` so the UI badge updates
|
||||
/// immediately. The audio engine must be running.
|
||||
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026).
|
||||
///
|
||||
/// This call must succeed even when the audio engine is not
|
||||
/// running — users may bind a key before they ever join a voice
|
||||
/// channel. We always persist the binding to the identity store,
|
||||
/// always store it in `pending_binding`, and only forward it
|
||||
/// live to the `PttController` when the controller exists. When
|
||||
/// audio next starts the controller picks up the pending binding
|
||||
/// (see `start_audio` / `voice_join`).
|
||||
pub async fn set_ptt_binding(&self, binding: PttBinding) -> Result<(), CoreError> {
|
||||
// 1. Persist to disk (best-effort; missing identity store
|
||||
// just means storage hasn't been wired yet).
|
||||
if let Some(store) = self.identity_store.lock().await.as_ref() {
|
||||
let class_s = match binding.input_class {
|
||||
PttInputClass::None => "",
|
||||
PttInputClass::Keyboard => "keyboard",
|
||||
PttInputClass::MouseSideButton => "mouse-side-button",
|
||||
};
|
||||
if let Err(e) =
|
||||
store.set_ptt_binding(class_s, &binding.platform_key, &binding.platform_key)
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"could not persist PTT binding"
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Stash for future controller arming.
|
||||
*self.pending_binding.lock().await = Some(binding.clone());
|
||||
// 3. Forward live if a controller exists.
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
let controller = state
|
||||
.ptt_controller
|
||||
.as_ref()
|
||||
.ok_or(CoreError::AudioNotStarted)?;
|
||||
let desc = controller.set_binding(binding).await?;
|
||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if let Some(controller) = state.ptt_controller.as_ref() {
|
||||
let desc = controller.set_binding(binding).await?;
|
||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -610,6 +680,31 @@ impl ChanoraSession {
|
||||
)
|
||||
}
|
||||
|
||||
/// Read the persisted PTT binding as `(input_class, key_label)`.
|
||||
/// Used by the Flutter side at launch so the badge and the
|
||||
/// mode line can show the user's saved hotkey before any audio
|
||||
/// engine has spun up. Empty strings indicate no binding has
|
||||
/// been saved yet.
|
||||
pub async fn get_ptt_binding(&self) -> (String, String) {
|
||||
// 1) Prefer the in-memory pending binding (most-recent
|
||||
// save, possibly not yet flushed to disk on a slow FS).
|
||||
if let Some(b) = self.pending_binding.lock().await.clone() {
|
||||
let class_s = match b.input_class {
|
||||
PttInputClass::None => "",
|
||||
PttInputClass::Keyboard => "keyboard",
|
||||
PttInputClass::MouseSideButton => "mouse-side-button",
|
||||
};
|
||||
return (class_s.to_string(), b.platform_key);
|
||||
}
|
||||
// 2) Fall back to the persisted file (case: app just
|
||||
// started, init_storage already ran).
|
||||
if let Some(store) = self.identity_store.lock().await.as_ref() {
|
||||
let (class_s, _platform_key, key_label) = store.get_ptt_binding();
|
||||
return (class_s, key_label);
|
||||
}
|
||||
(String::new(), String::new())
|
||||
}
|
||||
|
||||
/// Move our own client to `channel_id`. Optional channel
|
||||
/// `password` for password-protected channels (empty string
|
||||
/// counts as no password).
|
||||
@@ -887,6 +982,7 @@ async fn supervisor_loop(
|
||||
sup_inner: Arc<Mutex<SupervisorInner>>,
|
||||
mut network_rx: watch::Receiver<NetworkState>,
|
||||
voice_selector: Arc<TransmitModeSelector>,
|
||||
pending_binding: Arc<Mutex<Option<PttBinding>>>,
|
||||
) {
|
||||
let mut lost_rx = initial_lost_rx;
|
||||
let mut probe = initial_probe;
|
||||
@@ -1178,6 +1274,22 @@ async fn supervisor_loop(
|
||||
// the new engine's gate (SDD-088).
|
||||
let controller = ptt::PttController::new(gate);
|
||||
state.ptt_controller = Some(controller.clone());
|
||||
// Re-apply any persisted PTT binding
|
||||
// so reconnect doesn't silently drop
|
||||
// the user's hotkey.
|
||||
if let Some(pending) =
|
||||
pending_binding.lock().await.clone()
|
||||
{
|
||||
if let Err(e) =
|
||||
controller.set_binding(pending).await
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"re-applying PTT binding after reconnect failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = events_tx
|
||||
.send(SessionEvent::AudioStarted);
|
||||
// Re-publish the post-reconnect
|
||||
|
||||
Reference in New Issue
Block a user