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
+12
View File
@@ -518,6 +518,18 @@ pub async fn ptt_descriptor() -> (String, String, String) {
.unwrap_or_else(|_| (String::new(), String::new(), String::new()))
}
/// Return the persisted PTT binding as a
/// `(input_class, platform_key)` pair so the UI can hydrate its
/// display state at launch (e.g. show "PTT: Space" next to the
/// badge before the user re-opens the binding dialog). Empty
/// strings mean no binding has been persisted yet.
pub async fn get_ptt_binding() -> (String, String) {
runtime()
.spawn(async { session().get_ptt_binding().await })
.await
.unwrap_or_else(|_| (String::new(), String::new()))
}
/// Move our own client to `channel_id`. Optional channel password
/// for password-protected channels — pass an empty string when not
/// required.
+75 -22
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 651188866;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1306308591;
// Section: executor
@@ -326,6 +326,41 @@ fn wire__crate__api__export_diagnostics_impl(
},
)
}
fn wire__crate__api__get_ptt_binding_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "get_ptt_binding",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let output_ok = Result::<_, ()>::Ok(crate::api::get_ptt_binding().await)?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__get_release_tail_ms_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1406,6 +1441,15 @@ impl SseDecode for Vec<u8> {
}
}
impl SseDecode for (String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_field0 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::sse_decode(deserializer);
return (var_field0, var_field1);
}
}
impl SseDecode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1458,25 +1502,26 @@ fn pde_ffi_dispatcher_primary_impl(
5 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1490,8 +1535,8 @@ fn pde_ffi_dispatcher_sync_impl(
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
14 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
19 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
20 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2048,6 +2093,14 @@ impl SseEncode for Vec<u8> {
}
}
impl SseEncode for (String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.0, serializer);
<String>::sse_encode(self.1, serializer);
}
}
impl SseEncode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {