From 7d5d8c2c9051ef93d135e4371d2c55ef399e6ba4 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sat, 23 May 2026 06:51:55 +0900 Subject: [PATCH] feat: integrate chat voice and diagnostics client --- Cargo.lock | 48 +- .../android/app/build.gradle.kts | 35 +- .../android/app/proguard-rules.pro | 23 +- .../android/app/src/main/AndroidManifest.xml | 15 +- .../AndroidPermissionRequester.kt | 10 +- .../AndroidVoiceForegroundService.kt | 36 +- .../src/main/res/values-zh-rCN/strings.xml | 9 + .../app/src/main/res/values/strings.xml | 10 + .../lib/design/chanora_tokens.dart | 185 +++ .../lib/design/platform_capabilities.dart | 196 +++ apps/chanora_flutter/lib/l10n/app_en.arb | 21 +- apps/chanora_flutter/lib/l10n/app_zh.arb | 21 +- .../lib/l10n/generated/app_localizations.dart | 48 +- .../l10n/generated/app_localizations_en.dart | 25 +- .../l10n/generated/app_localizations_zh.dart | 24 +- apps/chanora_flutter/lib/main.dart | 369 +++-- .../android_audio_output_devices.dart | 40 + .../lib/services/app_bootstrap.dart | 97 ++ .../lib/services/audio_lifecycle_service.dart | 107 ++ .../services/channel_join_error_mapper.dart | 39 + .../lib/services/channel_spacer.dart | 243 ++++ .../lib/services/connection_phase_state.dart | 79 ++ .../lib/services/link_trust_service.dart | 9 +- .../lib/services/snapshot_state_mapper.dart | 65 + .../lib/services/ts3_server_link.dart | 92 ++ .../lib/services/ui_preferences_service.dart | 18 +- apps/chanora_flutter/lib/src/rust/api.dart | 249 +++- .../lib/src/rust/api.freezed.dart | 578 +++++++- .../lib/src/rust/frb_generated.dart | 758 ++++++++-- .../lib/src/rust/frb_generated.io.dart | 151 +- .../lib/src/rust/frb_generated.web.dart | 151 +- .../lib/widgets/audio_device_list_tile.dart | 128 ++ .../lib/widgets/audio_output_tile.dart | 567 ++++++++ .../audio_processing_config_state.dart | 147 ++ .../lib/widgets/bbcode_text.dart | 144 +- .../lib/widgets/chat_views.dart | 557 ++++---- .../lib/widgets/connect_widgets.dart | 197 +++ .../lib/widgets/input_dialogs.dart | 279 ++++ .../lib/widgets/snapshot_view.dart | 665 +++++++++ .../lib/widgets/talk_power_warning.dart | 60 + .../lib/widgets/voice_bar.dart | 517 +++---- .../lib/widgets/voice_compact.dart | 1234 ++++------------- .../lib/widgets/voice_haptics.dart | 16 + .../lib/widgets/voice_level_meter.dart | 32 + .../lib/widgets/voice_settings.dart | 393 ++---- .../lib/widgets/voice_settings_controls.dart | 195 +++ .../lib/widgets/voice_status_summary.dart | 89 ++ apps/chanora_flutter/pubspec.lock | 40 +- apps/chanora_flutter/pubspec.yaml | 4 +- .../android_audio_output_devices_test.dart | 51 + .../test/services/app_bootstrap_test.dart | 22 + .../audio_lifecycle_service_test.dart | 25 + .../channel_join_error_mapper_test.dart | 69 + .../test/services/channel_spacer_test.dart | 127 ++ .../services/connection_phase_state_test.dart | 81 ++ .../services/snapshot_state_mapper_test.dart | 152 ++ .../test/services/ts3_server_link_test.dart | 44 + .../services/ui_preferences_service_test.dart | 16 - apps/chanora_flutter/test/widget_test.dart | 28 + .../widgets/audio_device_list_tile_test.dart | 32 + .../audio_processing_config_state_test.dart | 81 ++ .../test/widgets/bbcode_text_test.dart | 53 + .../test/widgets/chat_views_test.dart | 81 +- .../test/widgets/snapshot_view_test.dart | 437 ++++++ .../test/widgets/talk_power_warning_test.dart | 73 + .../widgets/voice_settings_controls_test.dart | 85 ++ .../widgets/voice_status_summary_test.dart | 106 ++ .../windows/flutter/generated_plugins.cmake | 1 + core/chanora_core/src/lib.rs | 433 ++++-- crates/chanora_audio/Cargo.toml | 9 +- .../chanora_audio/benches/realtime_capture.rs | 2 +- .../chanora_audio/src/android_voice_unit.rs | 130 +- crates/chanora_audio/src/audio_processing.rs | 14 +- crates/chanora_audio/src/engine.rs | 402 +++--- crates/chanora_audio/src/ios_raw_unit.rs | 105 +- crates/chanora_audio/src/ios_voice_unit.rs | 114 +- crates/chanora_audio/src/lib.rs | 3 +- .../chanora_audio/src/mobile_voice_backend.rs | 65 +- crates/chanora_audio/src/sdl_output.rs | 9 - crates/chanora_audio/src/vad/silero_onnx.rs | 23 +- crates/chanora_audio/src/vad/ten_onnx.rs | 84 +- crates/chanora_bridge/Cargo.toml | 1 - crates/chanora_bridge/src/api.rs | 289 +++- crates/chanora_bridge/src/frb_generated.rs | 729 ++++++++-- crates/chanora_bridge/src/lib.rs | 9 +- crates/chanora_diagnostics/src/lib.rs | 117 ++ crates/chanora_protocol/Cargo.toml | 1 + crates/chanora_protocol/src/adapter.rs | 167 ++- crates/chanora_protocol/src/dto.rs | 33 + crates/chanora_protocol/src/lib.rs | 4 +- crates/chanora_state/Cargo.toml | 1 - crates/chanora_state/src/channel_join.rs | 4 +- crates/chanora_storage/src/lib.rs | 293 ++-- 93 files changed, 10273 insertions(+), 3347 deletions(-) create mode 100644 apps/chanora_flutter/android/app/src/main/res/values-zh-rCN/strings.xml create mode 100644 apps/chanora_flutter/android/app/src/main/res/values/strings.xml create mode 100644 apps/chanora_flutter/lib/design/chanora_tokens.dart create mode 100644 apps/chanora_flutter/lib/design/platform_capabilities.dart create mode 100644 apps/chanora_flutter/lib/services/android_audio_output_devices.dart create mode 100644 apps/chanora_flutter/lib/services/app_bootstrap.dart create mode 100644 apps/chanora_flutter/lib/services/audio_lifecycle_service.dart create mode 100644 apps/chanora_flutter/lib/services/channel_join_error_mapper.dart create mode 100644 apps/chanora_flutter/lib/services/channel_spacer.dart create mode 100644 apps/chanora_flutter/lib/services/connection_phase_state.dart create mode 100644 apps/chanora_flutter/lib/services/snapshot_state_mapper.dart create mode 100644 apps/chanora_flutter/lib/services/ts3_server_link.dart create mode 100644 apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart create mode 100644 apps/chanora_flutter/lib/widgets/audio_output_tile.dart create mode 100644 apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart create mode 100644 apps/chanora_flutter/lib/widgets/connect_widgets.dart create mode 100644 apps/chanora_flutter/lib/widgets/input_dialogs.dart create mode 100644 apps/chanora_flutter/lib/widgets/snapshot_view.dart create mode 100644 apps/chanora_flutter/lib/widgets/talk_power_warning.dart create mode 100644 apps/chanora_flutter/lib/widgets/voice_haptics.dart create mode 100644 apps/chanora_flutter/lib/widgets/voice_level_meter.dart create mode 100644 apps/chanora_flutter/lib/widgets/voice_settings_controls.dart create mode 100644 apps/chanora_flutter/lib/widgets/voice_status_summary.dart create mode 100644 apps/chanora_flutter/test/services/android_audio_output_devices_test.dart create mode 100644 apps/chanora_flutter/test/services/app_bootstrap_test.dart create mode 100644 apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart create mode 100644 apps/chanora_flutter/test/services/channel_join_error_mapper_test.dart create mode 100644 apps/chanora_flutter/test/services/channel_spacer_test.dart create mode 100644 apps/chanora_flutter/test/services/connection_phase_state_test.dart create mode 100644 apps/chanora_flutter/test/services/snapshot_state_mapper_test.dart create mode 100644 apps/chanora_flutter/test/services/ts3_server_link_test.dart create mode 100644 apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart create mode 100644 apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart create mode 100644 apps/chanora_flutter/test/widgets/bbcode_text_test.dart create mode 100644 apps/chanora_flutter/test/widgets/snapshot_view_test.dart create mode 100644 apps/chanora_flutter/test/widgets/talk_power_warning_test.dart create mode 100644 apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart create mode 100644 apps/chanora_flutter/test/widgets/voice_status_summary_test.dart diff --git a/Cargo.lock b/Cargo.lock index da608c9..c5a90e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,7 +432,7 @@ dependencies = [ "oboe", "ort", "rand 0.8.6", - "reqwest", + "rustfft", "sdl2", "serde_json", "sonora", @@ -452,7 +452,6 @@ version = "0.2.0-beta.1" dependencies = [ "chanora_audio", "chanora_core", - "chanora_protocol", "flutter_rust_bridge", "jni 0.21.1", "log", @@ -501,6 +500,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "ts-bookkeeping", "tsclientlib", "tsproto-packets", "tsproto-types", @@ -511,7 +511,6 @@ name = "chanora_state" version = "0.2.0-beta.1" dependencies = [ "thiserror 2.0.18", - "tracing", ] [[package]] @@ -2561,7 +2560,7 @@ dependencies = [ [[package]] name = "oboe" -version = "0.6.2" +version = "0.6.3" dependencies = [ "num-derive", "num-traits", @@ -2570,7 +2569,7 @@ dependencies = [ [[package]] name = "oboe-sys" -version = "0.6.2" +version = "0.6.3" dependencies = [ "cc", ] @@ -2872,6 +2871,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -3259,6 +3267,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3727,6 +3749,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "subtle" version = "2.6.1" @@ -4192,6 +4220,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/apps/chanora_flutter/android/app/build.gradle.kts b/apps/chanora_flutter/android/app/build.gradle.kts index ed07d76..ab3cfb9 100644 --- a/apps/chanora_flutter/android/app/build.gradle.kts +++ b/apps/chanora_flutter/android/app/build.gradle.kts @@ -69,8 +69,8 @@ android { // Per-ABI delivery is handled by the AAB bundle splits below // (SDD-109 item 2) rather than a fat APK. ndk { - // DEC-032 RESOLVED (2026-05-18): canonical three-ABI set - // restored after the audiopus_sys + cmake-rs + NDK toolchain-file + // DEC-032 RESOLVED (2026-05-18): canonical two-ABI set + // (arm64-v8a + x86_64) restored after the audiopus_sys // ANDROID_ABI propagation gap was closed by the workspace // [patch.crates-io] override pinning `cmake` to the fork // carrying cmake-rs PR #257 (forwards ANDROID_ABI / @@ -138,9 +138,10 @@ android { } // SDD-109 item 2: Android App Bundle (.aab) split configuration. - // Combined with the SDD-073 abiFilters set, this yields three - // native splits (arm64-v8a, armeabi-v7a, x86_64), per-language - // resource delivery, and per-density resource delivery. + // Combined with the SDD-073 abiFilters set, this yields two + // native splits (arm64-v8a, x86_64), per-language resource + // delivery, and per-density resource delivery. Each device + // receives only its matching ABI .so — no cross-ABI bundling. bundle { language { enableSplit = true @@ -221,7 +222,6 @@ val chanoraAndroidMinSdk: Provider = // for the per-ABI copy and for the inputs/outputs declaration. val abiToRustTriple: Map = mapOf( "arm64-v8a" to "aarch64-linux-android", - "armeabi-v7a" to "armv7-linux-androideabi", "x86_64" to "x86_64-linux-android", ) @@ -278,7 +278,7 @@ val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") { if (missing.isNotEmpty()) { throw GradleException( "[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " + - "Install: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android" + "Install: rustup target add aarch64-linux-android x86_64-linux-android" ) } } @@ -312,6 +312,15 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> { .takeUnless { it.isNullOrBlank() } ?: android.ndkDirectory.absolutePath val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake" + val homeDir = System.getenv("HOME") ?: System.getProperty("user.home") + val rustupBinDir = listOf( + "$homeDir/.cargo/bin", + "/opt/homebrew/opt/rustup/bin", + "/usr/local/opt/rustup/bin" + ).firstOrNull { File(it).resolve("cargo").exists() } + val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup" + val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo" + val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin" // SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an // isolated `cargo ndk -t ... -- build ...` so ANDROID_ABI is set @@ -361,15 +370,18 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> { // Any CARGO_TARGET_* env vars set by an outer build wrapper val cleanEnv = buildMap { // Pass-through from the calling environment, only the safe set. - System.getenv("PATH")?.let { put("PATH", it) } - System.getenv("HOME")?.let { put("HOME", it) } + System.getenv("PATH")?.let { path -> + put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":")) + } + put("HOME", homeDir) System.getenv("USER")?.let { put("USER", it) } System.getenv("TMPDIR")?.let { put("TMPDIR", it) } System.getenv("TEMP")?.let { put("TEMP", it) } System.getenv("LANG")?.let { put("LANG", it) } System.getenv("LC_ALL")?.let { put("LC_ALL", it) } - System.getenv("CARGO_HOME")?.let { put("CARGO_HOME", it) } - System.getenv("RUSTUP_HOME")?.let { put("RUSTUP_HOME", it) } + put("CARGO_HOME", cargoHome) + put("RUSTUP_HOME", rustupHome) + put("RUSTUP_TOOLCHAIN", rustupToolchain) System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) } // Per-task explicit settings (override any pass-through). // SDD-118 item 5: audiopus_sys needs libopus built from source @@ -444,7 +456,6 @@ val buildRustBridgeRelease = registerCargoNdkBuildTask("release") // $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/. val abiToNdkSysrootTriple: Map = mapOf( "arm64-v8a" to "aarch64-linux-android", - "armeabi-v7a" to "arm-linux-androideabi", "x86_64" to "x86_64-linux-android", ) fun registerJniLibsCopyTask(profile: String): TaskProvider { diff --git a/apps/chanora_flutter/android/app/proguard-rules.pro b/apps/chanora_flutter/android/app/proguard-rules.pro index 2310487..2cfa07c 100644 --- a/apps/chanora_flutter/android/app/proguard-rules.pro +++ b/apps/chanora_flutter/android/app/proguard-rules.pro @@ -11,8 +11,8 @@ # the app module — these are looked up by signature from C/Rust. # 3. The Android voice foreground service (SDD-107) — referenced from # the manifest by FQN and from JNI via static start/stop helpers. -# 4. The Android audio mode controller (SDD-108) — referenced from -# JNI via GlobalRef. +# 4. The Android audio mode controller (SDD-108) — audio mode is +# managed in Rust via direct JNI (engine.rs), not a Kotlin class. # 5. Kotlin metadata required by FRB-generated bindings (SDD-079). # --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) ----------- @@ -46,21 +46,18 @@ } # --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------ -# The service is referenced by FQN from AndroidManifest.xml and by JNI from -# the Rust audio engine (start/stop helpers per SDD-107 item 10). Keep the -# class and its public/static members. --keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; } +# Covered by the JNI-surface keep block above. -# --- 4. AndroidAudioModeController (SDD-108) --------------------------------- -# Referenced from Rust via GlobalRef + static method invocation. -# Wave 2B-2 owns the class; keep rule is forward-looking but harmless if -# the class does not yet exist (R8 ignores keep rules for missing classes). --keep class app.chanora.chanora_flutter.AndroidAudioModeController { *; } +# --- 4. Android Audio Mode Controller (SDD-108) ------------------------- +# SDD-108 in-call audio mode (AudioManager.setMode) is implemented in the +# Rust audio engine (crates/chanora_audio/src/engine.rs) via direct JNI, +# not through a Kotlin controller class. No keep rule is needed; the +# engine.rs android_set_audio_mode / android_get_audio_mode helpers +# resolve AudioManager at runtime via ndk_context. # --- 5. Kotlin metadata + serialisation (SDD-079) ---------------------------- -keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod -keep class kotlin.Metadata { *; } # Defer to Flutter's own keep rules for the embedding layer; the Flutter -# Gradle plugin contributes those automatically. If a future SDD revision -# disables R8 entirely, this file can be reduced to a single TODO comment. +# Gradle plugin contributes those automatically. diff --git a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml index b3200f3..766ac15 100644 --- a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,18 @@ protocol layer to dial a TeamSpeak-compatible server over UDP. --> + + + + + + + + + + 语音会话 + Chanora 语音会话处于活动状态时显示。 + Chanora — 语音会话进行中 + 麦克风可能正在使用中。 + diff --git a/apps/chanora_flutter/android/app/src/main/res/values/strings.xml b/apps/chanora_flutter/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..27fd04b --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + Voice session + Shown while a Chanora voice session is active. + Chanora — Voice session active + Microphone may be in use. + diff --git a/apps/chanora_flutter/lib/design/chanora_tokens.dart b/apps/chanora_flutter/lib/design/chanora_tokens.dart new file mode 100644 index 0000000..5e4233d --- /dev/null +++ b/apps/chanora_flutter/lib/design/chanora_tokens.dart @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Chanora design tokens for connection, voice, and latency states. +// Maps abstract server/voice/network state to Material 3 colour, icon, +// and label affordances. + +import 'package:flutter/material.dart'; + +/// Tokenised visual mapping for a connection state. +class ConnectionTokens { + final Color color; + final Color? background; + final IconData icon; + final String label; + + const ConnectionTokens({ + required this.color, + required this.icon, + required this.label, + this.background, + }); + + static ConnectionTokens disconnected(ColorScheme cs) => ConnectionTokens( + color: cs.onSurfaceVariant, + background: cs.surfaceContainerHighest, + icon: Icons.cloud_off, + label: 'Disconnected', + ); + + static ConnectionTokens connecting(ColorScheme cs) => ConnectionTokens( + color: cs.primary, + background: cs.primaryContainer, + icon: Icons.sync, + label: 'Connecting', + ); + + static ConnectionTokens synchronizing(ColorScheme cs) => ConnectionTokens( + color: cs.secondary, + background: cs.secondaryContainer, + icon: Icons.hourglass_top, + label: 'Synchronizing', + ); + + static ConnectionTokens connected(ColorScheme cs) => ConnectionTokens( + color: cs.tertiary, + background: cs.tertiaryContainer, + icon: Icons.cloud_done, + label: 'Connected', + ); + + static ConnectionTokens reconnecting(ColorScheme cs) => ConnectionTokens( + color: cs.error, + background: cs.errorContainer, + icon: Icons.restart_alt, + label: 'Reconnecting', + ); + + static ConnectionTokens lost(ColorScheme cs) => ConnectionTokens( + color: cs.error, + background: cs.errorContainer, + icon: Icons.warning_amber, + label: 'Connection Lost', + ); +} + +/// Tokenised visual mapping for a voice transmit/mute state. +class VoiceTokens { + final Color color; + final Color? background; + final IconData icon; + + const VoiceTokens({ + required this.color, + required this.icon, + this.background, + }); + + static VoiceTokens idle(ColorScheme cs) => VoiceTokens( + color: cs.onSurfaceVariant, + icon: Icons.mic_none, + ); + + static VoiceTokens pttHeld(ColorScheme cs) => VoiceTokens( + color: cs.primary, + background: cs.primaryContainer, + icon: Icons.mic, + ); + + static VoiceTokens voiceActivity(ColorScheme cs) => VoiceTokens( + color: cs.tertiary, + background: cs.tertiaryContainer, + icon: Icons.mic, + ); + + static VoiceTokens continuousTx(ColorScheme cs) => VoiceTokens( + color: cs.primary, + background: cs.primaryContainer, + icon: Icons.settings_voice, + ); + + static VoiceTokens muted(ColorScheme cs) => VoiceTokens( + color: cs.error, + background: cs.errorContainer, + icon: Icons.mic_off, + ); + + static VoiceTokens outputMuted(ColorScheme cs) => VoiceTokens( + color: cs.error, + icon: Icons.headset_off, + ); + + static VoiceTokens deafened(ColorScheme cs) => VoiceTokens( + color: cs.error, + background: cs.errorContainer, + icon: Icons.hearing_disabled, + ); +} + +/// Tokenised visual mapping for network latency/quality. +class LatencyTokens { + final Color color; + final String label; + + const LatencyTokens({required this.color, required this.label}); + + static LatencyTokens good(ColorScheme cs) => LatencyTokens( + color: cs.tertiary, + label: 'Good', + ); + + static LatencyTokens warning(ColorScheme cs) => LatencyTokens( + color: cs.error, + label: 'Warning', + ); + + static LatencyTokens unknown(ColorScheme cs) => LatencyTokens( + color: cs.outlineVariant, + label: '—', + ); +} + +/// Tokens for channel membership status. +class ChannelTokens { + final IconData? icon; + final String? label; + + const ChannelTokens({this.icon, this.label}); + + static ChannelTokens inChannel(ColorScheme cs) => ChannelTokens( + icon: Icons.headset_mic, + label: 'Connected', + ); + + static ChannelTokens joining(ColorScheme cs) => ChannelTokens( + icon: Icons.login, + label: 'Joining...', + ); + + static ChannelTokens notInChannel(ColorScheme cs) => ChannelTokens( + icon: null, + label: null, + ); +} + +/// Tokens for PTT capability display. +class PttTokens { + final String label; + final Color? color; + + const PttTokens({required this.label, this.color}); + + static PttTokens focused(ColorScheme cs) => PttTokens( + label: 'Keyboard PTT', + color: cs.tertiary, + ); + + static PttTokens platformHook(ColorScheme cs) => PttTokens( + label: 'Global PTT', + color: cs.primary, + ); + + static PttTokens none(ColorScheme cs) => PttTokens( + label: 'No PTT input', + color: cs.error, + ); +} diff --git a/apps/chanora_flutter/lib/design/platform_capabilities.dart b/apps/chanora_flutter/lib/design/platform_capabilities.dart new file mode 100644 index 0000000..e5cbcbb --- /dev/null +++ b/apps/chanora_flutter/lib/design/platform_capabilities.dart @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// SRS-115: Unsupported/degraded platform behaviours documentation. +// Describes what is available, degraded, or unavailable per platform. + +import 'dart:io' show Platform; +import 'package:flutter/foundation.dart' show kIsWeb; + +/// Describes capability tier for a feature on a platform. +enum CapabilityTier { supported, degraded, unavailable } + +/// A single platform capability description. +class PlatformCapability { + final String feature; + final String description; + final CapabilityTier tier; + + const PlatformCapability({ + required this.feature, + required this.description, + required this.tier, + }); +} + +/// Returns the current platform's capability matrix. +List currentPlatformCapabilities() { + if (kIsWeb) { + return [ + PlatformCapability( + feature: 'Audio', + description: 'Web audio is not supported in the beta.', + tier: CapabilityTier.unavailable, + ), + ]; + } + if (Platform.isAndroid) { + return _androidCapabilities(); + } + if (Platform.isIOS) { + return _iosCapabilities(); + } + if (Platform.isMacOS) { + return _macosCapabilities(); + } + if (Platform.isWindows) { + return _windowsCapabilities(); + } + if (Platform.isLinux) { + return _linuxCapabilities(); + } + return []; +} + +List _androidCapabilities() => [ + const PlatformCapability( + feature: 'Voice capture', + description: 'Oboe AAudio backend; Bluetooth SCO supported.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Foreground service', + description: 'Persistent notification during active connection.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Not available on Android.', + tier: CapabilityTier.unavailable, + ), + const PlatformCapability( + feature: 'Audio device selection', + description: 'System-managed; wired/BT/SCO automatic routing.', + tier: CapabilityTier.degraded, + ), + const PlatformCapability( + feature: 'Push-to-talk button', + description: 'Media button / volume-key binding supported.', + tier: CapabilityTier.supported, + ), + ]; + +List _iosCapabilities() => [ + const PlatformCapability( + feature: 'Voice capture', + description: 'VoiceProcessingIO AudioUnit (default); Sonora raw mode optional.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'Platform AEC/NS/AGC via VPIO; optional Rust AEC3/NS/AGC2 via Sonora.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Audio route switching', + description: 'Handles speaker/earpiece/Bluetooth route changes and interruptions.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Not available on iOS.', + tier: CapabilityTier.unavailable, + ), + const PlatformCapability( + feature: 'Background audio', + description: 'Supported via AVAudioSession background mode.', + tier: CapabilityTier.supported, + ), + ]; + +List _macosCapabilities() => [ + const PlatformCapability( + feature: 'Voice capture', + description: 'cpal device enumeration; VoiceProcessingIO optional.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Supported via platform-global key-binding API.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Secure storage', + description: 'macOS Keychain.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Audio device selection', + description: 'System audio output route picker.', + tier: CapabilityTier.supported, + ), + ]; + +List _windowsCapabilities() => [ + const PlatformCapability( + feature: 'Voice capture', + description: 'WASAPI via cpal.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Supported via platform-global hotkey binding.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Secure storage', + description: 'Windows Credential Manager / DPAPI.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Installer', + description: 'MSIX packaging not yet available in beta.', + tier: CapabilityTier.unavailable, + ), + ]; + +List _linuxCapabilities() => [ + const PlatformCapability( + feature: 'Voice capture', + description: 'PulseAudio/ALSA via cpal.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2; no platform VPIO.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Supported via X11/Wayland global key-binding. ', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Secure storage', + description: 'Secret Service / libsecret.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Desktop environment', + description: 'DE-specific behaviour: screen locker may suspend audio.', + tier: CapabilityTier.degraded, + ), + ]; diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index e9f48c8..804a5dc 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -181,24 +181,19 @@ "audioRouteChangeFailed": "Could not change audio output.", "iosAudioInterrupted": "Audio interrupted by system (phone call)", "iosAudioResuming": "Audio resuming", - "settingsAction": "Settings", - "appSettingsTitle": "Settings", - "pokeAlertsTitle": "Poke alerts", - "pokeAlertsDescription": "Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.", - "pokeDialogTitle": "You were poked", - "pokeDialogCloseAction": "Close", - "pokeDialogIncomingNoMessage": "{time} {sender} pokes you", - "@pokeDialogIncomingNoMessage": { + "pokeSnackBarClearAction": "Clear", + "pokeSnackBarMoreIndicator": "...", + "pokeSnackBarIncomingNoMessage": "{sender} pokes you", + "@pokeSnackBarIncomingNoMessage": { "placeholders": { - "time": { "type": "String" }, "sender": { "type": "String" } } }, - "pokeDialogIncomingWithMessage": "{time} {sender} pokes you with a message", - "@pokeDialogIncomingWithMessage": { + "pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}", + "@pokeSnackBarIncomingWithMessage": { "placeholders": { - "time": { "type": "String" }, - "sender": { "type": "String" } + "sender": { "type": "String" }, + "message": { "type": "String" } } }, "pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 8aceeac..1b4cf9e 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -135,24 +135,19 @@ "audioRouteChangeFailed": "无法切换音频输出。", "iosAudioInterrupted": "系统已中断音频(电话通话)", "iosAudioResuming": "音频正在恢复", - "settingsAction": "设置", - "appSettingsTitle": "设置", - "pokeAlertsTitle": "戳一戳提醒", - "pokeAlertsDescription": "收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。", - "pokeDialogTitle": "你被戳了一下", - "pokeDialogCloseAction": "关闭", - "pokeDialogIncomingNoMessage": "{time} {sender} 戳了你一下", - "@pokeDialogIncomingNoMessage": { + "pokeSnackBarClearAction": "清除", + "pokeSnackBarMoreIndicator": "...", + "pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下", + "@pokeSnackBarIncomingNoMessage": { "placeholders": { - "time": { "type": "String" }, "sender": { "type": "String" } } }, - "pokeDialogIncomingWithMessage": "{time} {sender} 戳了你一下并附带消息", - "@pokeDialogIncomingWithMessage": { + "pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}", + "@pokeSnackBarIncomingWithMessage": { "placeholders": { - "time": { "type": "String" }, - "sender": { "type": "String" } + "sender": { "type": "String" }, + "message": { "type": "String" } } }, "pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index c368064..38eca27 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -823,53 +823,29 @@ abstract class AppL10n { /// **'Audio resuming'** String get iosAudioResuming; - /// No description provided for @settingsAction. + /// No description provided for @pokeSnackBarClearAction. /// /// In en, this message translates to: - /// **'Settings'** - String get settingsAction; + /// **'Clear'** + String get pokeSnackBarClearAction; - /// No description provided for @appSettingsTitle. + /// No description provided for @pokeSnackBarMoreIndicator. /// /// In en, this message translates to: - /// **'Settings'** - String get appSettingsTitle; + /// **'...'** + String get pokeSnackBarMoreIndicator; - /// No description provided for @pokeAlertsTitle. + /// No description provided for @pokeSnackBarIncomingNoMessage. /// /// In en, this message translates to: - /// **'Poke alerts'** - String get pokeAlertsTitle; + /// **'{sender} pokes you'** + String pokeSnackBarIncomingNoMessage(String sender); - /// No description provided for @pokeAlertsDescription. + /// No description provided for @pokeSnackBarIncomingWithMessage. /// /// In en, this message translates to: - /// **'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'** - String get pokeAlertsDescription; - - /// No description provided for @pokeDialogTitle. - /// - /// In en, this message translates to: - /// **'You were poked'** - String get pokeDialogTitle; - - /// No description provided for @pokeDialogCloseAction. - /// - /// In en, this message translates to: - /// **'Close'** - String get pokeDialogCloseAction; - - /// No description provided for @pokeDialogIncomingNoMessage. - /// - /// In en, this message translates to: - /// **'{time} {sender} pokes you'** - String pokeDialogIncomingNoMessage(String time, String sender); - - /// No description provided for @pokeDialogIncomingWithMessage. - /// - /// In en, this message translates to: - /// **'{time} {sender} pokes you with a message'** - String pokeDialogIncomingWithMessage(String time, String sender); + /// **'{sender} pokes you: {message}'** + String pokeSnackBarIncomingWithMessage(String sender, String message); /// No description provided for @pokeHistorySelfNoMessage. /// diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 3b8c288..89fec7c 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -416,32 +416,19 @@ class AppL10nEn extends AppL10n { String get iosAudioResuming => 'Audio resuming'; @override - String get settingsAction => 'Settings'; + String get pokeSnackBarClearAction => 'Clear'; @override - String get appSettingsTitle => 'Settings'; + String get pokeSnackBarMoreIndicator => '...'; @override - String get pokeAlertsTitle => 'Poke alerts'; - - @override - String get pokeAlertsDescription => - 'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'; - - @override - String get pokeDialogTitle => 'You were poked'; - - @override - String get pokeDialogCloseAction => 'Close'; - - @override - String pokeDialogIncomingNoMessage(String time, String sender) { - return '$time $sender pokes you'; + String pokeSnackBarIncomingNoMessage(String sender) { + return '$sender pokes you'; } @override - String pokeDialogIncomingWithMessage(String time, String sender) { - return '$time $sender pokes you with a message'; + String pokeSnackBarIncomingWithMessage(String sender, String message) { + return '$sender pokes you: $message'; } @override diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 4a0ab34..debbc4a 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -406,31 +406,19 @@ class AppL10nZh extends AppL10n { String get iosAudioResuming => '音频正在恢复'; @override - String get settingsAction => '设置'; + String get pokeSnackBarClearAction => '清除'; @override - String get appSettingsTitle => '设置'; + String get pokeSnackBarMoreIndicator => '...'; @override - String get pokeAlertsTitle => '戳一戳提醒'; - - @override - String get pokeAlertsDescription => '收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。'; - - @override - String get pokeDialogTitle => '你被戳了一下'; - - @override - String get pokeDialogCloseAction => '关闭'; - - @override - String pokeDialogIncomingNoMessage(String time, String sender) { - return '$time $sender 戳了你一下'; + String pokeSnackBarIncomingNoMessage(String sender) { + return '$sender 戳了你一下'; } @override - String pokeDialogIncomingWithMessage(String time, String sender) { - return '$time $sender 戳了你一下并附带消息'; + String pokeSnackBarIncomingWithMessage(String sender, String message) { + return '$sender 戳了你一下:$message'; } @override diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 96b4510..bf3d374 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -145,7 +145,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ConnectionPhase _phase = ConnectionPhase.idle; bool get _serverReachable => _phase.isServerReachable; rust.BridgeSnapshot? _snapshot; - String? _error; + final List _uiDiagnostics = []; rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; int _statsTick = 0; @@ -195,11 +195,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final List _chatMessages = []; int _chatUnread = 0; bool _chatOpen = false; - bool _showPokeDialogs = true; - final ValueNotifier> _pokeDialogPokes = ValueNotifier( + final ValueNotifier> _pokeSnackBarPokes = ValueNotifier( const [], ); - bool _pokeDialogShowing = false; + bool _pokeSnackBarVisible = false; IconData? _audioRoute; // SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service. @@ -246,25 +245,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (settings.nickname.isNotEmpty) { _nickCtl.text = settings.nickname; } - if (mounted) { - setState(() => _showPokeDialogs = settings.showPokeDialogs); - } else { - _showPokeDialogs = settings.showPokeDialogs; - } } catch (_) {} } - Future _saveUiSettings({ - String? host, - String? nickname, - bool? showPokeDialogs, - }) async { + Future _saveUiSettings({String? host, String? nickname}) async { try { - await _uiPreferences.saveSettings( - host: host, - nickname: nickname, - showPokeDialogs: showPokeDialogs, - ); + await _uiPreferences.saveSettings(host: host, nickname: nickname); } catch (_) {} } @@ -342,12 +328,45 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('clear permission mute', e); } finally { _permissionHardMuteClearInFlight = false; } } + void _recordUiDiagnostic(String area, Object error) { + final line = '${DateTime.now().toIso8601String()} [$area] $error'; + debugPrint('chanora: $line'); + _uiDiagnostics.add(line); + if (_uiDiagnostics.length > 100) { + _uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100); + } + } + + void _showUiError(String area, Object error) { + if (!mounted) return; + _showUiErrorSnackBar(area: area, error: error); + } + + void _showUiErrorSnackBar({ + required String area, + required Object error, + String? displayMessage, + }) { + _recordUiDiagnostic(area, error); + final l10n = AppL10n.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + displayMessage ?? l10n.statusError(error.toString()), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + bool _handleFocusedPttKey(KeyEvent event) { final label = pttDisplayLabelForKey(event.logicalKey); final isBoundKey = @@ -564,13 +583,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } }); if (isPoke) { - if (_showPokeDialogs) { - _appendPokeDialog( - senderName: senderName, - message: message, - receivedAt: receivedAt, - ); - } + _showPokeSnackBar( + senderName: senderName, + message: message, + receivedAt: receivedAt, + ); return; } if (!_chatOpen && _chatUnread > 0) { @@ -656,7 +673,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _hostCtl.dispose(); _nickCtl.dispose(); _passwordCtl.dispose(); - _pokeDialogPokes.dispose(); + _pokeSnackBarPokes.dispose(); _androidPermissions.recordAudioState.removeListener( _onRecordAudioPermissionChanged, ); @@ -678,7 +695,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }) async { setState(() { _phase = ConnectionPhase.connecting; - _error = null; _snapshot = null; _chatMessages.clear(); }); @@ -718,8 +734,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } setState(() { _phase = ConnectionPhase.idle; - _error = errorStr; }); + _showUiErrorSnackBar(area: 'connect', error: e); } } @@ -786,7 +802,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await rust.setPtt(active: active); } catch (e) { if (!mounted || !reportError) return; - setState(() => _error = e.toString()); + _showUiError('ptt', e); } } @@ -800,7 +816,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('output mute', e); } } @@ -812,7 +828,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (_pendingVoiceChannelId != null) return; if (ch.id == _currentVoiceChannelId) return; final l10n = AppL10n.of(context); - final messenger = ScaffoldMessenger.of(context); String? password; if (askForPassword) { password = await _askChannelPassword(l10n); @@ -872,18 +887,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } catch (e) { if (!mounted) return; // Surface as a SnackBar so the user sees it even while - // connected (the persistent _error string lives in the - // pre-connect area and is hidden post-connect). The message - // is selected by TS3 error code per the canonical - // catalogue at https://github.com/ReSpeak/tsdeclarations. + // connected. The message is selected by TS3 error code per + // the canonical catalogue at + // https://github.com/ReSpeak/tsdeclarations. final message = channelJoinErrorMessage(l10n, e); setState(() => _pendingVoiceChannelId = null); - messenger.showSnackBar( - SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 4), - ), + _showUiErrorSnackBar( + area: 'join channel', + error: e, + displayMessage: message, ); } } @@ -914,7 +926,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('hard mute', e); } } @@ -938,7 +950,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } catch (e) { if (held) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('onscreen ptt', e); } } } @@ -977,7 +989,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { setState(() => _transmitMode = mode); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('transmit mode', e); } }, onReleaseTailChanged: (ms) async { @@ -987,7 +999,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { setState(() => _releaseTailMs = ms); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('release tail', e); } }, onAudioConfigChanged: (config) async { @@ -995,7 +1007,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await rust.setAudioProcessingConfig(config: config); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('audio processing config', e); } }, ); @@ -1023,7 +1035,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await rust.setAudioProcessingConfig(config: result.audioConfig); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('voice settings', e); } if (result.bindKeyRequested && mounted) { await _onConfigurePtt(context); @@ -1055,7 +1067,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { setState(() => _applySnapshot(snap)); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('refresh snapshot', e); } } @@ -1113,7 +1125,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _phase = phase; _snapshot = null; _audioStats = null; - _error = null; _inputMuted = false; _outputMuted = false; _inChannel = false; @@ -1171,36 +1182,52 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (mounted) setState(() => _chatOpen = false); } - void _appendPokeDialog({ + void _showPokeSnackBar({ required String senderName, required String message, required DateTime receivedAt, }) { - _pokeDialogPokes.value = [ - ..._pokeDialogPokes.value, + _pokeSnackBarPokes.value = [ + ..._pokeSnackBarPokes.value, _ReceivedPoke( senderName: senderName, message: message, receivedAt: receivedAt, ), ]; - unawaited(_showPokeDialog()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _renderPokeSnackBar(); + }); } - Future _showPokeDialog() async { - if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) { - return; - } - _pokeDialogShowing = true; - try { - await showDialog( - context: context, - builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes), - ); - } finally { - _pokeDialogPokes.value = const []; - _pokeDialogShowing = false; - } + void _renderPokeSnackBar() { + if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return; + _pokeSnackBarVisible = true; + final messenger = ScaffoldMessenger.of(context); + final controller = messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: _chatSnackBarMargin(), + duration: const Duration(days: 365), + dismissDirection: DismissDirection.none, + content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes), + action: SnackBarAction( + label: AppL10n.of(context).pokeSnackBarClearAction, + onPressed: () { + _pokeSnackBarPokes.value = const []; + _pokeSnackBarVisible = false; + }, + ), + ), + ); + controller.closed.then((_) { + if (!mounted) return; + _pokeSnackBarVisible = false; + if (_pokeSnackBarPokes.value.isEmpty) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _renderPokeSnackBar(); + }); + }); } void _showChatMessageSnackBar({ @@ -1208,6 +1235,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { required String message, required rust.BridgeMessageTarget target, }) { + if (_pokeSnackBarPokes.value.isNotEmpty) return; final messenger = ScaffoldMessenger.of(context); messenger.hideCurrentSnackBar(); messenger.showSnackBar( @@ -1313,7 +1341,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _onShowDiagnostics(BuildContext context) async { final l10n = AppL10n.of(context); - final text = rust.exportDiagnostics(); + final rustText = rust.exportDiagnostics(); + final uiText = _uiDiagnostics.isEmpty + ? 'UI diagnostics: none' + : ['UI diagnostics:', ..._uiDiagnostics].join('\n'); + final text = '$uiText\n\nRust diagnostics:\n$rustText'; if (!mounted) return; await showDialog( context: context, @@ -1370,10 +1402,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); } catch (e) { if (!mounted) return; - final messenger = ScaffoldMessenger.of(this.context); - messenger.showSnackBar( - SnackBar(content: Text(l10n.statusError(e.toString()))), - ); + _showUiErrorSnackBar(area: 'ptt portal binding', error: e); } return; } @@ -1405,13 +1434,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (!mounted) return; // Surface the failure as a snackbar so the user sees that // their binding did not stick. - if (!mounted) return; - // Use the State's context (guaranteed valid because we - // re-checked `mounted` immediately above). - final messenger = ScaffoldMessenger.of(this.context); - messenger.showSnackBar( - SnackBar(content: Text(l10n.statusError(e.toString()))), - ); + _showUiErrorSnackBar(area: 'ptt binding', error: e); } } @@ -1514,7 +1537,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await _reloadBookmarks(); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('add bookmark', e); } } @@ -1524,7 +1547,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await _reloadBookmarks(); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('delete bookmark', e); } } @@ -1573,24 +1596,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"'))); } catch (e) { if (!mounted) return; - setState(() => _error = e.toString()); + _showUiError('teamspeak link bookmark', e); } } - Future _onOpenAppSettings() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _AppSettingsPage( - showPokeDialogs: _showPokeDialogs, - onShowPokeDialogsChanged: (value) { - setState(() => _showPokeDialogs = value); - unawaited(_saveUiSettings(showPokeDialogs: value)); - }, - ), - ), - ); - } - @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); @@ -1617,11 +1626,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { onPressed: _toggleOutputMute, ), ], - IconButton( - tooltip: l10n.settingsAction, - icon: const Icon(Icons.settings_outlined), - onPressed: _onOpenAppSettings, - ), IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), @@ -1656,7 +1660,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final statusText = connectionStatusText( phase: _phase, l10n: l10n, - error: _error, serverName: _snapshot?.serverName, lostReason: _lostReason, reconnectAttempt: _reconnectAttempt, @@ -1686,11 +1689,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)], if (!_serverReachable) Row( - mainAxisSize: MainAxisSize.min, children: [ Icon(connTokens.icon, size: 18, color: connTokens.color), const SizedBox(width: 6), - Text(statusText, style: theme.textTheme.titleMedium), + Expanded( + child: Text( + statusText, + softWrap: true, + style: theme.textTheme.titleMedium, + ), + ), ], ), if (_serverReachable && _audioRoute != null) ...[ @@ -1928,75 +1936,74 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } } -class _PokeDialog extends StatelessWidget { - const _PokeDialog({required this.pokes}); +class _PokeSnackBarContent extends StatelessWidget { + const _PokeSnackBarContent({required this.pokes}); final ValueListenable> pokes; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppL10n.of(context); - - return AlertDialog( - icon: Icon( - Icons.notifications_active_outlined, - color: theme.colorScheme.primary, - ), - title: Text(l10n.pokeDialogTitle), - content: ValueListenableBuilder>( - valueListenable: pokes, - builder: (context, entries, _) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360), - child: ListView.separated( - shrinkWrap: true, - itemCount: entries.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]), - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.pokeDialogCloseAction), - ), - ], + return ValueListenableBuilder>( + valueListenable: pokes, + builder: (context, entries, _) { + final l10n = AppL10n.of(context); + final visible = entries.length <= 3 + ? entries + : entries.sublist(entries.length - 3); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (entries.length > 3) + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 2), + child: Text( + l10n.pokeSnackBarMoreIndicator, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + for (final poke in visible) _PokeSnackBarRow(poke: poke), + ], + ); + }, ); } } -class _AppSettingsPage extends StatefulWidget { - const _AppSettingsPage({ - required this.showPokeDialogs, - required this.onShowPokeDialogsChanged, - }); +class _PokeSnackBarRow extends StatelessWidget { + const _PokeSnackBarRow({required this.poke}); - final bool showPokeDialogs; - final ValueChanged onShowPokeDialogsChanged; - - @override - State<_AppSettingsPage> createState() => _AppSettingsPageState(); -} - -class _AppSettingsPageState extends State<_AppSettingsPage> { - late bool _showPokeDialogs = widget.showPokeDialogs; + final _ReceivedPoke poke; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); - return Scaffold( - appBar: AppBar(title: Text(l10n.appSettingsTitle)), - body: ListView( + final message = poke.message.trim(); + final text = message.isEmpty + ? l10n.pokeSnackBarIncomingNoMessage(poke.senderName) + : l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SwitchListTile( - title: Text(l10n.pokeAlertsTitle), - subtitle: Text(l10n.pokeAlertsDescription), - value: _showPokeDialogs, - onChanged: (value) { - setState(() => _showPokeDialogs = value); - widget.onShowPokeDialogsChanged(value); - }, + Expanded( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 12), + Text( + pokeSnackBarTimeLabel(poke.receivedAt), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onInverseSurface.withValues(alpha: 0.72), + ), ), ], ), @@ -2004,43 +2011,7 @@ class _AppSettingsPageState extends State<_AppSettingsPage> { } } -class _PokeDialogEntry extends StatelessWidget { - const _PokeDialogEntry({required this.poke}); - - final _ReceivedPoke poke; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppL10n.of(context); - final message = poke.message.trim(); - final hasMessage = message.isNotEmpty; - final time = chatTimeLabel(poke.receivedAt); - final detail = hasMessage - ? l10n.pokeDialogIncomingWithMessage(time, poke.senderName) - : l10n.pokeDialogIncomingNoMessage(time, poke.senderName); - - return DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - detail, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - if (hasMessage) ...[const SizedBox(height: 8), Text(message)], - ], - ), - ), - ); - } +String pokeSnackBarTimeLabel(DateTime timestamp) { + String two(int value) => value.toString().padLeft(2, '0'); + return '${two(timestamp.hour)}:${two(timestamp.minute)}'; } diff --git a/apps/chanora_flutter/lib/services/android_audio_output_devices.dart b/apps/chanora_flutter/lib/services/android_audio_output_devices.dart new file mode 100644 index 0000000..5da65ae --- /dev/null +++ b/apps/chanora_flutter/lib/services/android_audio_output_devices.dart @@ -0,0 +1,40 @@ +class AndroidAudioOutputDevice { + const AndroidAudioOutputDevice({ + required this.id, + required this.name, + required this.type, + required this.isSelected, + required this.isAvailableForCommunication, + }); + + final String id; + final String name; + final String type; + final bool isSelected; + final bool isAvailableForCommunication; + + factory AndroidAudioOutputDevice.fromMap(Map map) { + return AndroidAudioOutputDevice( + id: map['id']?.toString() ?? '', + name: map['name']?.toString() ?? '', + type: map['type']?.toString() ?? 'unknown', + isSelected: map['isSelected'] == true, + isAvailableForCommunication: map['isAvailableForCommunication'] == true, + ); + } +} + +List parseAndroidAudioOutputDevices( + List raw, +) { + return raw + .whereType>() + .map(AndroidAudioOutputDevice.fromMap) + .toList(); +} + +AndroidAudioOutputDevice? selectedAndroidAudioOutputDevice( + List devices, +) { + return devices.where((device) => device.isSelected).firstOrNull; +} diff --git a/apps/chanora_flutter/lib/services/app_bootstrap.dart b/apps/chanora_flutter/lib/services/app_bootstrap.dart new file mode 100644 index 0000000..5a79f15 --- /dev/null +++ b/apps/chanora_flutter/lib/services/app_bootstrap.dart @@ -0,0 +1,97 @@ +import 'dart:io' show File; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../src/rust/api.dart' as rust; + +const String appSemverBaseline = 'v0.1.0'; +const String _sileroVadAsset = 'assets/models/silero_vad.onnx'; +const String _tenVadAsset = 'assets/models/ten_vad.onnx'; + +Future _copyBundledAssetToDocuments({ + required String assetPath, + required String fileName, +}) async { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/$fileName'); + final data = await rootBundle.load(assetPath); + final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + + if (await file.exists() && await file.length() == bytes.length) { + return file; + } + + await file.writeAsBytes(bytes, flush: true); + return file; +} + +Future configureBundledVadModels() async { + final silero = await _copyBundledAssetToDocuments( + assetPath: _sileroVadAsset, + fileName: 'silero_vad.onnx', + ); + final ten = await _copyBundledAssetToDocuments( + assetPath: _tenVadAsset, + fileName: 'ten_vad.onnx', + ); + await rust.setVadModelPath(path: silero.path); + await rust.setTenVadModelPath(path: ten.path); +} + +/// Resolve the human-readable app version displayed in About. +/// +/// The semver baseline is kept in code because iOS strips pre-release +/// identifiers from `CFBundleShortVersionString`; `package_info_plus` +/// still provides the platform build counter. +Future resolveAppVersion({ + String semverBaseline = appSemverBaseline, +}) async { + try { + final info = await PackageInfo.fromPlatform(); + return appVersionFromBuildNumber( + semverBaseline: semverBaseline, + buildNumber: info.buildNumber, + ); + } catch (_) { + return semverBaseline; + } +} + +String appVersionFromBuildNumber({ + required String semverBaseline, + required String buildNumber, +}) { + final build = buildNumber.isEmpty ? '' : '+$buildNumber'; + return '$semverBaseline$build'; +} + +Future wireStorage() async { + try { + final dir = await getApplicationSupportDirectory(); + await rust.initStorage(dir: dir.path); + } catch (_) { + // Best-effort; missing storage just means no identity persistence + // and no bookmark list this session. + } +} + +rust.BridgeNetworkState _mapConnectivity(List results) { + if (results.isEmpty) return rust.BridgeNetworkState.unknown; + final allNone = results.every((r) => r == ConnectivityResult.none); + if (allNone) return rust.BridgeNetworkState.offline; + return rust.BridgeNetworkState.online; +} + +Future wireConnectivity() async { + final connectivity = Connectivity(); + try { + final initial = await connectivity.checkConnectivity(); + rust.setNetworkState(state: _mapConnectivity(initial)); + } catch (_) {} + connectivity.onConnectivityChanged.listen((results) { + rust.setNetworkState(state: _mapConnectivity(results)); + }); +} diff --git a/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart b/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart new file mode 100644 index 0000000..7e28f7e --- /dev/null +++ b/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart @@ -0,0 +1,107 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +import '../src/rust/api.dart' as rust; + +const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle'; +const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle'; + +rust.BridgeAudioRoute parseBridgeAudioRoute(String value) { + switch (value) { + case 'Earpiece': + return rust.BridgeAudioRoute.earpiece; + case 'Speaker': + return rust.BridgeAudioRoute.speaker; + case 'WiredHeadset': + return rust.BridgeAudioRoute.wiredHeadset; + case 'BluetoothHfp': + return rust.BridgeAudioRoute.bluetoothHfp; + case 'BluetoothA2dp': + return rust.BridgeAudioRoute.bluetoothA2Dp; + default: + return rust.BridgeAudioRoute.unknown; + } +} + +void wireAudioLifecycle() { + wireIosAudioLifecycle(); + wireAndroidAudioLifecycle(); +} + +/// Wire the iOS AVAudioSession lifecycle MethodChannel. +/// +/// Swift side (AppDelegate) posts route-change and interruption events through +/// this channel. The handler dispatches them to the FRB bridge functions on +/// the Rust side. +void wireIosAudioLifecycle({ + MethodChannel channel = const MethodChannel(iosAudioLifecycleChannelName), +}) { + channel.setMethodCallHandler((call) async { + try { + switch (call.method) { + case 'handleRouteChange': + final routeStr = call.arguments as String? ?? 'Unknown'; + final route = parseBridgeAudioRoute(routeStr); + rust.handleRouteChange(route: route); + break; + case 'handleMediaServicesReset': + final routeClass = call.arguments as String? ?? 'Unknown'; + rust.handleMediaServicesResetWithRoute(routeClass: routeClass); + break; + case 'handleInterruptionBegan': + rust.handleInterruptionBegan(); + break; + case 'handleInterruptionEnded': + final shouldResume = call.arguments as bool? ?? false; + rust.handleInterruptionEnded(shouldResume: shouldResume); + break; + case 'handleWillResignActive': + case 'handleDidEnterBackground': + rust.handleInterruptionBegan(); + break; + case 'handleWillEnterForeground': + rust.handleInterruptionEnded(shouldResume: true); + break; + case 'handleWillTerminate': + rust.handleInterruptionBegan(); + break; + default: + break; + } + } catch (_) { + // Errors from the Rust side are already logged there; do not propagate + // exceptions to the platform framework. + } + }); +} + +/// Wire the Android audio lifecycle MethodChannel. +/// +/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change events +/// through this channel. This mirrors the iOS dispatch path. +void wireAndroidAudioLifecycle({ + bool isAndroid = false, + MethodChannel channel = const MethodChannel(androidAudioLifecycleChannelName), +}) { + if (!isAndroid && !Platform.isAndroid) return; + channel.setMethodCallHandler((call) async { + try { + switch (call.method) { + case 'handleRouteChange': + final args = call.arguments; + final routeStr = args is Map + ? (args['routeType'] as String? ?? 'Unknown') + : (args as String? ?? 'Unknown'); + final route = parseBridgeAudioRoute(routeStr); + rust.handleRouteChange(route: route); + break; + default: + break; + } + } catch (_) { + // Errors from the Rust side are already logged there; do not propagate + // exceptions to the platform framework. + } + }); +} diff --git a/apps/chanora_flutter/lib/services/channel_join_error_mapper.dart b/apps/chanora_flutter/lib/services/channel_join_error_mapper.dart new file mode 100644 index 0000000..a5bae46 --- /dev/null +++ b/apps/chanora_flutter/lib/services/channel_join_error_mapper.dart @@ -0,0 +1,39 @@ +import '../l10n/generated/app_localizations.dart'; +import '../src/rust/lib.dart' as rust_err; + +/// Map a `voiceJoin` error to a localized user-facing message. +String channelJoinErrorMessage(AppL10n l10n, Object error) { + if (error is rust_err.BridgeError) { + return error.when( + invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg), + dnsFailed: (host, reason) => + l10n.channelJoinFailedGeneric('$host: $reason'), + connection: (msg) => l10n.channelJoinFailedGeneric(msg), + notConnected: () => l10n.channelJoinFailedGeneric('not connected'), + alreadyConnected: () => + l10n.channelJoinFailedGeneric('already connected'), + serverRejected: (code, message) { + switch (code) { + case 0x0001: + return l10n.channelJoinFailedTimeout; + case 0x0a08: + return l10n.channelJoinFailedPermission; + case 0x0302: + return l10n.channelJoinAlreadyIn; + case 0x030d: + return l10n.channelJoinFailedPassword; + case 0x0309: + return l10n.channelJoinFailedFull; + case 0x030a: + return l10n.channelJoinFailedFamilyFull; + case 0x030e: + return l10n.channelJoinFailedPrivate; + default: + return l10n.channelJoinFailedGeneric(message); + } + }, + unmapped: (msg) => l10n.channelJoinFailedGeneric(msg), + ); + } + return l10n.channelJoinFailedGeneric(error.toString()); +} diff --git a/apps/chanora_flutter/lib/services/channel_spacer.dart b/apps/chanora_flutter/lib/services/channel_spacer.dart new file mode 100644 index 0000000..31db404 --- /dev/null +++ b/apps/chanora_flutter/lib/services/channel_spacer.dart @@ -0,0 +1,243 @@ +/// Alignment requested by a TeamSpeak spacer channel tag. +enum SpacerAlignment { + /// Left-aligned spacer content. + left, + + /// Right-aligned spacer content. + right, + + /// Center-aligned spacer content. + center, +} + +/// Built-in TeamSpeak spacer separator patterns. +enum SpacerSpecialType { + /// `___` + solidLine, + + /// `---` + dashLine, + + /// `...` + dotLine, + + /// `-.-` + dashDotLine, + + /// `-..` + dashDotDotLine, +} + +/// Parsed form of a TeamSpeak spacer channel name. +class SpacerChannelNameParseResult { + /// Construct a spacer parse result. + const SpacerChannelNameParseResult({ + required this.isSpacer, + required this.isValid, + required this.alignment, + required this.isRepeating, + required this.uniqueSuffix, + required this.text, + required this.specialType, + required this.isBlankSpacer, + this.reason, + }); + + /// True only when the name begins with a valid bracketed spacer tag. + final bool isSpacer; + + /// True when the bracketed spacer tag follows the supported syntax. + final bool isValid; + + /// Optional alignment flag. Null means the server/client default. + final SpacerAlignment? alignment; + + /// True when `*` appears in the tag and the text should repeat. + final bool isRepeating; + + /// The exact suffix after `Spacer` and before `]`. + final String uniqueSuffix; + + /// The exact text after the closing `]`. + final String text; + + /// Built-in separator type for special text values. + final SpacerSpecialType? specialType; + + /// True for the known blank-looking right-aligned dot spacer. + final bool isBlankSpacer; + + /// Parse error/reason for non-spacer or malformed names. + final String? reason; +} + +/// Options for formatting a TeamSpeak spacer channel name. +class SpacerChannelNameFormatOptions { + /// Construct spacer formatting options. + const SpacerChannelNameFormatOptions({ + this.alignment, + this.isRepeating = false, + this.uniqueSuffix = '', + this.text = '', + }); + + /// Optional alignment flag. Null means omit the alignment prefix. + final SpacerAlignment? alignment; + + /// Whether to include the repeating `*` tag flag. + final bool isRepeating; + + /// Uniqueness suffix to place after `Spacer`. + final String uniqueSuffix; + + /// Text to place after the closing `]`. + final String text; +} + +const _notSpacer = SpacerChannelNameParseResult( + isSpacer: false, + isValid: false, + alignment: null, + isRepeating: false, + uniqueSuffix: '', + text: '', + specialType: null, + isBlankSpacer: false, + reason: 'not a spacer channel name', +); + +/// Return true when [name] begins with a valid bracketed spacer tag. +bool isSpacerChannelName(String name) => parseSpacerChannelName(name).isSpacer; + +/// Parse a TeamSpeak spacer channel name. +/// +/// Supported tag form is `[?Spacer#]Text`, parsed case-insensitively. +/// `?` may be `l`, `r`, or `c`; `*` may also appear in the tag to +/// mark repeating spacer content. The suffix and text are preserved +/// exactly as written. +SpacerChannelNameParseResult parseSpacerChannelName(String name) { + if (!name.startsWith('[')) return _notSpacer; + + final close = name.indexOf(']'); + if (close < 0) { + return _invalidSpacerName('missing closing bracket'); + } + + final tag = name.substring(1, close); + final text = name.substring(close + 1); + final match = RegExp( + r'^([lrc*]*)(spacer)(.*)$', + caseSensitive: false, + ).firstMatch(tag); + + if (match == null) { + if (tag.toLowerCase().contains('spacer')) { + return _invalidSpacerName('invalid spacer tag'); + } + return _notSpacer; + } + + final flags = match.group(1) ?? ''; + final uniqueSuffix = match.group(3) ?? ''; + final alignmentFlags = flags + .toLowerCase() + .split('') + .where((flag) => flag == 'l' || flag == 'r' || flag == 'c') + .toList(); + final repeatingFlags = flags.split('').where((flag) => flag == '*').length; + + if (alignmentFlags.length > 1) { + return _invalidSpacerName('multiple alignment flags'); + } + if (repeatingFlags > 1) { + return _invalidSpacerName('multiple repeating flags'); + } + + final alignmentFlag = alignmentFlags.isEmpty ? null : alignmentFlags.first; + final alignment = switch (alignmentFlag) { + 'l' => SpacerAlignment.left, + 'r' => SpacerAlignment.right, + 'c' => SpacerAlignment.center, + _ => null, + }; + final specialType = _specialTypeForText(text); + return SpacerChannelNameParseResult( + isSpacer: true, + isValid: true, + alignment: alignment, + isRepeating: repeatingFlags == 1, + uniqueSuffix: uniqueSuffix, + text: text, + specialType: specialType, + isBlankSpacer: alignment == SpacerAlignment.right && text == '.', + ); +} + +/// Format a TeamSpeak spacer channel name deterministically. +/// +/// The output uses canonical `Spacer` casing and places `*` before +/// the alignment flag when both are present. +String formatSpacerChannelName(SpacerChannelNameFormatOptions options) { + final repeatFlag = options.isRepeating ? '*' : ''; + final alignFlag = switch (options.alignment) { + SpacerAlignment.left => 'l', + SpacerAlignment.right => 'r', + SpacerAlignment.center => 'c', + null => '', + }; + return '[$repeatFlag${alignFlag}Spacer${options.uniqueSuffix}]${options.text}'; +} + +/// Convert a channel name into display text while preserving the +/// underlying channel entity and behavior. +String channelSpacerLabel(String raw, {int repeatColumns = 32}) { + final parsed = parseSpacerChannelName(raw); + if (!parsed.isValid) return raw; + if (parsed.isBlankSpacer) return ''; + if (parsed.isRepeating) { + return _repeatSpacerText(parsed.text, repeatColumns); + } + + return switch (parsed.specialType) { + SpacerSpecialType.solidLine => '────────', + SpacerSpecialType.dashLine => '╌╌╌╌╌╌╌╌', + SpacerSpecialType.dotLine => '········', + SpacerSpecialType.dashDotLine => '─╶─╶─╶─╶─╶─╶─╶─╶', + SpacerSpecialType.dashDotDotLine => '─╶╶─╶╶─╶╶─╶╶', + null => parsed.text, + }; +} + +SpacerChannelNameParseResult _invalidSpacerName(String reason) { + return SpacerChannelNameParseResult( + isSpacer: false, + isValid: false, + alignment: null, + isRepeating: false, + uniqueSuffix: '', + text: '', + specialType: null, + isBlankSpacer: false, + reason: reason, + ); +} + +SpacerSpecialType? _specialTypeForText(String text) { + return switch (text) { + '___' => SpacerSpecialType.solidLine, + '---' => SpacerSpecialType.dashLine, + '...' => SpacerSpecialType.dotLine, + '-.-' => SpacerSpecialType.dashDotLine, + '-..' => SpacerSpecialType.dashDotDotLine, + _ => null, + }; +} + +String _repeatSpacerText(String pattern, int repeatColumns) { + if (pattern.isEmpty) return '────────'; + final buffer = StringBuffer(); + while (buffer.length < repeatColumns) { + buffer.write(pattern); + } + return buffer.toString().substring(0, repeatColumns); +} diff --git a/apps/chanora_flutter/lib/services/connection_phase_state.dart b/apps/chanora_flutter/lib/services/connection_phase_state.dart new file mode 100644 index 0000000..9124c08 --- /dev/null +++ b/apps/chanora_flutter/lib/services/connection_phase_state.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../design/chanora_tokens.dart'; +import '../l10n/generated/app_localizations.dart'; + +enum ConnectionPhase { + /// App is disconnected, no error. + idle, + + /// Connecting to server. + connecting, + + /// Connected but still synchronizing snapshot. + synchronizing, + + /// Connected and ready. + connected, + + /// Connection lost, auto-reconnecting. + reconnecting, + + /// Explicitly disconnected by user. + disconnected, +} + +extension ConnectionPhaseState on ConnectionPhase { + bool get isServerReachable => + this == ConnectionPhase.connected || + this == ConnectionPhase.synchronizing || + this == ConnectionPhase.reconnecting; + + bool get canOpenChat => + this == ConnectionPhase.connected || + this == ConnectionPhase.synchronizing; + + bool get canDisconnect => canOpenChat; + + ConnectionTokens tokens(ColorScheme colorScheme) { + switch (this) { + case ConnectionPhase.idle: + case ConnectionPhase.disconnected: + return ConnectionTokens.disconnected(colorScheme); + case ConnectionPhase.connecting: + return ConnectionTokens.connecting(colorScheme); + case ConnectionPhase.synchronizing: + return ConnectionTokens.synchronizing(colorScheme); + case ConnectionPhase.connected: + return ConnectionTokens.connected(colorScheme); + case ConnectionPhase.reconnecting: + return ConnectionTokens.reconnecting(colorScheme); + } + } +} + +String connectionStatusText({ + required ConnectionPhase phase, + required AppL10n l10n, + String? serverName, + String? lostReason, + int? reconnectAttempt, + int? reconnectDelay, +}) { + switch (phase) { + case ConnectionPhase.idle: + return l10n.statusIdle; + case ConnectionPhase.connecting: + return l10n.statusConnecting; + case ConnectionPhase.synchronizing: + return 'Synchronizing...'; + case ConnectionPhase.connected: + return l10n.statusConnected(serverName ?? ''); + case ConnectionPhase.reconnecting: + return reconnectAttempt != null + ? l10n.statusReconnecting(reconnectAttempt, reconnectDelay ?? 0) + : l10n.statusConnectionLost(lostReason ?? ''); + case ConnectionPhase.disconnected: + return l10n.statusIdle; + } +} diff --git a/apps/chanora_flutter/lib/services/link_trust_service.dart b/apps/chanora_flutter/lib/services/link_trust_service.dart index c7f19bf..2861c31 100644 --- a/apps/chanora_flutter/lib/services/link_trust_service.dart +++ b/apps/chanora_flutter/lib/services/link_trust_service.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; class LinkTrustService extends ChangeNotifier { static LinkTrustService? _instance; @@ -71,13 +69,12 @@ Future showLinkTrustDialog(BuildContext context, String domain) async { height: 24, child: Checkbox( value: remember, - onChanged: (v) => setDialogState(() => remember = v ?? false), + onChanged: (v) => + setDialogState(() => remember = v ?? false), ), ), const SizedBox(width: 8), - const Flexible( - child: Text('Trust all links from this domain'), - ), + const Flexible(child: Text('Trust all links from this domain')), ], ), ], diff --git a/apps/chanora_flutter/lib/services/snapshot_state_mapper.dart b/apps/chanora_flutter/lib/services/snapshot_state_mapper.dart new file mode 100644 index 0000000..7cd6da6 --- /dev/null +++ b/apps/chanora_flutter/lib/services/snapshot_state_mapper.dart @@ -0,0 +1,65 @@ +import '../src/rust/api.dart' as rust; + +class OwnClientSnapshotState { + const OwnClientSnapshotState({ + required this.channelId, + required this.inputMuted, + required this.outputMuted, + required this.talkPowerOk, + required this.talkPower, + required this.talkPowerGranted, + this.neededTalkPower, + }); + + final BigInt channelId; + final bool inputMuted; + final bool outputMuted; + final bool talkPowerOk; + final int talkPower; + final bool talkPowerGranted; + final int? neededTalkPower; +} + +OwnClientSnapshotState? ownClientSnapshotState(rust.BridgeSnapshot snapshot) { + for (final client in snapshot.clients) { + if (client.id != snapshot.ownClientId) continue; + final neededTalkPower = _channelById( + snapshot, + client.channel, + )?.neededTalkPower; + return OwnClientSnapshotState( + channelId: client.channel, + inputMuted: client.inputMuted, + outputMuted: client.outputMuted, + talkPowerOk: _talkPowerOk(client, neededTalkPower), + talkPower: client.talkPower, + talkPowerGranted: client.talkPowerGranted, + neededTalkPower: neededTalkPower, + ); + } + return null; +} + +String snapshotChannelName(rust.BridgeSnapshot? snapshot, BigInt? channelId) { + if (snapshot == null || channelId == null) return ''; + return _channelById(snapshot, channelId)?.name ?? ''; +} + +int? snapshotNeededTalkPower(rust.BridgeSnapshot? snapshot, BigInt? channelId) { + if (snapshot == null || channelId == null) return null; + return _channelById(snapshot, channelId)?.neededTalkPower; +} + +rust.BridgeChannel? _channelById( + rust.BridgeSnapshot snapshot, + BigInt channelId, +) { + return snapshot.channels + .where((channel) => channel.id == channelId) + .firstOrNull; +} + +bool _talkPowerOk(rust.BridgeClient client, int? neededTalkPower) { + if (client.talkPowerGranted) return true; + return neededTalkPower == null || client.talkPower >= neededTalkPower; +} diff --git a/apps/chanora_flutter/lib/services/ts3_server_link.dart b/apps/chanora_flutter/lib/services/ts3_server_link.dart new file mode 100644 index 0000000..2e1edf6 --- /dev/null +++ b/apps/chanora_flutter/lib/services/ts3_server_link.dart @@ -0,0 +1,92 @@ +typedef Ts3ServerLinkHandler = Future Function(Ts3ServerLink link); + +class Ts3ServerLink { + const Ts3ServerLink({ + required this.host, + required this.hostWithPort, + this.port, + this.nickname, + this.password, + this.channel, + this.cid, + this.channelPassword, + this.token, + this.addBookmark, + }); + + final String host; + final String hostWithPort; + final int? port; + final String? nickname; + final String? password; + final String? channel; + final String? cid; + final String? channelPassword; + final String? token; + final String? addBookmark; +} + +Ts3ServerLink? parseTs3ServerLink(String rawUrl) { + final trimmed = rawUrl.trim(); + if (!trimmed.toLowerCase().startsWith('ts3server://')) return null; + + var body = trimmed.substring('ts3server://'.length); + body = body.replaceFirst(RegExp(r'/+$'), ''); + if (body.isEmpty) return null; + + final split = _splitAuthorityAndQuery(body); + final hostPart = Uri.decodeComponent(split.authority).trim(); + if (hostPart.isEmpty) return null; + + final params = split.query == null + ? const {} + : Uri.splitQueryString(split.query!); + final port = int.tryParse(params['port'] ?? ''); + final hostWithPort = port == null || _hasExplicitPort(hostPart) + ? hostPart + : '$hostPart:$port'; + + return Ts3ServerLink( + host: hostPart, + hostWithPort: hostWithPort, + port: port, + nickname: _emptyToNull(params['nickname']), + password: _emptyToNull(params['password']), + channel: _emptyToNull(params['channel']), + cid: _emptyToNull(params['cid']), + channelPassword: _emptyToNull(params['channelpassword']), + token: _emptyToNull(params['token']), + addBookmark: _emptyToNull(params['addbookmark']), + ); +} + +({String authority, String? query}) _splitAuthorityAndQuery(String body) { + final rawQueryIndex = body.indexOf('?'); + if (rawQueryIndex >= 0) { + return ( + authority: body.substring(0, rawQueryIndex), + query: body.substring(rawQueryIndex + 1), + ); + } + + final encodedQueryIndex = body.toLowerCase().indexOf('%3f'); + if (encodedQueryIndex >= 0) { + return ( + authority: body.substring(0, encodedQueryIndex), + query: Uri.decodeComponent(body.substring(encodedQueryIndex + 3)), + ); + } + + return (authority: body, query: null); +} + +bool _hasExplicitPort(String host) { + final lastColon = host.lastIndexOf(':'); + if (lastColon <= 0 || lastColon == host.length - 1) return false; + return int.tryParse(host.substring(lastColon + 1)) != null; +} + +String? _emptyToNull(String? value) { + if (value == null || value.isEmpty) return null; + return value; +} diff --git a/apps/chanora_flutter/lib/services/ui_preferences_service.dart b/apps/chanora_flutter/lib/services/ui_preferences_service.dart index 39a4873..b940b74 100644 --- a/apps/chanora_flutter/lib/services/ui_preferences_service.dart +++ b/apps/chanora_flutter/lib/services/ui_preferences_service.dart @@ -1,21 +1,15 @@ import 'package:shared_preferences/shared_preferences.dart'; class UiSettings { - const UiSettings({ - this.host = '', - this.nickname = '', - this.showPokeDialogs = true, - }); + const UiSettings({this.host = '', this.nickname = ''}); final String host; final String nickname; - final bool showPokeDialogs; } class UiPreferencesService { static const _hostKey = 'ui.host'; static const _nicknameKey = 'ui.nickname'; - static const _showPokeDialogsKey = 'ui.show_poke_dialogs'; static const _permissionsExplainedKey = 'perms_explained'; const UiPreferencesService(); @@ -25,21 +19,13 @@ class UiPreferencesService { return UiSettings( host: prefs.getString(_hostKey) ?? '', nickname: prefs.getString(_nicknameKey) ?? '', - showPokeDialogs: prefs.getBool(_showPokeDialogsKey) ?? true, ); } - Future saveSettings({ - String? host, - String? nickname, - bool? showPokeDialogs, - }) async { + Future saveSettings({String? host, String? nickname}) async { final prefs = await SharedPreferences.getInstance(); if (host != null) await prefs.setString(_hostKey, host); if (nickname != null) await prefs.setString(_nicknameKey, nickname); - if (showPokeDialogs != null) { - await prefs.setBool(_showPokeDialogsKey, showPokeDialogs); - } } Future hasExplainedPermissions() async { diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 5b5f330..5de105f 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'api.freezed.dart'; // These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` // These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate` /// Return the platform-conventional log-file path as a string, or @@ -46,15 +46,6 @@ Future isConnected() => RustLib.instance.api.crateApiIsConnected(); void handleRouteChange({required BridgeAudioRoute route}) => RustLib.instance.api.crateApiHandleRouteChange(route: route); -/// Handle iOS AVAudioSession media-services reset (legacy, no route arg). -/// -/// Called by the existing FRB-generated Dart binding. Uses -/// `AudioRoute::Unknown` which triggers a route-change recompute. -/// The AppDelegate now also calls `handle_media_services_reset_with_route` -/// directly after rebuilding the session. -void handleMediaServicesReset() => - RustLib.instance.api.crateApiHandleMediaServicesReset(); - /// Handle iOS AVAudioSession media-services reset with the current /// route class. Called by AppDelegate after rebuilding the session. /// @@ -74,11 +65,11 @@ void handleInterruptionEnded({required bool shouldResume}) => RustLib .api .crateApiHandleInterruptionEnded(shouldResume: shouldResume); -/// Set the push-to-talk state. +/// Set the focused/on-screen push-to-talk hold state. /// -/// Superseded in v1 by [`set_transmit_mode`] + the binding capture -/// dialog. Retained so legacy callers and integration tests keep -/// working; the new VoiceBar UI no longer invokes this. +/// Binding capture chooses which physical key drives PTT, while this +/// command carries the actual press/release edge for fallback focused +/// keyboard handling and touch controls. Future setPtt({required bool active}) => RustLib.instance.api.crateApiSetPtt(active: active); @@ -131,19 +122,17 @@ Future setPttBinding({ platformKey: platformKey, ); -/// Read the current PTT capability descriptor. Returns a -/// `(level, backend_id, bound_input_class)` triple matching the -/// privacy-safe `BridgeEvent::PttCapability` event shape; useful -/// for the initial UI render before the first event arrives. -Future<(String, String, String)> pttDescriptor() => +/// Read the current PTT capability descriptor. Matches the privacy-safe +/// `BridgeEvent::PttCapability` event shape; useful for the initial UI +/// render before the first event arrives. +Future pttDescriptor() => RustLib.instance.api.crateApiPttDescriptor(); -/// 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. -Future<(String, String)> getPttBinding() => +/// Return the persisted PTT binding 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. +Future getPttBinding() => RustLib.instance.api.crateApiGetPttBinding(); /// Move our own client to `channel_id`. Optional channel password @@ -176,6 +165,27 @@ Future setOutputMuted({required bool muted}) => Future setOutputGain({required double gain}) => RustLib.instance.api.crateApiSetOutputGain(gain: gain); +/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0` +/// mutes. No-op when client has no active voice queue. Volume is +/// applied directly to the tsclientlib AudioQueue and takes effect +/// immediately on the next render callback. +Future setClientVolume({ + required BigInt clientId, + required double volume, +}) => RustLib.instance.api.crateApiSetClientVolume( + clientId: clientId, + volume: volume, +); + +/// Send a text message to the specified target. +Future sendChatMessage({ + required String message, + required BridgeMessageTarget target, +}) => RustLib.instance.api.crateApiSendChatMessage( + message: message, + target: target, +); + /// User-initiated diagnostic export. Returns a multi-line text /// blob, redacted per the production policy, that the user can /// share or copy. DEC-016 forbids automatic uploads — this is the @@ -256,6 +266,19 @@ Future getAudioProcessingConfig() => Future audioProcessingStats() => RustLib.instance.api.crateApiAudioProcessingStats(); +/// List available audio input and output devices from the platform. +Future listAudioDevices() => + RustLib.instance.api.crateApiListAudioDevices(); + +/// Set the preferred input device by name. Takes effect on next +/// `start_audio`. +Future setInputDevice({String? name}) => + RustLib.instance.api.crateApiSetInputDevice(name: name); + +/// Set the preferred output device by name. +Future setOutputDevice({String? name}) => + RustLib.instance.api.crateApiSetOutputDevice(name: name); + /// Configure the VAD model path. Future setVadModelPath({required String path}) => RustLib.instance.api.crateApiSetVadModelPath(path: path); @@ -273,6 +296,14 @@ Future setIosVoiceProcessingMode({ required BridgeIosVoiceProcessingMode mode, }) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode); +/// Set the preferred audio output route (Android/iOS). +void setAudioOutputRoute({required BridgeAudioRoute route}) => + RustLib.instance.api.crateApiSetAudioOutputRoute(route: route); + +/// Called from Flutter when the app enters background/foreground. +void recordLifecycleEvent({required String state}) => + RustLib.instance.api.crateApiRecordLifecycleEvent(state: state); + /// Bridge processing backend. enum BridgeAudioBackend { /// Platform voice processing. @@ -288,6 +319,53 @@ enum BridgeAudioBackend { noop, } +/// Audio device info from the platform. +class BridgeAudioDevice { + /// Human-readable device name. + final String name; + + /// True if the OS reports this as the default device. + final bool isDefault; + + const BridgeAudioDevice({required this.name, required this.isDefault}); + + @override + int get hashCode => name.hashCode ^ isDefault.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BridgeAudioDevice && + runtimeType == other.runtimeType && + name == other.name && + isDefault == other.isDefault; +} + +/// List of available audio devices. +class BridgeAudioDeviceList { + /// Available input devices. + final List inputDevices; + + /// Available output devices. + final List outputDevices; + + const BridgeAudioDeviceList({ + required this.inputDevices, + required this.outputDevices, + }); + + @override + int get hashCode => inputDevices.hashCode ^ outputDevices.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BridgeAudioDeviceList && + runtimeType == other.runtimeType && + inputDevices == other.inputDevices && + outputDevices == other.outputDevices; +} + /// P1 audio-processing configuration DTO. class BridgeAudioProcessingConfig { /// Route class. @@ -628,12 +706,17 @@ class BridgeChannel { /// True when the server marks the channel as password-protected. final bool hasPassword; + /// Talk power threshold required to speak in this channel. + /// None means no talk-power restriction. + final int? neededTalkPower; + const BridgeChannel({ required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, + this.neededTalkPower, }); @override @@ -642,7 +725,8 @@ class BridgeChannel { parent.hashCode ^ name.hashCode ^ order.hashCode ^ - hasPassword.hashCode; + hasPassword.hashCode ^ + neededTalkPower.hashCode; @override bool operator ==(Object other) => @@ -653,7 +737,8 @@ class BridgeChannel { parent == other.parent && name == other.name && order == other.order && - hasPassword == other.hasPassword; + hasPassword == other.hasPassword && + neededTalkPower == other.neededTalkPower; } /// Client as seen by Dart. @@ -679,6 +764,12 @@ class BridgeClient { /// True for TeamSpeak ServerQuery clients. final bool isServerQuery; + /// Current talk power value assigned by the server. + final int talkPower; + + /// True when the server has granted talk power regardless of numeric value. + final bool talkPowerGranted; + const BridgeClient({ required this.id, required this.channel, @@ -687,6 +778,8 @@ class BridgeClient { required this.outputMuted, required this.isSpeaking, required this.isServerQuery, + required this.talkPower, + required this.talkPowerGranted, }); @override @@ -697,7 +790,9 @@ class BridgeClient { inputMuted.hashCode ^ outputMuted.hashCode ^ isSpeaking.hashCode ^ - isServerQuery.hashCode; + isServerQuery.hashCode ^ + talkPower.hashCode ^ + talkPowerGranted.hashCode; @override bool operator ==(Object other) => @@ -710,7 +805,9 @@ class BridgeClient { inputMuted == other.inputMuted && outputMuted == other.outputMuted && isSpeaking == other.isSpeaking && - isServerQuery == other.isServerQuery; + isServerQuery == other.isServerQuery && + talkPower == other.talkPower && + talkPowerGranted == other.talkPowerGranted; } /// Bridge effect owner for AEC/NS/AGC. @@ -863,6 +960,27 @@ sealed class BridgeEvent with _$BridgeEvent { /// Resolved permission state. required PermissionStateKind state, }) = BridgeEvent_PermissionState; + + /// A text message received from the server. + const factory BridgeEvent.chatMessage({ + /// Client id of the sender. + required BigInt senderId, + + /// Nickname of the sender. + required String senderName, + + /// Message content. + required String message, + + /// Target scope (server/channel/private/poke). + required BridgeMessageTarget target, + }) = BridgeEvent_ChatMessage; + + /// Audio route changed (speaker/earpiece/BT/wired). + const factory BridgeEvent.audioRouteChanged({ + /// The new audio route. + required BridgeAudioRoute route, + }) = BridgeEvent_AudioRouteChanged; } /// Bridge iOS voice-processing mode. @@ -874,6 +992,25 @@ enum BridgeIosVoiceProcessingMode { sonoraExperimental, } +@freezed +sealed class BridgeMessageTarget with _$BridgeMessageTarget { + const BridgeMessageTarget._(); + + /// Broadcast to entire server. + const factory BridgeMessageTarget.server() = BridgeMessageTarget_Server; + + /// Broadcast to current channel. + const factory BridgeMessageTarget.channel() = BridgeMessageTarget_Channel; + + /// Private message to a specific client. + const factory BridgeMessageTarget.client(BigInt field0) = + BridgeMessageTarget_Client; + + /// Poke a specific client. + const factory BridgeMessageTarget.poke(BigInt field0) = + BridgeMessageTarget_Poke; +} + /// Coarse OS-reported network state. Mirrors /// [`chanora_core::NetworkState`] across the bridge. enum BridgeNetworkState { @@ -887,6 +1024,60 @@ enum BridgeNetworkState { offline, } +/// Persisted PTT binding display state for the UI. +class BridgePttBinding { + /// Stable input category string (`""`, `"keyboard"`, or + /// `"mouse-side-button"`). + final String inputClass; + + /// Display-only key label; empty when no binding is active. + final String keyLabel; + + const BridgePttBinding({required this.inputClass, required this.keyLabel}); + + @override + int get hashCode => inputClass.hashCode ^ keyLabel.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BridgePttBinding && + runtimeType == other.runtimeType && + inputClass == other.inputClass && + keyLabel == other.keyLabel; +} + +/// Privacy-safe PTT capability descriptor for the UI. +class BridgePttDescriptor { + /// Stable capability level name. + final String level; + + /// Stable backend identifier. + final String backendId; + + /// Coarse bound input class; empty when no binding is active. + final String boundInputClass; + + const BridgePttDescriptor({ + required this.level, + required this.backendId, + required this.boundInputClass, + }); + + @override + int get hashCode => + level.hashCode ^ backendId.hashCode ^ boundInputClass.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BridgePttDescriptor && + runtimeType == other.runtimeType && + level == other.level && + backendId == other.backendId && + boundInputClass == other.boundInputClass; +} + /// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings; /// the bridge never carries raw key codes. enum BridgePttInputClass { diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index fe1d46d..bf123b7 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -69,7 +69,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapabilit return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null: return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null: -return permissionState(_that);case _: +return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null: +return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return audioRouteChanged(_that);case _: return orElse(); } @@ -87,7 +89,7 @@ return permissionState(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,}){ +@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){ final _that = this; switch (_that) { case BridgeEvent_Connected(): @@ -101,7 +103,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability(): return pttCapability(_that);case BridgeEvent_VoiceState(): return voiceState(_that);case BridgeEvent_InterruptionState(): return interruptionState(_that);case BridgeEvent_PermissionState(): -return permissionState(_that);} +return permissionState(_that);case BridgeEvent_ChatMessage(): +return chatMessage(_that);case BridgeEvent_AudioRouteChanged(): +return audioRouteChanged(_that);} } /// A variant of `map` that fallback to returning `null`. /// @@ -115,7 +119,7 @@ return permissionState(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -129,7 +133,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapabilit return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null: return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null: -return permissionState(_that);case _: +return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null: +return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return audioRouteChanged(_that);case _: return null; } @@ -146,7 +152,7 @@ return permissionState(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -159,7 +165,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null: return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: -return permissionState(_that.permission,_that.state);case _: +return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null: +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return audioRouteChanged(_that.route);case _: return orElse(); } @@ -177,7 +185,7 @@ return permissionState(_that.permission,_that.state);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -190,7 +198,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState(): return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState(): return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState(): -return permissionState(_that.permission,_that.state);} +return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage(): +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged(): +return audioRouteChanged(_that.route);} } /// A variant of `when` that fallback to returning `null` /// @@ -204,7 +214,7 @@ return permissionState(_that.permission,_that.state);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -217,7 +227,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null: return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: -return permissionState(_that.permission,_that.state);case _: +return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null: +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return audioRouteChanged(_that.route);case _: return null; } @@ -230,7 +242,7 @@ return permissionState(_that.permission,_that.state);case _: class BridgeEvent_Connected extends BridgeEvent { const BridgeEvent_Connected({required this.serverName}): super._(); - + /// Server name reported by the server snapshot. final String serverName; @@ -297,7 +309,7 @@ as String, class BridgeEvent_Lost extends BridgeEvent { const BridgeEvent_Lost({required this.reason}): super._(); - + /// Reason classification from the protocol layer. final String reason; @@ -364,7 +376,7 @@ as String, class BridgeEvent_Reconnecting extends BridgeEvent { const BridgeEvent_Reconnecting({required this.attempt, required this.delaySecs}): super._(); - + /// 1-based attempt counter for the current outage. final int attempt; @@ -434,7 +446,7 @@ as int, class BridgeEvent_Disconnected extends BridgeEvent { const BridgeEvent_Disconnected({required this.reason}): super._(); - + /// Reason classification. final String reason; @@ -501,7 +513,7 @@ as String, class BridgeEvent_AudioStarted extends BridgeEvent { const BridgeEvent_AudioStarted(): super._(); - + @@ -533,7 +545,7 @@ String toString() { class BridgeEvent_AudioStopped extends BridgeEvent { const BridgeEvent_AudioStopped(): super._(); - + @@ -565,7 +577,7 @@ String toString() { class BridgeEvent_SnapshotChanged extends BridgeEvent { const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._(); - + /// Latest channel count. final int channels; @@ -635,7 +647,7 @@ as int, class BridgeEvent_PttCapability extends BridgeEvent { const BridgeEvent_PttCapability({required this.level, required this.backendId, required this.boundInputClass}): super._(); - + /// Stable capability identifier (`"L0Focused"`, /// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`, @@ -711,7 +723,7 @@ as String, class BridgeEvent_VoiceState extends BridgeEvent { const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs, this.currentChannelId, this.pendingTargetChannelId, required this.canJoin, required this.canLeave, required this.joinSyncState, this.joinErrorCode}): super._(); - + /// True when the session is currently joined to a voice /// channel and the audio engine is running. @@ -806,7 +818,7 @@ as BridgeVoiceJoinErrorCode?, class BridgeEvent_InterruptionState extends BridgeEvent { const BridgeEvent_InterruptionState({required this.began, required this.shouldResume}): super._(); - + /// True when interruption began, false when it ended. final bool began; @@ -876,7 +888,7 @@ as bool, class BridgeEvent_PermissionState extends BridgeEvent { const BridgeEvent_PermissionState({required this.permission, required this.state}): super._(); - + /// Canonical Android permission identifier. final String permission; @@ -939,6 +951,526 @@ as PermissionStateKind, } +} + +/// @nodoc + + +class BridgeEvent_ChatMessage extends BridgeEvent { + const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._(); + + +/// Client id of the sender. + final BigInt senderId; +/// Nickname of the sender. + final String senderName; +/// Message content. + final String message; +/// Target scope (server/channel/private/poke). + final BridgeMessageTarget target; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeEvent_ChatMessageCopyWith get copyWith => _$BridgeEvent_ChatMessageCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)); +} + + +@override +int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target); + +@override +String toString() { + return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEventCopyWith<$Res> { + factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl; +@useResult +$Res call({ + BigInt senderId, String senderName, String message, BridgeMessageTarget target +}); + + +$BridgeMessageTargetCopyWith<$Res> get target; + +} +/// @nodoc +class _$BridgeEvent_ChatMessageCopyWithImpl<$Res> + implements $BridgeEvent_ChatMessageCopyWith<$Res> { + _$BridgeEvent_ChatMessageCopyWithImpl(this._self, this._then); + + final BridgeEvent_ChatMessage _self; + final $Res Function(BridgeEvent_ChatMessage) _then; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) { + return _then(BridgeEvent_ChatMessage( +senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable +as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable +as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable +as BridgeMessageTarget, + )); +} + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BridgeMessageTargetCopyWith<$Res> get target { + + return $BridgeMessageTargetCopyWith<$Res>(_self.target, (value) { + return _then(_self.copyWith(target: value)); + }); +} +} + +/// @nodoc + + +class BridgeEvent_AudioRouteChanged extends BridgeEvent { + const BridgeEvent_AudioRouteChanged({required this.route}): super._(); + + +/// The new audio route. + final BridgeAudioRoute route; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeEvent_AudioRouteChangedCopyWith get copyWith => _$BridgeEvent_AudioRouteChangedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_AudioRouteChanged&&(identical(other.route, route) || other.route == route)); +} + + +@override +int get hashCode => Object.hash(runtimeType,route); + +@override +String toString() { + return 'BridgeEvent.audioRouteChanged(route: $route)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeEvent_AudioRouteChangedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> { + factory $BridgeEvent_AudioRouteChangedCopyWith(BridgeEvent_AudioRouteChanged value, $Res Function(BridgeEvent_AudioRouteChanged) _then) = _$BridgeEvent_AudioRouteChangedCopyWithImpl; +@useResult +$Res call({ + BridgeAudioRoute route +}); + + + + +} +/// @nodoc +class _$BridgeEvent_AudioRouteChangedCopyWithImpl<$Res> + implements $BridgeEvent_AudioRouteChangedCopyWith<$Res> { + _$BridgeEvent_AudioRouteChangedCopyWithImpl(this._self, this._then); + + final BridgeEvent_AudioRouteChanged _self; + final $Res Function(BridgeEvent_AudioRouteChanged) _then; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? route = null,}) { + return _then(BridgeEvent_AudioRouteChanged( +route: null == route ? _self.route : route // ignore: cast_nullable_to_non_nullable +as BridgeAudioRoute, + )); +} + + +} + +/// @nodoc +mixin _$BridgeMessageTarget { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'BridgeMessageTarget()'; +} + + +} + +/// @nodoc +class $BridgeMessageTargetCopyWith<$Res> { +$BridgeMessageTargetCopyWith(BridgeMessageTarget _, $Res Function(BridgeMessageTarget) __); +} + + +/// Adds pattern-matching-related methods to [BridgeMessageTarget]. +extension BridgeMessageTargetPatterns on BridgeMessageTarget { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( BridgeMessageTarget_Server value)? server,TResult Function( BridgeMessageTarget_Channel value)? channel,TResult Function( BridgeMessageTarget_Client value)? client,TResult Function( BridgeMessageTarget_Poke value)? poke,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case BridgeMessageTarget_Server() when server != null: +return server(_that);case BridgeMessageTarget_Channel() when channel != null: +return channel(_that);case BridgeMessageTarget_Client() when client != null: +return client(_that);case BridgeMessageTarget_Poke() when poke != null: +return poke(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( BridgeMessageTarget_Server value) server,required TResult Function( BridgeMessageTarget_Channel value) channel,required TResult Function( BridgeMessageTarget_Client value) client,required TResult Function( BridgeMessageTarget_Poke value) poke,}){ +final _that = this; +switch (_that) { +case BridgeMessageTarget_Server(): +return server(_that);case BridgeMessageTarget_Channel(): +return channel(_that);case BridgeMessageTarget_Client(): +return client(_that);case BridgeMessageTarget_Poke(): +return poke(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeMessageTarget_Server value)? server,TResult? Function( BridgeMessageTarget_Channel value)? channel,TResult? Function( BridgeMessageTarget_Client value)? client,TResult? Function( BridgeMessageTarget_Poke value)? poke,}){ +final _that = this; +switch (_that) { +case BridgeMessageTarget_Server() when server != null: +return server(_that);case BridgeMessageTarget_Channel() when channel != null: +return channel(_that);case BridgeMessageTarget_Client() when client != null: +return client(_that);case BridgeMessageTarget_Poke() when poke != null: +return poke(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? server,TResult Function()? channel,TResult Function( BigInt field0)? client,TResult Function( BigInt field0)? poke,required TResult orElse(),}) {final _that = this; +switch (_that) { +case BridgeMessageTarget_Server() when server != null: +return server();case BridgeMessageTarget_Channel() when channel != null: +return channel();case BridgeMessageTarget_Client() when client != null: +return client(_that.field0);case BridgeMessageTarget_Poke() when poke != null: +return poke(_that.field0);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() server,required TResult Function() channel,required TResult Function( BigInt field0) client,required TResult Function( BigInt field0) poke,}) {final _that = this; +switch (_that) { +case BridgeMessageTarget_Server(): +return server();case BridgeMessageTarget_Channel(): +return channel();case BridgeMessageTarget_Client(): +return client(_that.field0);case BridgeMessageTarget_Poke(): +return poke(_that.field0);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? server,TResult? Function()? channel,TResult? Function( BigInt field0)? client,TResult? Function( BigInt field0)? poke,}) {final _that = this; +switch (_that) { +case BridgeMessageTarget_Server() when server != null: +return server();case BridgeMessageTarget_Channel() when channel != null: +return channel();case BridgeMessageTarget_Client() when client != null: +return client(_that.field0);case BridgeMessageTarget_Poke() when poke != null: +return poke(_that.field0);case _: + return null; + +} +} + +} + +/// @nodoc + + +class BridgeMessageTarget_Server extends BridgeMessageTarget { + const BridgeMessageTarget_Server(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Server); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'BridgeMessageTarget.server()'; +} + + +} + + + + +/// @nodoc + + +class BridgeMessageTarget_Channel extends BridgeMessageTarget { + const BridgeMessageTarget_Channel(): super._(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Channel); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'BridgeMessageTarget.channel()'; +} + + +} + + + + +/// @nodoc + + +class BridgeMessageTarget_Client extends BridgeMessageTarget { + const BridgeMessageTarget_Client(this.field0): super._(); + + + final BigInt field0; + +/// Create a copy of BridgeMessageTarget +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeMessageTarget_ClientCopyWith get copyWith => _$BridgeMessageTarget_ClientCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Client&&(identical(other.field0, field0) || other.field0 == field0)); +} + + +@override +int get hashCode => Object.hash(runtimeType,field0); + +@override +String toString() { + return 'BridgeMessageTarget.client(field0: $field0)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeMessageTarget_ClientCopyWith<$Res> implements $BridgeMessageTargetCopyWith<$Res> { + factory $BridgeMessageTarget_ClientCopyWith(BridgeMessageTarget_Client value, $Res Function(BridgeMessageTarget_Client) _then) = _$BridgeMessageTarget_ClientCopyWithImpl; +@useResult +$Res call({ + BigInt field0 +}); + + + + +} +/// @nodoc +class _$BridgeMessageTarget_ClientCopyWithImpl<$Res> + implements $BridgeMessageTarget_ClientCopyWith<$Res> { + _$BridgeMessageTarget_ClientCopyWithImpl(this._self, this._then); + + final BridgeMessageTarget_Client _self; + final $Res Function(BridgeMessageTarget_Client) _then; + +/// Create a copy of BridgeMessageTarget +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) { + return _then(BridgeMessageTarget_Client( +null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable +as BigInt, + )); +} + + +} + +/// @nodoc + + +class BridgeMessageTarget_Poke extends BridgeMessageTarget { + const BridgeMessageTarget_Poke(this.field0): super._(); + + + final BigInt field0; + +/// Create a copy of BridgeMessageTarget +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeMessageTarget_PokeCopyWith get copyWith => _$BridgeMessageTarget_PokeCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Poke&&(identical(other.field0, field0) || other.field0 == field0)); +} + + +@override +int get hashCode => Object.hash(runtimeType,field0); + +@override +String toString() { + return 'BridgeMessageTarget.poke(field0: $field0)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeMessageTarget_PokeCopyWith<$Res> implements $BridgeMessageTargetCopyWith<$Res> { + factory $BridgeMessageTarget_PokeCopyWith(BridgeMessageTarget_Poke value, $Res Function(BridgeMessageTarget_Poke) _then) = _$BridgeMessageTarget_PokeCopyWithImpl; +@useResult +$Res call({ + BigInt field0 +}); + + + + +} +/// @nodoc +class _$BridgeMessageTarget_PokeCopyWithImpl<$Res> + implements $BridgeMessageTarget_PokeCopyWith<$Res> { + _$BridgeMessageTarget_PokeCopyWithImpl(this._self, this._then); + + final BridgeMessageTarget_Poke _self; + final $Res Function(BridgeMessageTarget_Poke) _then; + +/// Create a copy of BridgeMessageTarget +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) { + return _then(BridgeMessageTarget_Poke( +null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable +as BigInt, + )); +} + + } // dart format on diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index f867ca5..130f739 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -436507436; + int get rustContentHash => 1433826599; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -105,7 +105,7 @@ abstract class RustLibApi extends BaseApi { Future crateApiGetAudioProcessingConfig(); - Future<(String, String)> crateApiGetPttBinding(); + Future crateApiGetPttBinding(); Future crateApiGetReleaseTailMs(); @@ -115,8 +115,6 @@ abstract class RustLibApi extends BaseApi { void crateApiHandleInterruptionEnded({required bool shouldResume}); - void crateApiHandleMediaServicesReset(); - void crateApiHandleMediaServicesResetWithRoute({required String routeClass}); void crateApiHandleRouteChange({required BridgeAudioRoute route}); @@ -125,6 +123,8 @@ abstract class RustLibApi extends BaseApi { Future crateApiIsConnected(); + Future crateApiListAudioDevices(); + Future> crateApiListBookmarks(); String crateApiLogFilePathStr(); @@ -134,14 +134,30 @@ abstract class RustLibApi extends BaseApi { required String password, }); - Future<(String, String, String)> crateApiPttDescriptor(); + Future crateApiPttDescriptor(); + + void crateApiRecordLifecycleEvent({required String state}); + + Future crateApiSendChatMessage({ + required String message, + required BridgeMessageTarget target, + }); + + void crateApiSetAudioOutputRoute({required BridgeAudioRoute route}); Future crateApiSetAudioProcessingConfig({ required BridgeAudioProcessingConfig config, }); + Future crateApiSetClientVolume({ + required BigInt clientId, + required double volume, + }); + Future crateApiSetHardMute({required bool muted}); + Future crateApiSetInputDevice({String? name}); + Future crateApiSetInputMuted({required bool muted}); Future crateApiSetIosVoiceProcessingMode({ @@ -150,6 +166,8 @@ abstract class RustLibApi extends BaseApi { void crateApiSetNetworkState({required BridgeNetworkState state}); + Future crateApiSetOutputDevice({String? name}); + Future crateApiSetOutputGain({required double gain}); Future crateApiSetOutputMuted({required bool muted}); @@ -505,7 +523,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future<(String, String)> crateApiGetPttBinding() { + Future crateApiGetPttBinding() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -518,7 +536,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); }, codec: SseCodec( - decodeSuccessData: sse_decode_record_string_string, + decodeSuccessData: sse_decode_bridge_ptt_binding, decodeErrorData: null, ), constMeta: kCrateApiGetPttBindingConstMeta, @@ -633,31 +651,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["shouldResume"], ); - @override - void crateApiHandleMediaServicesReset() { - return handler.executeSync( - SyncTask( - callFfi: () { - final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: null, - ), - constMeta: kCrateApiHandleMediaServicesResetConstMeta, - argValues: [], - apiImpl: this, - ), - ); - } - - TaskConstMeta get kCrateApiHandleMediaServicesResetConstMeta => - const TaskConstMeta( - debugName: "handle_media_services_reset", - argNames: [], - ); - @override void crateApiHandleMediaServicesResetWithRoute({required String routeClass}) { return handler.executeSync( @@ -665,7 +658,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(routeClass, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -691,7 +684,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_audio_route(route, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -719,7 +712,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 19, port: port_, ); }, @@ -746,7 +739,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 20, port: port_, ); }, @@ -764,6 +757,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiIsConnectedConstMeta => const TaskConstMeta(debugName: "is_connected", argNames: []); + @override + Future crateApiListAudioDevices() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 21, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bridge_audio_device_list, + decodeErrorData: null, + ), + constMeta: kCrateApiListAudioDevicesConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiListAudioDevicesConstMeta => + const TaskConstMeta(debugName: "list_audio_devices", argNames: []); + @override Future> crateApiListBookmarks() { return handler.executeNormal( @@ -848,7 +868,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future<(String, String, String)> crateApiPttDescriptor() { + Future crateApiPttDescriptor() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -861,7 +881,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); }, codec: SseCodec( - decodeSuccessData: sse_decode_record_string_string_string, + decodeSuccessData: sse_decode_bridge_ptt_descriptor, decodeErrorData: null, ), constMeta: kCrateApiPttDescriptorConstMeta, @@ -874,6 +894,92 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiPttDescriptorConstMeta => const TaskConstMeta(debugName: "ptt_descriptor", argNames: []); + @override + void crateApiRecordLifecycleEvent({required String state}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(state, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiRecordLifecycleEventConstMeta, + argValues: [state], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiRecordLifecycleEventConstMeta => + const TaskConstMeta( + debugName: "record_lifecycle_event", + argNames: ["state"], + ); + + @override + Future crateApiSendChatMessage({ + required String message, + required BridgeMessageTarget target, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(message, serializer); + sse_encode_box_autoadd_bridge_message_target(target, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 27, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSendChatMessageConstMeta, + argValues: [message, target], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSendChatMessageConstMeta => const TaskConstMeta( + debugName: "send_chat_message", + argNames: ["message", "target"], + ); + + @override + void crateApiSetAudioOutputRoute({required BridgeAudioRoute route}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bridge_audio_route(route, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSetAudioOutputRouteConstMeta, + argValues: [route], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetAudioOutputRouteConstMeta => + const TaskConstMeta( + debugName: "set_audio_output_route", + argNames: ["route"], + ); + @override Future crateApiSetAudioProcessingConfig({ required BridgeAudioProcessingConfig config, @@ -889,7 +995,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 29, port: port_, ); }, @@ -910,6 +1016,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["config"], ); + @override + Future crateApiSetClientVolume({ + required BigInt clientId, + required double volume, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_64(clientId, serializer); + sse_encode_f_32(volume, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 30, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetClientVolumeConstMeta, + argValues: [clientId, volume], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetClientVolumeConstMeta => const TaskConstMeta( + debugName: "set_client_volume", + argNames: ["clientId", "volume"], + ); + @override Future crateApiSetHardMute({required bool muted}) { return handler.executeNormal( @@ -920,7 +1060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 31, port: port_, ); }, @@ -938,6 +1078,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiSetHardMuteConstMeta => const TaskConstMeta(debugName: "set_hard_mute", argNames: ["muted"]); + @override + Future crateApiSetInputDevice({String? name}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_opt_String(name, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 32, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetInputDeviceConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetInputDeviceConstMeta => + const TaskConstMeta(debugName: "set_input_device", argNames: ["name"]); + @override Future crateApiSetInputMuted({required bool muted}) { return handler.executeNormal( @@ -948,7 +1116,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 33, port: port_, ); }, @@ -978,7 +1146,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 34, port: port_, ); }, @@ -1006,7 +1174,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1022,6 +1190,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiSetNetworkStateConstMeta => const TaskConstMeta(debugName: "set_network_state", argNames: ["state"]); + @override + Future crateApiSetOutputDevice({String? name}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_opt_String(name, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 36, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_bridge_error, + ), + constMeta: kCrateApiSetOutputDeviceConstMeta, + argValues: [name], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetOutputDeviceConstMeta => + const TaskConstMeta(debugName: "set_output_device", argNames: ["name"]); + @override Future crateApiSetOutputGain({required double gain}) { return handler.executeNormal( @@ -1032,7 +1228,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 37, port: port_, ); }, @@ -1060,7 +1256,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 38, port: port_, ); }, @@ -1088,7 +1284,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 39, port: port_, ); }, @@ -1120,7 +1316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 40, port: port_, ); }, @@ -1150,7 +1346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 41, port: port_, ); }, @@ -1178,7 +1374,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 42, port: port_, ); }, @@ -1208,7 +1404,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 37, + funcId: 43, port: port_, ); }, @@ -1236,7 +1432,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 44, port: port_, ); }, @@ -1263,7 +1459,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 45, port: port_, ); }, @@ -1291,7 +1487,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 46, port: port_, ); }, @@ -1323,7 +1519,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 47, port: port_, ); }, @@ -1352,7 +1548,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 48, port: port_, ); }, @@ -1409,6 +1605,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_bridge_bookmark(raw); } + @protected + BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_bridge_message_target(raw); + } + @protected BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( dynamic raw, @@ -1417,6 +1621,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_bridge_voice_join_error_code(raw); } + @protected + int dco_decode_box_autoadd_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + @protected BigInt dco_decode_box_autoadd_u_64(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1429,6 +1639,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeAudioBackend.values[raw as int]; } + @protected + BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return BridgeAudioDevice( + name: dco_decode_String(arr[0]), + isDefault: dco_decode_bool(arr[1]), + ); + } + + @protected + BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return BridgeAudioDeviceList( + inputDevices: dco_decode_list_bridge_audio_device(arr[0]), + outputDevices: dco_decode_list_bridge_audio_device(arr[1]), + ); + } + @protected BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config( dynamic raw, @@ -1525,14 +1759,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeChannel dco_decode_bridge_channel(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 5) - throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); + if (arr.length != 6) + throw Exception('unexpected arr length: expect 6 but see ${arr.length}'); return BridgeChannel( id: dco_decode_u_64(arr[0]), parent: dco_decode_u_64(arr[1]), name: dco_decode_String(arr[2]), order: dco_decode_i_64(arr[3]), hasPassword: dco_decode_bool(arr[4]), + neededTalkPower: dco_decode_opt_box_autoadd_i_32(arr[5]), ); } @@ -1540,8 +1775,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeClient dco_decode_bridge_client(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 7) - throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); return BridgeClient( id: dco_decode_u_64(arr[0]), channel: dco_decode_u_64(arr[1]), @@ -1550,6 +1785,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { outputMuted: dco_decode_bool(arr[4]), isSpeaking: dco_decode_bool(arr[5]), isServerQuery: dco_decode_bool(arr[6]), + talkPower: dco_decode_i_32(arr[7]), + talkPowerGranted: dco_decode_bool(arr[8]), ); } @@ -1642,6 +1879,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { permission: dco_decode_String(raw[1]), state: dco_decode_permission_state_kind(raw[2]), ); + case 11: + return BridgeEvent_ChatMessage( + senderId: dco_decode_u_64(raw[1]), + senderName: dco_decode_String(raw[2]), + message: dco_decode_String(raw[3]), + target: dco_decode_box_autoadd_bridge_message_target(raw[4]), + ); + case 12: + return BridgeEvent_AudioRouteChanged( + route: dco_decode_bridge_audio_route(raw[1]), + ); default: throw Exception("unreachable"); } @@ -1655,12 +1903,54 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeIosVoiceProcessingMode.values[raw as int]; } + @protected + BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return BridgeMessageTarget_Server(); + case 1: + return BridgeMessageTarget_Channel(); + case 2: + return BridgeMessageTarget_Client(dco_decode_u_64(raw[1])); + case 3: + return BridgeMessageTarget_Poke(dco_decode_u_64(raw[1])); + default: + throw Exception("unreachable"); + } + } + @protected BridgeNetworkState dco_decode_bridge_network_state(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs return BridgeNetworkState.values[raw as int]; } + @protected + BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return BridgePttBinding( + inputClass: dco_decode_String(arr[0]), + keyLabel: dco_decode_String(arr[1]), + ); + } + + @protected + BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 3) + throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); + return BridgePttDescriptor( + level: dco_decode_String(arr[0]), + backendId: dco_decode_String(arr[1]), + boundInputClass: dco_decode_String(arr[2]), + ); + } + @protected BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1730,6 +2020,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dcoDecodeI64(raw); } + @protected + List dco_decode_list_bridge_audio_device(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_bridge_audio_device).toList(); + } + @protected List dco_decode_list_bridge_bookmark(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1754,6 +2050,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as Uint8List; } + @protected + String? dco_decode_opt_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_String(raw); + } + @protected BridgeVoiceJoinErrorCode? dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) { @@ -1763,6 +2065,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { : dco_decode_box_autoadd_bridge_voice_join_error_code(raw); } + @protected + int? dco_decode_opt_box_autoadd_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_i_32(raw); + } + @protected BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1775,30 +2083,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return PermissionStateKind.values[raw as int]; } - @protected - (String, String) dco_decode_record_string_string(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - final arr = raw as List; - if (arr.length != 2) { - throw Exception('Expected 2 elements, got ${arr.length}'); - } - return (dco_decode_String(arr[0]), dco_decode_String(arr[1])); - } - - @protected - (String, String, String) dco_decode_record_string_string_string(dynamic raw) { - // Codec=Dco (DartCObject based), see doc to use other codecs - final arr = raw as List; - if (arr.length != 3) { - throw Exception('Expected 3 elements, got ${arr.length}'); - } - return ( - dco_decode_String(arr[0]), - dco_decode_String(arr[1]), - dco_decode_String(arr[2]), - ); - } - @protected int dco_decode_u_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1868,6 +2152,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_bridge_bookmark(deserializer)); } + @protected + BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_bridge_message_target(deserializer)); + } + @protected BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, @@ -1876,6 +2168,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_bridge_voice_join_error_code(deserializer)); } + @protected + int sse_decode_box_autoadd_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_i_32(deserializer)); + } + @protected BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1891,6 +2189,29 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeAudioBackend.values[inner]; } + @protected + BridgeAudioDevice sse_decode_bridge_audio_device( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_name = sse_decode_String(deserializer); + var var_isDefault = sse_decode_bool(deserializer); + return BridgeAudioDevice(name: var_name, isDefault: var_isDefault); + } + + @protected + BridgeAudioDeviceList sse_decode_bridge_audio_device_list( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_inputDevices = sse_decode_list_bridge_audio_device(deserializer); + var var_outputDevices = sse_decode_list_bridge_audio_device(deserializer); + return BridgeAudioDeviceList( + inputDevices: var_inputDevices, + outputDevices: var_outputDevices, + ); + } + @protected BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config( SseDeserializer deserializer, @@ -2019,12 +2340,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_name = sse_decode_String(deserializer); var var_order = sse_decode_i_64(deserializer); var var_hasPassword = sse_decode_bool(deserializer); + var var_neededTalkPower = sse_decode_opt_box_autoadd_i_32(deserializer); return BridgeChannel( id: var_id, parent: var_parent, name: var_name, order: var_order, hasPassword: var_hasPassword, + neededTalkPower: var_neededTalkPower, ); } @@ -2038,6 +2361,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_outputMuted = sse_decode_bool(deserializer); var var_isSpeaking = sse_decode_bool(deserializer); var var_isServerQuery = sse_decode_bool(deserializer); + var var_talkPower = sse_decode_i_32(deserializer); + var var_talkPowerGranted = sse_decode_bool(deserializer); return BridgeClient( id: var_id, channel: var_channel, @@ -2046,6 +2371,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { outputMuted: var_outputMuted, isSpeaking: var_isSpeaking, isServerQuery: var_isServerQuery, + talkPower: var_talkPower, + talkPowerGranted: var_talkPowerGranted, ); } @@ -2178,6 +2505,22 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { permission: var_permission, state: var_state, ); + case 11: + var var_senderId = sse_decode_u_64(deserializer); + var var_senderName = sse_decode_String(deserializer); + var var_message = sse_decode_String(deserializer); + var var_target = sse_decode_box_autoadd_bridge_message_target( + deserializer, + ); + return BridgeEvent_ChatMessage( + senderId: var_senderId, + senderName: var_senderName, + message: var_message, + target: var_target, + ); + case 12: + var var_route = sse_decode_bridge_audio_route(deserializer); + return BridgeEvent_AudioRouteChanged(route: var_route); default: throw UnimplementedError(''); } @@ -2192,6 +2535,29 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeIosVoiceProcessingMode.values[inner]; } + @protected + BridgeMessageTarget sse_decode_bridge_message_target( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + return BridgeMessageTarget_Server(); + case 1: + return BridgeMessageTarget_Channel(); + case 2: + var var_field0 = sse_decode_u_64(deserializer); + return BridgeMessageTarget_Client(var_field0); + case 3: + var var_field0 = sse_decode_u_64(deserializer); + return BridgeMessageTarget_Poke(var_field0); + default: + throw UnimplementedError(''); + } + } + @protected BridgeNetworkState sse_decode_bridge_network_state( SseDeserializer deserializer, @@ -2201,6 +2567,29 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeNetworkState.values[inner]; } + @protected + BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_inputClass = sse_decode_String(deserializer); + var var_keyLabel = sse_decode_String(deserializer); + return BridgePttBinding(inputClass: var_inputClass, keyLabel: var_keyLabel); + } + + @protected + BridgePttDescriptor sse_decode_bridge_ptt_descriptor( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_level = sse_decode_String(deserializer); + var var_backendId = sse_decode_String(deserializer); + var var_boundInputClass = sse_decode_String(deserializer); + return BridgePttDescriptor( + level: var_level, + backendId: var_backendId, + boundInputClass: var_boundInputClass, + ); + } + @protected BridgePttInputClass sse_decode_bridge_ptt_input_class( SseDeserializer deserializer, @@ -2283,6 +2672,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getPlatformInt64(); } + @protected + List sse_decode_list_bridge_audio_device( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_bridge_audio_device(deserializer)); + } + return ans_; + } + @protected List sse_decode_list_bridge_bookmark( SseDeserializer deserializer, @@ -2332,6 +2735,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8List(len_); } + @protected + String? sse_decode_opt_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_String(deserializer)); + } else { + return null; + } + } + @protected BridgeVoiceJoinErrorCode? sse_decode_opt_box_autoadd_bridge_voice_join_error_code( @@ -2348,6 +2762,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_i_32(deserializer)); + } else { + return null; + } + } + @protected BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2368,27 +2793,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return PermissionStateKind.values[inner]; } - @protected - (String, String) sse_decode_record_string_string( - SseDeserializer deserializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - var var_field0 = sse_decode_String(deserializer); - var var_field1 = sse_decode_String(deserializer); - return (var_field0, var_field1); - } - - @protected - (String, String, String) sse_decode_record_string_string_string( - SseDeserializer deserializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - var var_field0 = sse_decode_String(deserializer); - var var_field1 = sse_decode_String(deserializer); - var var_field2 = sse_decode_String(deserializer); - return (var_field0, var_field1, var_field2); - } - @protected int sse_decode_u_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2468,6 +2872,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bridge_bookmark(self, serializer); } + @protected + void sse_encode_box_autoadd_bridge_message_target( + BridgeMessageTarget self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bridge_message_target(self, serializer); + } + @protected void sse_encode_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode self, @@ -2477,6 +2890,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bridge_voice_join_error_code(self, serializer); } + @protected + void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self, serializer); + } + @protected void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2492,6 +2911,26 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(self.index, serializer); } + @protected + void sse_encode_bridge_audio_device( + BridgeAudioDevice self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.name, serializer); + sse_encode_bool(self.isDefault, serializer); + } + + @protected + void sse_encode_bridge_audio_device_list( + BridgeAudioDeviceList self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_bridge_audio_device(self.inputDevices, serializer); + sse_encode_list_bridge_audio_device(self.outputDevices, serializer); + } + @protected void sse_encode_bridge_audio_processing_config( BridgeAudioProcessingConfig self, @@ -2584,6 +3023,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.name, serializer); sse_encode_i_64(self.order, serializer); sse_encode_bool(self.hasPassword, serializer); + sse_encode_opt_box_autoadd_i_32(self.neededTalkPower, serializer); } @protected @@ -2596,6 +3036,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bool(self.outputMuted, serializer); sse_encode_bool(self.isSpeaking, serializer); sse_encode_bool(self.isServerQuery, serializer); + sse_encode_i_32(self.talkPower, serializer); + sse_encode_bool(self.talkPowerGranted, serializer); } @protected @@ -2715,6 +3157,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(10, serializer); sse_encode_String(permission, serializer); sse_encode_permission_state_kind(state, serializer); + case BridgeEvent_ChatMessage( + senderId: final senderId, + senderName: final senderName, + message: final message, + target: final target, + ): + sse_encode_i_32(11, serializer); + sse_encode_u_64(senderId, serializer); + sse_encode_String(senderName, serializer); + sse_encode_String(message, serializer); + sse_encode_box_autoadd_bridge_message_target(target, serializer); + case BridgeEvent_AudioRouteChanged(route: final route): + sse_encode_i_32(12, serializer); + sse_encode_bridge_audio_route(route, serializer); } } @@ -2727,6 +3183,26 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(self.index, serializer); } + @protected + void sse_encode_bridge_message_target( + BridgeMessageTarget self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case BridgeMessageTarget_Server(): + sse_encode_i_32(0, serializer); + case BridgeMessageTarget_Channel(): + sse_encode_i_32(1, serializer); + case BridgeMessageTarget_Client(field0: final field0): + sse_encode_i_32(2, serializer); + sse_encode_u_64(field0, serializer); + case BridgeMessageTarget_Poke(field0: final field0): + sse_encode_i_32(3, serializer); + sse_encode_u_64(field0, serializer); + } + } + @protected void sse_encode_bridge_network_state( BridgeNetworkState self, @@ -2736,6 +3212,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(self.index, serializer); } + @protected + void sse_encode_bridge_ptt_binding( + BridgePttBinding self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.inputClass, serializer); + sse_encode_String(self.keyLabel, serializer); + } + + @protected + void sse_encode_bridge_ptt_descriptor( + BridgePttDescriptor self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.level, serializer); + sse_encode_String(self.backendId, serializer); + sse_encode_String(self.boundInputClass, serializer); + } + @protected void sse_encode_bridge_ptt_input_class( BridgePttInputClass self, @@ -2814,6 +3311,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putPlatformInt64(self); } + @protected + void sse_encode_list_bridge_audio_device( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_bridge_audio_device(item, serializer); + } + } + @protected void sse_encode_list_bridge_bookmark( List self, @@ -2860,6 +3369,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8List(self); } + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_String(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode? self, @@ -2873,6 +3392,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_i_32(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2892,27 +3421,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(self.index, serializer); } - @protected - void sse_encode_record_string_string( - (String, String) self, - SseSerializer serializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_String(self.$1, serializer); - sse_encode_String(self.$2, serializer); - } - - @protected - void sse_encode_record_string_string_string( - (String, String, String) self, - SseSerializer serializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_String(self.$1, serializer); - sse_encode_String(self.$2, serializer); - sse_encode_String(self.$3, serializer); - } - @protected void sse_encode_u_32(int self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index a444e1b..cdcd3a7 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -40,17 +40,29 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw); + @protected + BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw); + @protected BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( dynamic raw, ); + @protected + int dco_decode_box_autoadd_i_32(dynamic raw); + @protected BigInt dco_decode_box_autoadd_u_64(dynamic raw); @protected BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw); + @protected + BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw); + + @protected + BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw); + @protected BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config( dynamic raw, @@ -90,9 +102,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { dynamic raw, ); + @protected + BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw); + @protected BridgeNetworkState dco_decode_bridge_network_state(dynamic raw); + @protected + BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw); + + @protected + BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw); + @protected BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw); @@ -120,6 +141,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected PlatformInt64 dco_decode_i_64(dynamic raw); + @protected + List dco_decode_list_bridge_audio_device(dynamic raw); + @protected List dco_decode_list_bridge_bookmark(dynamic raw); @@ -132,22 +156,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + String? dco_decode_opt_String(dynamic raw); + @protected BridgeVoiceJoinErrorCode? dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw); + @protected + int? dco_decode_opt_box_autoadd_i_32(dynamic raw); + @protected BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); @protected PermissionStateKind dco_decode_permission_state_kind(dynamic raw); - @protected - (String, String) dco_decode_record_string_string(dynamic raw); - - @protected - (String, String, String) dco_decode_record_string_string_string(dynamic raw); - @protected int dco_decode_u_32(dynamic raw); @@ -185,11 +209,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target( + SseDeserializer deserializer, + ); + @protected BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, ); + @protected + int sse_decode_box_autoadd_i_32(SseDeserializer deserializer); + @protected BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); @@ -198,6 +230,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeAudioDevice sse_decode_bridge_audio_device( + SseDeserializer deserializer, + ); + + @protected + BridgeAudioDeviceList sse_decode_bridge_audio_device_list( + SseDeserializer deserializer, + ); + @protected BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config( SseDeserializer deserializer, @@ -239,11 +281,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeMessageTarget sse_decode_bridge_message_target( + SseDeserializer deserializer, + ); + @protected BridgeNetworkState sse_decode_bridge_network_state( SseDeserializer deserializer, ); + @protected + BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer); + + @protected + BridgePttDescriptor sse_decode_bridge_ptt_descriptor( + SseDeserializer deserializer, + ); + @protected BridgePttInputClass sse_decode_bridge_ptt_input_class( SseDeserializer deserializer, @@ -279,6 +334,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); + @protected + List sse_decode_list_bridge_audio_device( + SseDeserializer deserializer, + ); + @protected List sse_decode_list_bridge_bookmark( SseDeserializer deserializer, @@ -297,12 +357,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + @protected BridgeVoiceJoinErrorCode? sse_decode_opt_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, ); + @protected + int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer); + @protected BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); @@ -311,16 +377,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); - @protected - (String, String) sse_decode_record_string_string( - SseDeserializer deserializer, - ); - - @protected - (String, String, String) sse_decode_record_string_string_string( - SseDeserializer deserializer, - ); - @protected int sse_decode_u_32(SseDeserializer deserializer); @@ -363,12 +419,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_bridge_message_target( + BridgeMessageTarget self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode self, SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer); + @protected void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); @@ -378,6 +443,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_audio_device( + BridgeAudioDevice self, + SseSerializer serializer, + ); + + @protected + void sse_encode_bridge_audio_device_list( + BridgeAudioDeviceList self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_audio_processing_config( BridgeAudioProcessingConfig self, @@ -432,12 +509,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_message_target( + BridgeMessageTarget self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_network_state( BridgeNetworkState self, SseSerializer serializer, ); + @protected + void sse_encode_bridge_ptt_binding( + BridgePttBinding self, + SseSerializer serializer, + ); + + @protected + void sse_encode_bridge_ptt_descriptor( + BridgePttDescriptor self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_ptt_input_class( BridgePttInputClass self, @@ -483,6 +578,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); + @protected + void sse_encode_list_bridge_audio_device( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_list_bridge_bookmark( List self, @@ -507,12 +608,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode? self, SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); @@ -522,18 +629,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); - @protected - void sse_encode_record_string_string( - (String, String) self, - SseSerializer serializer, - ); - - @protected - void sse_encode_record_string_string_string( - (String, String, String) self, - SseSerializer serializer, - ); - @protected void sse_encode_u_32(int self, SseSerializer serializer); diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index 7c32ea5..20bd007 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -42,17 +42,29 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw); + @protected + BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw); + @protected BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( dynamic raw, ); + @protected + int dco_decode_box_autoadd_i_32(dynamic raw); + @protected BigInt dco_decode_box_autoadd_u_64(dynamic raw); @protected BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw); + @protected + BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw); + + @protected + BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw); + @protected BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config( dynamic raw, @@ -92,9 +104,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { dynamic raw, ); + @protected + BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw); + @protected BridgeNetworkState dco_decode_bridge_network_state(dynamic raw); + @protected + BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw); + + @protected + BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw); + @protected BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw); @@ -122,6 +143,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected PlatformInt64 dco_decode_i_64(dynamic raw); + @protected + List dco_decode_list_bridge_audio_device(dynamic raw); + @protected List dco_decode_list_bridge_bookmark(dynamic raw); @@ -134,22 +158,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + String? dco_decode_opt_String(dynamic raw); + @protected BridgeVoiceJoinErrorCode? dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw); + @protected + int? dco_decode_opt_box_autoadd_i_32(dynamic raw); + @protected BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); @protected PermissionStateKind dco_decode_permission_state_kind(dynamic raw); - @protected - (String, String) dco_decode_record_string_string(dynamic raw); - - @protected - (String, String, String) dco_decode_record_string_string_string(dynamic raw); - @protected int dco_decode_u_32(dynamic raw); @@ -187,11 +211,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target( + SseDeserializer deserializer, + ); + @protected BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, ); + @protected + int sse_decode_box_autoadd_i_32(SseDeserializer deserializer); + @protected BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); @@ -200,6 +232,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeAudioDevice sse_decode_bridge_audio_device( + SseDeserializer deserializer, + ); + + @protected + BridgeAudioDeviceList sse_decode_bridge_audio_device_list( + SseDeserializer deserializer, + ); + @protected BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config( SseDeserializer deserializer, @@ -241,11 +283,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeMessageTarget sse_decode_bridge_message_target( + SseDeserializer deserializer, + ); + @protected BridgeNetworkState sse_decode_bridge_network_state( SseDeserializer deserializer, ); + @protected + BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer); + + @protected + BridgePttDescriptor sse_decode_bridge_ptt_descriptor( + SseDeserializer deserializer, + ); + @protected BridgePttInputClass sse_decode_bridge_ptt_input_class( SseDeserializer deserializer, @@ -281,6 +336,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); + @protected + List sse_decode_list_bridge_audio_device( + SseDeserializer deserializer, + ); + @protected List sse_decode_list_bridge_bookmark( SseDeserializer deserializer, @@ -299,12 +359,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + @protected BridgeVoiceJoinErrorCode? sse_decode_opt_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, ); + @protected + int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer); + @protected BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); @@ -313,16 +379,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); - @protected - (String, String) sse_decode_record_string_string( - SseDeserializer deserializer, - ); - - @protected - (String, String, String) sse_decode_record_string_string_string( - SseDeserializer deserializer, - ); - @protected int sse_decode_u_32(SseDeserializer deserializer); @@ -365,12 +421,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_bridge_message_target( + BridgeMessageTarget self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode self, SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer); + @protected void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); @@ -380,6 +445,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_audio_device( + BridgeAudioDevice self, + SseSerializer serializer, + ); + + @protected + void sse_encode_bridge_audio_device_list( + BridgeAudioDeviceList self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_audio_processing_config( BridgeAudioProcessingConfig self, @@ -434,12 +511,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_message_target( + BridgeMessageTarget self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_network_state( BridgeNetworkState self, SseSerializer serializer, ); + @protected + void sse_encode_bridge_ptt_binding( + BridgePttBinding self, + SseSerializer serializer, + ); + + @protected + void sse_encode_bridge_ptt_descriptor( + BridgePttDescriptor self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_ptt_input_class( BridgePttInputClass self, @@ -485,6 +580,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); + @protected + void sse_encode_list_bridge_audio_device( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_list_bridge_bookmark( List self, @@ -509,12 +610,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode? self, SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); @@ -524,18 +631,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); - @protected - void sse_encode_record_string_string( - (String, String) self, - SseSerializer serializer, - ); - - @protected - void sse_encode_record_string_string_string( - (String, String, String) self, - SseSerializer serializer, - ); - @protected void sse_encode_u_32(int self, SseSerializer serializer); diff --git a/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart b/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart new file mode 100644 index 0000000..255d7aa --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +import '../src/rust/api.dart' as rust; + +/// Loads available input/output audio devices. +typedef AudioDeviceListLoader = Future Function(); + +/// Persists a selected audio device name. +typedef AudioDeviceSetter = Future Function({String? name}); + +/// Which desktop audio device group this tile manages. +enum AudioDeviceKind { + /// Capture device. + input, + + /// Playback device. + output, +} + +/// Desktop audio device selector tile. +class AudioDeviceListTile extends StatefulWidget { + /// Construct an audio device list tile. + const AudioDeviceListTile({ + super.key, + required this.label, + required this.kind, + AudioDeviceListLoader? loadDevices, + AudioDeviceSetter? setInputDevice, + AudioDeviceSetter? setOutputDevice, + }) : loadDevices = loadDevices ?? rust.listAudioDevices, + setInputDevice = setInputDevice ?? rust.setInputDevice, + setOutputDevice = setOutputDevice ?? rust.setOutputDevice; + + /// Tile title. + final String label; + + /// Device group managed by this tile. + final AudioDeviceKind kind; + + /// Loads available devices. + final AudioDeviceListLoader loadDevices; + + /// Selects an input device. + final AudioDeviceSetter setInputDevice; + + /// Selects an output device. + final AudioDeviceSetter setOutputDevice; + + @override + State createState() => _AudioDeviceListTileState(); +} + +class _AudioDeviceListTileState extends State { + List _devices = []; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _loadDevices(); + } + + Future _loadDevices() async { + final list = await widget.loadDevices(); + if (!mounted) return; + setState(() { + _devices = switch (widget.kind) { + AudioDeviceKind.input => list.inputDevices, + AudioDeviceKind.output => list.outputDevices, + }; + _loaded = true; + }); + } + + Future _selectDevice(rust.BridgeAudioDevice device) async { + switch (widget.kind) { + case AudioDeviceKind.input: + await widget.setInputDevice(name: device.name); + case AudioDeviceKind.output: + await widget.setOutputDevice(name: device.name); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${widget.label} set to ${device.name}'), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_loaded) { + return ListTile( + title: Text(widget.label), + subtitle: const Text('Loading...'), + trailing: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + if (_devices.isEmpty) { + return ListTile( + title: Text(widget.label), + subtitle: const Text('System default'), + leading: const Icon(Icons.check_circle_outline, size: 18), + ); + } + return ExpansionTile( + title: Text(widget.label), + subtitle: Text('${_devices.length} available'), + leading: const Icon(Icons.headphones, size: 18), + children: [ + for (final device in _devices) + ListTile( + dense: true, + title: Text(device.name, style: const TextStyle(fontSize: 13)), + trailing: device.isDefault + ? const Icon(Icons.check, size: 16, color: Colors.green) + : null, + onTap: device.isDefault ? null : () => _selectDevice(device), + ), + ], + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/audio_output_tile.dart b/apps/chanora_flutter/lib/widgets/audio_output_tile.dart new file mode 100644 index 0000000..aad16cd --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/audio_output_tile.dart @@ -0,0 +1,567 @@ +import 'dart:async' show StreamSubscription, unawaited; +import 'dart:io' show Platform; + +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:haptic_kit/haptic_kit.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../services/android_audio_output_devices.dart'; + +/// Tile that displays the active audio output route and opens the +/// native picker on tap. +class AudioOutputTile extends StatefulWidget { + /// Construct an audio output tile. + const AudioOutputTile({super.key}); + + @override + State createState() => AudioOutputTileState(); +} + +/// State for [AudioOutputTile]. +class AudioOutputTileState extends State { + static const _androidOutputChannel = MethodChannel('app.audio_output'); + static const _androidOutputEvents = EventChannel('app.audio_output/events'); + + AVAudioSessionPortDescription? _activeOutput; + StreamSubscription? _routeSub; + StreamSubscription? _androidOutputSub; + List _androidDevices = const []; + bool _androidLoading = false; + + @override + void initState() { + super.initState(); + if (!kIsWeb && Platform.isAndroid) { + _refreshAndroidDevices(); + _androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen(( + _, + ) { + if (!mounted) return; + _refreshAndroidDevices(); + }); + return; + } + _refresh(); + _routeSub = AVAudioSession().routeChangeStream.listen((_) { + if (!mounted) return; + _refresh(); + }); + } + + @override + void dispose() { + _routeSub?.cancel(); + _routeSub = null; + _androidOutputSub?.cancel(); + _androidOutputSub = null; + super.dispose(); + } + + Future _refreshAndroidDevices() async { + if (_androidLoading) return; + setState(() => _androidLoading = true); + try { + final raw = + await _androidOutputChannel.invokeListMethod( + 'getOutputDevices', + ) ?? + const []; + final devices = parseAndroidAudioOutputDevices(raw); + if (!mounted) return; + setState(() { + _androidDevices = devices; + _androidLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _androidLoading = false); + } + } + + Future _refresh() async { + try { + final route = await AVAudioSession().currentRoute; + if (!mounted) return; + setState(() { + _activeOutput = route.outputs.firstOrNull; + }); + } catch (_) { + // AVAudioSession may transiently throw before the session is active. + } + } + + static String portLabel( + AVAudioSessionPort? type, + String fallback, + AppL10n l10n, + ) { + switch (type) { + case AVAudioSessionPort.builtInSpeaker: + return l10n.audioRouteSpeaker; + case AVAudioSessionPort.builtInReceiver: + return l10n.audioRouteReceiver; + case AVAudioSessionPort.bluetoothHfp: + case AVAudioSessionPort.bluetoothA2dp: + case AVAudioSessionPort.bluetoothLe: + return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback; + case AVAudioSessionPort.headphones: + case AVAudioSessionPort.headsetMic: + return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback; + case AVAudioSessionPort.carAudio: + return l10n.audioRouteCarAudio; + case AVAudioSessionPort.airPlay: + return l10n.audioRouteAirplay; + case AVAudioSessionPort.builtInMic: + return fallback.isEmpty ? l10n.audioRouteReceiver : fallback; + case null: + default: + return fallback.isEmpty ? l10n.audioRouteUnknown : fallback; + } + } + + static IconData portIcon(AVAudioSessionPort? type) { + switch (type) { + case AVAudioSessionPort.builtInSpeaker: + return Icons.volume_up; + case AVAudioSessionPort.builtInReceiver: + return Icons.phone_in_talk; + case AVAudioSessionPort.bluetoothHfp: + case AVAudioSessionPort.bluetoothA2dp: + case AVAudioSessionPort.bluetoothLe: + return Icons.bluetooth_audio; + case AVAudioSessionPort.headphones: + case AVAudioSessionPort.headsetMic: + return Icons.headset; + case AVAudioSessionPort.carAudio: + return Icons.directions_car; + case AVAudioSessionPort.airPlay: + return Icons.airplay; + default: + return Icons.speaker; + } + } + + Future _openPicker() async { + if (!kIsWeb && Platform.isAndroid) { + final selectedDeviceId = await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (ctx) => _AndroidAudioOutputPickerSheet( + devices: _androidDevices, + loading: _androidLoading, + onRefresh: _refreshAndroidDevices, + ), + ); + if (selectedDeviceId == null) return; + try { + if (selectedDeviceId == 'auto') { + await _androidOutputChannel.invokeMethod( + 'clearCommunicationDevice', + ); + } else { + final changed = await _androidOutputChannel.invokeMethod( + 'setCommunicationDevice', + {'deviceId': selectedDeviceId}, + ); + if (changed != true && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppL10n.of(context).audioRouteCannotSelect), + ), + ); + } + } + await _refreshAndroidDevices(); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)), + ); + } + return; + } + + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (ctx) => const _AudioOutputPickerSheet(), + ); + await _refresh(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + if (!kIsWeb && Platform.isAndroid) { + final selected = selectedAndroidAudioOutputDevice(_androidDevices); + final label = selected == null + ? l10n.audioRouteSystemDefault + : _androidDeviceLabel(selected.type, selected.name, l10n); + return _AudioRouteRow( + icon: _androidDeviceIcon(selected?.type), + label: _androidLoading ? '${l10n.audioRouteUnknown}…' : label, + onTap: _openPicker, + ); + } + + final port = _activeOutput; + final label = portLabel(port?.portType, port?.portName ?? '', l10n); + return _AudioRouteRow( + icon: portIcon(port?.portType), + label: label, + onTap: _openPicker, + ); + } + + static String _androidDeviceLabel( + String type, + String fallback, + AppL10n l10n, + ) => switch (type) { + 'speaker' => l10n.audioRouteSpeaker, + 'earpiece' => l10n.audioRouteEarpiece, + 'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset, + 'bluetoothA2dp' || + 'bluetoothSco' || + 'bluetoothLe' => l10n.audioRouteBluetooth, + 'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback, + 'hdmi' => l10n.audioRouteCarAudio, + _ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback, + }; + + static IconData _androidDeviceIcon(String? type) => switch (type) { + 'speaker' => Icons.volume_up, + 'earpiece' => Icons.phone_in_talk, + 'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset, + 'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio, + 'hdmi' => Icons.tv, + _ => Icons.speaker, + }; +} + +class _AudioRouteRow extends StatelessWidget { + const _AudioRouteRow({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), + child: Row( + children: [ + Icon(icon, color: theme.colorScheme.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.audioOutputLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + label, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +class _AndroidAudioOutputPickerSheet extends StatelessWidget { + const _AndroidAudioOutputPickerSheet({ + required this.devices, + required this.loading, + required this.onRefresh, + }); + + final List devices; + final bool loading; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + _PickerRow( + icon: Icons.speaker, + label: l10n.audioRouteSystemDefault, + selected: !devices.any((d) => d.isSelected), + onTap: () => Navigator.of(context).pop('auto'), + ), + if (loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (devices.isEmpty) + TextButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh), + label: Text(l10n.audioRouteRefreshDevices), + ) + else + for (final device in devices) + _PickerRow( + icon: AudioOutputTileState._androidDeviceIcon(device.type), + label: AudioOutputTileState._androidDeviceLabel( + device.type, + device.name, + l10n, + ), + selected: device.isSelected, + onTap: device.isAvailableForCommunication + ? () => Navigator.of(context).pop(device.id) + : null, + ), + ], + ), + ), + ); + } +} + +class _AudioOutputPickerSheet extends StatefulWidget { + const _AudioOutputPickerSheet(); + + @override + State<_AudioOutputPickerSheet> createState() => + _AudioOutputPickerSheetState(); +} + +class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { + Set _availableInputs = const {}; + AVAudioSessionRouteDescription? _route; + StreamSubscription? _routeSub; + bool _loading = true; + + @override + void initState() { + super.initState(); + _refresh(); + _routeSub = AVAudioSession().routeChangeStream.listen((_) { + if (!mounted) return; + _refresh(); + }); + } + + @override + void dispose() { + _routeSub?.cancel(); + _routeSub = null; + super.dispose(); + } + + Future _refresh() async { + try { + final session = AVAudioSession(); + final inputs = await session.availableInputs; + final route = await session.currentRoute; + if (!mounted) return; + setState(() { + _availableInputs = inputs; + _route = route; + _loading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + Future _selectSpeaker() async { + try { + await AVAudioSession().overrideOutputAudioPort( + AVAudioSessionPortOverride.speaker, + ); + unawaited(Haptics.selection().catchError((_) {})); + debugPrint('chanora: audio output -> speakerphone (override applied)'); + } catch (e, st) { + debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); + } + if (!mounted) return; + Navigator.of(context).pop(); + } + + Future _selectReceiver() async { + try { + await AVAudioSession().overrideOutputAudioPort( + AVAudioSessionPortOverride.none, + ); + unawaited(Haptics.selection().catchError((_) {})); + debugPrint('chanora: audio output -> receiver (override cleared)'); + } catch (e, st) { + debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); + } + if (!mounted) return; + Navigator.of(context).pop(); + } + + Future _selectInput(AVAudioSessionPortDescription port) async { + try { + await AVAudioSession().overrideOutputAudioPort( + AVAudioSessionPortOverride.none, + ); + await AVAudioSession().setPreferredInput(port); + unawaited(Haptics.selection().catchError((_) {})); + debugPrint('chanora: audio output -> ${port.portType}'); + } catch (e, st) { + debugPrint('chanora: _selectInput FAILED: $e\n$st'); + } + if (!mounted) return; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + + if (_loading) { + return const SafeArea( + child: Padding( + padding: EdgeInsets.all(40), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + final currentOutputType = _route?.outputs.firstOrNull?.portType; + final currentInputUid = _route?.inputs.firstOrNull?.uid; + final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker; + final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver; + final externalInputs = _availableInputs + .where((p) => p.portType != AVAudioSessionPort.builtInMic) + .toList(); + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + _PickerRow( + icon: Icons.volume_up, + label: l10n.audioRouteSpeaker, + selected: isSpeaker, + onTap: _selectSpeaker, + ), + _PickerRow( + icon: Icons.phone_in_talk, + label: l10n.audioRouteReceiver, + selected: isReceiver, + onTap: _selectReceiver, + ), + for (final port in externalInputs) + _PickerRow( + icon: AudioOutputTileState.portIcon(port.portType), + label: AudioOutputTileState.portLabel( + port.portType, + port.portName, + l10n, + ), + selected: !isSpeaker && port.uid == currentInputUid, + onTap: () => _selectInput(port), + ), + ], + ), + ), + ); + } +} + +class _PickerRow extends StatelessWidget { + const _PickerRow({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final enabled = onTap != null; + final color = selected + ? theme.colorScheme.primary + : enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant.withAlpha(130); + return Semantics( + button: true, + selected: selected, + enabled: enabled, + label: label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), + child: Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyLarge?.copyWith(color: color), + ), + ), + if (selected) + Icon(Icons.check, color: theme.colorScheme.primary), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart new file mode 100644 index 0000000..53eb4b3 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart @@ -0,0 +1,147 @@ +import 'dart:io' show Platform; + +import '../src/rust/api.dart' as rust; + +/// Fallback audio-processing config used before the bridge can report one. +const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig( + route: rust.BridgeAudioRoute.unknown, + iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, + vadBackend: rust.BridgeVadBackend.sileroOnnx, + aec: rust.BridgeEffectOwner.platform, + ns: rust.BridgeEffectOwner.platform, + agc: rust.BridgeEffectOwner.platform, + hpfEnabled: true, + limiterEnabled: true, + vadHangoverMs: 500, + vadPreRollMs: 160, + vadMinTxMs: 200, + debugWavDumpEnabled: false, +); + +/// Mutable UI state for audio-processing controls. +class AudioProcessingConfigState { + /// Build control state from a bridge config. + AudioProcessingConfigState.fromConfig(rust.BridgeAudioProcessingConfig config) + : nsEnabled = config.ns != rust.BridgeEffectOwner.off, + aecEnabled = config.aec != rust.BridgeEffectOwner.off, + agcEnabled = config.agc != rust.BridgeEffectOwner.off, + hpfEnabled = config.hpfEnabled, + limiterEnabled = config.limiterEnabled, + debugWavDump = config.debugWavDumpEnabled, + preferHardware = _usesPlatformEffects(config), + vadBackend = normalizedVadBackend(config.vadBackend), + iosMode = config.iosMode; + + /// Noise suppression toggle. + bool nsEnabled; + + /// Echo cancellation toggle. + bool aecEnabled; + + /// Automatic gain-control toggle. + bool agcEnabled; + + /// High-pass filter toggle. + bool hpfEnabled; + + /// Limiter toggle. + bool limiterEnabled; + + /// Debug WAV dump toggle. + bool debugWavDump; + + /// Android hardware effects preference. + bool preferHardware; + + /// Selected VAD backend. + rust.BridgeVadBackend vadBackend; + + /// Selected iOS processing mode. + rust.BridgeIosVoiceProcessingMode iosMode; + + /// Build the bridge config represented by this UI state. + rust.BridgeAudioProcessingConfig buildConfig({ + required rust.BridgeAudioProcessingConfig base, + bool? isAndroid, + }) { + final android = isAndroid ?? Platform.isAndroid; + final vad = normalizedVadBackend(vadBackend); + + if (android) { + final owner = preferHardware + ? rust.BridgeEffectOwner.platform + : rust.BridgeEffectOwner.webrtcApm; + return rust.BridgeAudioProcessingConfig( + route: base.route, + iosMode: iosMode, + processingBackend: preferHardware + ? rust.BridgeAudioBackend.platformVoiceProcessing + : rust.BridgeAudioBackend.webrtcApm, + vadBackend: vad, + aec: aecEnabled ? owner : rust.BridgeEffectOwner.off, + ns: nsEnabled ? owner : rust.BridgeEffectOwner.off, + agc: agcEnabled ? owner : rust.BridgeEffectOwner.off, + hpfEnabled: hpfEnabled, + limiterEnabled: limiterEnabled, + vadHangoverMs: base.vadHangoverMs, + vadPreRollMs: base.vadPreRollMs, + vadMinTxMs: base.vadMinTxMs, + debugWavDumpEnabled: debugWavDump, + ); + } + + final isSonora = + iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental; + final aecOwner = isSonora + ? (aecEnabled + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.off) + : rust.BridgeEffectOwner.platform; + final nsOwner = isSonora + ? (nsEnabled + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.off) + : (nsEnabled + ? rust.BridgeEffectOwner.platform + : rust.BridgeEffectOwner.off); + final agcOwner = isSonora + ? (agcEnabled + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.off) + : (agcEnabled + ? rust.BridgeEffectOwner.platform + : rust.BridgeEffectOwner.off); + + return rust.BridgeAudioProcessingConfig( + route: base.route, + iosMode: iosMode, + processingBackend: isSonora + ? rust.BridgeAudioBackend.webrtcApm + : rust.BridgeAudioBackend.platformVoiceProcessing, + vadBackend: vad, + aec: aecOwner, + ns: nsOwner, + agc: agcOwner, + hpfEnabled: hpfEnabled, + limiterEnabled: limiterEnabled, + vadHangoverMs: base.vadHangoverMs, + vadPreRollMs: base.vadPreRollMs, + vadMinTxMs: base.vadMinTxMs, + debugWavDumpEnabled: debugWavDump, + ); + } +} + +/// Never leave the UI on the hidden disabled backend. +rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) { + return backend == rust.BridgeVadBackend.disabled + ? rust.BridgeVadBackend.webrtcVad + : backend; +} + +bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) { + return config.aec == rust.BridgeEffectOwner.platform || + config.ns == rust.BridgeEffectOwner.platform || + config.agc == rust.BridgeEffectOwner.platform; +} diff --git a/apps/chanora_flutter/lib/widgets/bbcode_text.dart b/apps/chanora_flutter/lib/widgets/bbcode_text.dart index 8bf00c2..c09bf96 100644 --- a/apps/chanora_flutter/lib/widgets/bbcode_text.dart +++ b/apps/chanora_flutter/lib/widgets/bbcode_text.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/link_trust_service.dart'; +import '../services/ts3_server_link.dart'; final _tagRe = RegExp( r'\[(\/?(?:b|i|u|s' @@ -13,12 +14,14 @@ final _tagRe = RegExp( r'))\]', caseSensitive: false, ); -final _colorRe = RegExp(r'color=([#\w]+)'); -final _sizeRe = RegExp(r'size=(\d+)'); -final _urlRe = RegExp(r'url=(.+)'); +final _urlRe = RegExp(r'url=(.+)', caseSensitive: false); final _imgRe = RegExp(r'img=(.+)'); -final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false); +final _urlAutoRe = RegExp( + r'(?:\[url\])?((?:https?|ts3server)://[^\s\[\]]+)(?:\[/url\])?', + caseSensitive: false, +); final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false); +const _linkStyle = TextStyle(color: Colors.blue); int? _findCloseUrl(String src, int from) { final m = _closeUrlRe.matchAsPrefix(src, from); @@ -42,10 +45,16 @@ Color? _parseColor(String hex) { } class BbCodeText extends StatelessWidget { - const BbCodeText(this.text, {super.key, required this.linkTrust}); + const BbCodeText( + this.text, { + super.key, + required this.linkTrust, + this.onTs3ServerLink, + }); final String text; final LinkTrustService linkTrust; + final Ts3ServerLinkHandler? onTs3ServerLink; @override Widget build(BuildContext context) { @@ -63,20 +72,17 @@ class BbCodeText extends StatelessWidget { parts.add(TextSpan(text: src.substring(last, m.start))); } final url = m.group(1)!; - parts.add(WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _LinkTap( - url: url, - linkTrust: linkTrust, - child: Text( - url, - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), + parts.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _LinkTap( + url: url, + linkTrust: linkTrust, + onTs3ServerLink: onTs3ServerLink, + child: Text(url, style: _linkStyle), ), ), - )); + ); last = m.end; } if (last < src.length) { @@ -118,19 +124,21 @@ class BbCodeText extends StatelessWidget { } } - spans.add(TextSpan( - text: t, - style: TextStyle( - fontWeight: bold ? FontWeight.bold : null, - fontStyle: italic ? FontStyle.italic : null, - decoration: TextDecoration.combine([ - if (underline) TextDecoration.underline, - if (strikethrough) TextDecoration.lineThrough, - ]), - color: color, - fontSize: size, + spans.add( + TextSpan( + text: t, + style: TextStyle( + fontWeight: bold ? FontWeight.bold : null, + fontStyle: italic ? FontStyle.italic : null, + decoration: TextDecoration.combine([ + if (underline) TextDecoration.underline, + if (strikethrough) TextDecoration.lineThrough, + ]), + color: color, + fontSize: size, + ), ), - )); + ); } final buf = StringBuffer(); @@ -150,7 +158,8 @@ class BbCodeText extends StatelessWidget { } flush(buf); - final raw = m.group(1)!.toLowerCase(); + final rawTag = m.group(1)!; + final raw = rawTag.toLowerCase(); i = m.end; if (raw.startsWith('/')) { @@ -184,7 +193,7 @@ class BbCodeText extends StatelessWidget { if (raw.startsWith('color=') || raw.startsWith('size=')) { tags.add(raw); } else if (raw.startsWith('url=')) { - final url = _urlRe.firstMatch(raw)?.group(1) ?? ''; + final url = _urlRe.firstMatch(rawTag)?.group(1) ?? ''; final closeIdx = _findCloseUrl(src, i); String inner; if (closeIdx != null) { @@ -193,53 +202,49 @@ class BbCodeText extends StatelessWidget { } else { inner = url; } - spans.add(WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _LinkTap( - url: url.isNotEmpty ? url : inner, - linkTrust: linkTrust, - child: Text( - inner, - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _LinkTap( + url: url.isNotEmpty ? url : inner, + linkTrust: linkTrust, + onTs3ServerLink: onTs3ServerLink, + child: Text(inner, style: _linkStyle), ), ), - )); + ); } else if (raw.startsWith('img=')) { final src2 = _imgRe.firstMatch(raw)?.group(1) ?? ''; if (src2.isNotEmpty) { - spans.add(WidgetSpan( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - Uri.tryParse(src2)?.toString() ?? src2, - fit: BoxFit.scaleDown, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), + spans.add( + WidgetSpan( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + Uri.tryParse(src2)?.toString() ?? src2, + fit: BoxFit.scaleDown, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), ), ), - )); + ); } } else if (raw == 'url') { final closeIdx = _findCloseUrl(src, i); if (closeIdx != null) { final url = src.substring(i, closeIdx).trim(); i = closeIdx + 6; - spans.add(WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _LinkTap( - url: url, - linkTrust: linkTrust, - child: Text( - url, - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - ), + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _LinkTap( + url: url, + linkTrust: linkTrust, + onTs3ServerLink: onTs3ServerLink, + child: Text(url, style: _linkStyle), ), ), - )); + ); } } break; @@ -258,11 +263,13 @@ class _LinkTap extends StatefulWidget { required this.url, required this.child, required this.linkTrust, + this.onTs3ServerLink, }); final String url; final Widget child; final LinkTrustService linkTrust; + final Ts3ServerLinkHandler? onTs3ServerLink; @override State<_LinkTap> createState() => _LinkTapState(); @@ -284,6 +291,12 @@ class _LinkTapState extends State<_LinkTap> { void _onChanged() => mounted ? setState(() {}) : null; Future _open() async { + final ts3Link = parseTs3ServerLink(widget.url); + if (ts3Link != null) { + await widget.onTs3ServerLink?.call(ts3Link); + return; + } + final uri = Uri.tryParse(widget.url); if (uri == null) return; final host = uri.host; @@ -305,9 +318,6 @@ class _LinkTapState extends State<_LinkTap> { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: _open, - child: widget.child, - ); + return GestureDetector(onTap: _open, child: widget.child); } } diff --git a/apps/chanora_flutter/lib/widgets/chat_views.dart b/apps/chanora_flutter/lib/widgets/chat_views.dart index fab7e72..27f477c 100644 --- a/apps/chanora_flutter/lib/widgets/chat_views.dart +++ b/apps/chanora_flutter/lib/widgets/chat_views.dart @@ -280,13 +280,20 @@ class ChatPage extends StatefulWidget { } class _ChatPageState extends State { - rust.BridgeMessageTarget? _detailTarget; + late rust.BridgeMessageTarget _selectedTarget; String _selectedClientName = ''; + final Set _closedPrivateChats = {}; @override void initState() { super.initState(); - _detailTarget = widget.initialTarget; + _selectedTarget = + widget.initialTarget ?? + resolveInitialChatTarget( + messages: widget.messages, + currentVoiceChannelId: _currentChannelId, + ) ?? + const rust.BridgeMessageTarget.server(); _selectedClientName = widget.initialClientName; } @@ -298,31 +305,102 @@ class _ChatPageState extends State { Widget build(BuildContext context) { final currentChannelId = _currentChannelId; final channelName = snapshotChannelName(widget.snapshot, currentChannelId); - if (_detailTarget != null) { - return _ChatDetailView( - target: _detailTarget!, - clientName: _selectedClientName, - snapshot: widget.snapshot, - messages: widget.messages, - currentChannelId: currentChannelId, - channelName: channelName, - onBack: () => setState(() => _detailTarget = null), - onTs3ServerLink: widget.onTs3ServerLink, + return Scaffold( + appBar: AppBar( + title: Text('Chat — ${widget.snapshot.serverName}'), + actions: [ + IconButton( + tooltip: 'Close chat', + icon: const Icon(Icons.close), + onPressed: _selectedPrivateClientId == null + ? null + : _closeSelectedPrivateChat, + ), + ], + ), + body: Row( + children: [ + _ChatSidebar( + selectedTarget: _selectedTarget, + privateChats: _privateChats, + onSelect: _selectTarget, + onNewPrivateChat: () => _pickClient((id, name) { + _closedPrivateChats.remove(id); + _selectTarget(rust.BridgeMessageTarget.client(id), name: name); + }), + ), + const VerticalDivider(width: 1), + Expanded( + child: _ChatDetailView( + target: _selectedTarget, + clientName: _selectedClientName, + snapshot: widget.snapshot, + messages: widget.messages, + currentChannelId: currentChannelId, + channelName: channelName, + onTs3ServerLink: widget.onTs3ServerLink, + ), + ), + ], + ), + ); + } + + BigInt? get _selectedPrivateClientId { + final target = _selectedTarget; + return target is rust.BridgeMessageTarget_Client ? target.field0 : null; + } + + List<_PrivateChatItem> get _privateChats { + final chats = {}; + for (final message in widget.messages) { + final target = message.target; + if (target is! rust.BridgeMessageTarget_Client) continue; + final id = target.field0; + if (_closedPrivateChats.contains(id)) continue; + final existing = chats[id]; + final name = existing?.name.isNotEmpty == true + ? existing!.name + : _privateChatName(id, message.senderName); + chats[id] = _PrivateChatItem(id: id, name: name); + } + final selectedId = _selectedPrivateClientId; + if (selectedId != null && !_closedPrivateChats.contains(selectedId)) { + chats.putIfAbsent( + selectedId, + () => _PrivateChatItem( + id: selectedId, + name: _selectedClientName.isNotEmpty ? _selectedClientName : 'Direct', + ), ); } - return _ChatHub( - snapshot: widget.snapshot, - messages: widget.messages, - currentChannelId: currentChannelId, - channelName: channelName, - onOpen: (t, {String name = ''}) { - setState(() { - _detailTarget = t; - _selectedClientName = name; - }); - }, - onPickClient: (fn) => _pickClient(fn), - ); + return chats.values.toList()..sort((a, b) => a.name.compareTo(b.name)); + } + + String _privateChatName(BigInt id, String fallback) { + for (final client in widget.snapshot.clients) { + if (client.id == id && client.name.isNotEmpty) return client.name; + } + return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct'; + } + + void _selectTarget(rust.BridgeMessageTarget target, {String name = ''}) { + setState(() { + _selectedTarget = target; + _selectedClientName = name; + }); + } + + void _closeSelectedPrivateChat() { + final id = _selectedPrivateClientId; + if (id == null) return; + setState(() { + _closedPrivateChats.add(id); + _selectedTarget = _currentChannelId != null + ? const rust.BridgeMessageTarget.channel() + : const rust.BridgeMessageTarget.server(); + _selectedClientName = ''; + }); } void _pickClient(void Function(BigInt id, String name) cb) { @@ -342,6 +420,134 @@ class _ChatPageState extends State { } } +class _PrivateChatItem { + const _PrivateChatItem({required this.id, required this.name}); + + final BigInt id; + final String name; +} + +class _ChatSidebar extends StatelessWidget { + const _ChatSidebar({ + required this.selectedTarget, + required this.privateChats, + required this.onSelect, + required this.onNewPrivateChat, + }); + + final rust.BridgeMessageTarget selectedTarget; + final List<_PrivateChatItem> privateChats; + final void Function(rust.BridgeMessageTarget target, {String name}) onSelect; + final VoidCallback onNewPrivateChat; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 148, + child: Column( + children: [ + _ChatSidebarItem( + icon: Icons.dns_outlined, + label: 'Server', + selected: selectedTarget is rust.BridgeMessageTarget_Server, + onTap: () => onSelect(const rust.BridgeMessageTarget.server()), + ), + _ChatSidebarItem( + icon: Icons.tag, + label: 'Channel', + selected: selectedTarget is rust.BridgeMessageTarget_Channel, + onTap: () => onSelect(const rust.BridgeMessageTarget.channel()), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: privateChats.length, + itemBuilder: (context, index) { + final chat = privateChats[index]; + final selected = switch (selectedTarget) { + rust.BridgeMessageTarget_Client(:final field0) => + field0 == chat.id, + _ => false, + }; + return _ChatSidebarItem( + icon: Icons.person_outline, + label: chat.name.isNotEmpty ? chat.name : 'Direct', + selected: selected, + onTap: () => onSelect( + rust.BridgeMessageTarget.client(chat.id), + name: chat.name, + ), + ); + }, + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(8), + child: IconButton.filledTonal( + tooltip: 'New private chat', + icon: const Icon(Icons.add), + onPressed: onNewPrivateChat, + ), + ), + ], + ), + ); + } +} + +class _ChatSidebarItem extends StatelessWidget { + const _ChatSidebarItem({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bg = selected + ? theme.colorScheme.primaryContainer + : Colors.transparent; + final fg = selected + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface; + return Material( + color: bg, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 44, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Icon(icon, size: 18, color: fg), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(color: fg), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + class _ClientPickerDialog extends StatefulWidget { const _ClientPickerDialog({ required this.channels, @@ -461,124 +667,6 @@ class _ChannelGroup extends StatelessWidget { } } -class _ChatHub extends StatelessWidget { - const _ChatHub({ - required this.snapshot, - required this.messages, - required this.currentChannelId, - required this.channelName, - required this.onOpen, - required this.onPickClient, - }); - - final rust.BridgeSnapshot snapshot; - final List messages; - final BigInt? currentChannelId; - final String channelName; - final void Function(rust.BridgeMessageTarget t, {String name}) onOpen; - final void Function(void Function(BigInt id, String name) cb) onPickClient; - - Iterable _of(rust.BridgeMessageTarget t) => - messages.where((m) => m.target == t); - - @override - Widget build(BuildContext context) { - final channelMsgs = _of(const rust.BridgeMessageTarget.channel()); - final privateMsgs = messages.where((m) => m.isPrivate); - final serverMsgs = _of(const rust.BridgeMessageTarget.server()); - - return Scaffold( - appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _HubCard( - icon: Icons.tag, - title: channelName.isNotEmpty - ? '# $channelName' - : 'Current Channel', - subtitle: channelMsgs.isNotEmpty - ? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}' - : 'No messages yet', - detail: currentChannelId != null - ? 'You can send messages here' - : 'Join a channel to send messages', - onTap: () => onOpen(const rust.BridgeMessageTarget.channel()), - ), - const SizedBox(height: 12), - _HubCard( - icon: Icons.person_outline, - title: 'Direct Messages', - subtitle: privateMsgs.isNotEmpty - ? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}' - : 'No private messages', - detail: 'Select a user to start a private chat.', - onTap: () => onPickClient( - (id, name) => - onOpen(rust.BridgeMessageTarget.client(id), name: name), - ), - ), - const SizedBox(height: 12), - _HubCard( - icon: Icons.list_alt_outlined, - title: 'Server Activity', - subtitle: serverMsgs.isNotEmpty - ? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}' - : 'No server activity', - detail: serverMsgs.isNotEmpty - ? 'Server messages and events appear here.' - : 'Server-wide messages will appear here.', - onTap: () => onOpen(const rust.BridgeMessageTarget.server()), - ), - ], - ), - ); - } -} - -class _HubCard extends StatelessWidget { - const _HubCard({ - required this.icon, - required this.title, - required this.subtitle, - required this.detail, - required this.onTap, - }); - - final IconData icon; - final String title; - final String subtitle; - final String detail; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - clipBehavior: Clip.antiAlias, - child: ListTile( - leading: Icon(icon, color: theme.colorScheme.primary), - title: Text(title, style: theme.textTheme.titleSmall), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text(subtitle, style: theme.textTheme.bodyMedium), - Text( - detail, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, - ), - ); - } -} - class _ChatDetailView extends StatefulWidget { const _ChatDetailView({ required this.target, @@ -587,7 +675,6 @@ class _ChatDetailView extends StatefulWidget { required this.messages, required this.currentChannelId, required this.channelName, - required this.onBack, this.onTs3ServerLink, }); @@ -597,7 +684,6 @@ class _ChatDetailView extends StatefulWidget { final List messages; final BigInt? currentChannelId; final String channelName; - final VoidCallback onBack; final Ts3ServerLinkHandler? onTs3ServerLink; @override @@ -682,109 +768,108 @@ class _ChatDetailViewState extends State<_ChatDetailView> { clientName: widget.clientName, ); - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), + return Column( + children: [ + Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Text(_title, style: theme.textTheme.titleMedium), ), - title: Text(_title), - actions: [ - TextButton(onPressed: widget.onBack, child: const Text('Activity')), - ], - ), - body: Column( - children: [ - Expanded( - child: msgs.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.forum_outlined, - size: 48, + Expanded( + child: msgs.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.forum_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + chatEmptyTitle(widget.target), + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + chatEmptyBody( + widget.target, + channelName: widget.channelName, + ), + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 16), - Text( - chatEmptyTitle(widget.target), - style: theme.textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - chatEmptyBody( - widget.target, - channelName: widget.channelName, - ), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ) - : ListView.builder( - controller: _scrollCtl, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: msgs.length, - itemBuilder: (_, i) => _MessageBubble( - entry: msgs[i], - onTs3ServerLink: widget.onTs3ServerLink, + textAlign: TextAlign.center, + ), + ], ), ), - ), - if (_blockedReason != null) - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - color: theme.colorScheme.surfaceContainerHighest, - child: Text( - _blockedReason!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + ) + : ListView.builder( + controller: _scrollCtl, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: msgs.length, + itemBuilder: (_, i) => _MessageBubble( + entry: msgs[i], + onTs3ServerLink: widget.onTs3ServerLink, + ), ), - textAlign: TextAlign.center, + ), + if (_blockedReason != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: theme.colorScheme.surfaceContainerHighest, + child: Text( + _blockedReason!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), + textAlign: TextAlign.center, ), - if (_canSend && _blockedReason == null) - Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _textCtl, - decoration: InputDecoration( - hintText: placeholder, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), + ), + if (_canSend && _blockedReason == null) + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textCtl, + decoration: InputDecoration( + hintText: placeholder, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, ), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _send(), ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), ), - const SizedBox(width: 8), - IconButton.filled( - icon: const Icon(Icons.send), - onPressed: _send, - tooltip: 'Send', - ), - ], - ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: const Icon(Icons.send), + onPressed: _send, + tooltip: 'Send', + ), + ], ), - ], - ), + ), + ], ); } } diff --git a/apps/chanora_flutter/lib/widgets/connect_widgets.dart b/apps/chanora_flutter/lib/widgets/connect_widgets.dart new file mode 100644 index 0000000..9165746 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/connect_widgets.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../src/rust/api.dart' as rust; + +/// Server connection form. +class ConnectForm extends StatefulWidget { + /// Construct a connect form. + const ConnectForm({ + super.key, + required this.hostCtl, + required this.nickCtl, + required this.passwordCtl, + required this.onConnect, + required this.onAddBookmark, + }); + + /// Server host controller. + final TextEditingController hostCtl; + + /// Nickname controller. + final TextEditingController nickCtl; + + /// Server password controller. + final TextEditingController passwordCtl; + + /// Called when the user submits a connection. + final VoidCallback onConnect; + + /// Called when the user saves the current form as a bookmark. + final VoidCallback onAddBookmark; + + @override + State createState() => _ConnectFormState(); +} + +class _ConnectFormState extends State { + final FocusNode _hostFocus = FocusNode(); + final FocusNode _nickFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + void _onTapOutside(PointerDownEvent _) { + FocusManager.instance.primaryFocus?.unfocus(); + } + + @override + void dispose() { + _hostFocus.dispose(); + _nickFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: widget.hostCtl, + focusNode: _hostFocus, + onTapOutside: _onTapOutside, + keyboardType: TextInputType.url, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp(r'\s')), + TextInputFormatter.withFunction( + (oldValue, newValue) => newValue.copyWith( + text: newValue.text.toLowerCase(), + selection: newValue.selection, + ), + ), + ], + decoration: InputDecoration( + labelText: l10n.fieldServerHost, + hintText: 'host[:port]', + prefixIcon: const Icon(Icons.dns_outlined), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + TextField( + controller: widget.nickCtl, + focusNode: _nickFocus, + onTapOutside: _onTapOutside, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.fieldNickname, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + TextField( + controller: widget.passwordCtl, + focusNode: _passwordFocus, + onTapOutside: _onTapOutside, + obscureText: true, + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.fieldServerPassword, + helperText: l10n.fieldServerPasswordHelp, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.login), + label: Text(l10n.connectAction), + onPressed: widget.onConnect, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + icon: const Icon(Icons.bookmark_add_outlined), + label: Text(l10n.bookmarkAddAction), + onPressed: widget.onAddBookmark, + ), + ], + ), + ], + ); + } +} + +/// Saved bookmark list. +class BookmarkList extends StatelessWidget { + /// Construct a bookmark list. + const BookmarkList({ + super.key, + required this.bookmarks, + required this.onConnect, + required this.onDelete, + }); + + /// Saved bookmarks. + final List bookmarks; + + /// Connect to a bookmark. + final ValueChanged onConnect; + + /// Delete a bookmark. + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + if (bookmarks.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + for (final b in bookmarks) + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + title: Text(b.displayName), + subtitle: Text('${b.host} — ${b.nickname}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.login), + tooltip: l10n.connectAction, + onPressed: () => onConnect(b), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: l10n.bookmarkDeleteAction, + onPressed: () => onDelete(b), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/input_dialogs.dart b/apps/chanora_flutter/lib/widgets/input_dialogs.dart new file mode 100644 index 0000000..7187078 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/input_dialogs.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../src/rust/api.dart' as rust; + +/// Translate a [LogicalKeyboardKey] into the platform-neutral label +/// stored by the PTT binding flow. +String? pttDisplayLabelForKey(LogicalKeyboardKey k) { + if (k == LogicalKeyboardKey.space) return 'Space'; + if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { + return 'Enter'; + } + if (k == LogicalKeyboardKey.tab) return 'Tab'; + if (k == LogicalKeyboardKey.escape) return 'Escape'; + if (k == LogicalKeyboardKey.backspace) return 'Backspace'; + if (k == LogicalKeyboardKey.delete) return 'Delete'; + if (k == LogicalKeyboardKey.insert) return 'Insert'; + if (k == LogicalKeyboardKey.home) return 'Home'; + if (k == LogicalKeyboardKey.end) return 'End'; + if (k == LogicalKeyboardKey.pageUp) return 'Page Up'; + if (k == LogicalKeyboardKey.pageDown) return 'Page Down'; + if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up'; + if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down'; + if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left'; + if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right'; + if (k == LogicalKeyboardKey.shift || + k == LogicalKeyboardKey.shiftLeft || + k == LogicalKeyboardKey.shiftRight || + k == LogicalKeyboardKey.control || + k == LogicalKeyboardKey.controlLeft || + k == LogicalKeyboardKey.controlRight || + k == LogicalKeyboardKey.alt || + k == LogicalKeyboardKey.altLeft || + k == LogicalKeyboardKey.altRight || + k == LogicalKeyboardKey.meta || + k == LogicalKeyboardKey.metaLeft || + k == LogicalKeyboardKey.metaRight || + k == LogicalKeyboardKey.capsLock || + k == LogicalKeyboardKey.numLock || + k == LogicalKeyboardKey.scrollLock) { + return null; + } + final fallback = k.keyLabel.trim(); + if (fallback.isEmpty) return null; + return fallback; +} + +/// Result of a successful PTT binding capture. +class CapturedBinding { + const CapturedBinding({required this.inputClass, required this.platformKey}); + + /// Coarse input class, safe to persist and display. + final rust.BridgePttInputClass inputClass; + + /// Opaque platform-neutral key label. + final String platformKey; +} + +/// 'Save bookmark' name-entry dialog. +class BookmarkNameDialog extends StatefulWidget { + /// Construct a bookmark-name dialog. + const BookmarkNameDialog({super.key, required this.initialName}); + + /// Initial display name. + final String initialName; + + @override + State createState() => _BookmarkNameDialogState(); +} + +class _BookmarkNameDialogState extends State { + late final TextEditingController _ctl = TextEditingController( + text: widget.initialName, + ); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + return AlertDialog( + title: Text(l10n.bookmarkAddTitle), + content: TextField( + controller: _ctl, + autofocus: true, + decoration: InputDecoration(labelText: l10n.fieldDisplayName), + onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_ctl.text), + child: Text(l10n.bookmarkAddAction), + ), + ], + ); + } +} + +/// Channel-password dialog. +class ChannelPasswordDialog extends StatefulWidget { + /// Construct a channel-password dialog. + const ChannelPasswordDialog({super.key}); + + @override + State createState() => _ChannelPasswordDialogState(); +} + +class _ChannelPasswordDialogState extends State { + final TextEditingController _ctl = TextEditingController(); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + return AlertDialog( + title: Text(l10n.channelPasswordTitle), + content: TextField( + controller: _ctl, + obscureText: true, + autofocus: true, + decoration: InputDecoration(labelText: l10n.fieldPassword), + onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_ctl.text), + child: Text(l10n.connectAction), + ), + ], + ); + } +} + +/// Focus-scoped dialog that captures the next key press or mouse +/// side-button click. +class PttBindingCaptureDialog extends StatefulWidget { + /// Construct a PTT binding capture dialog. + const PttBindingCaptureDialog({super.key}); + + @override + State createState() => + _PttBindingCaptureDialogState(); +} + +class _PttBindingCaptureDialogState extends State { + final FocusNode _focusNode = FocusNode(); + String? _captured; + rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final label = pttDisplayLabelForKey(event.logicalKey); + if (label == null) return KeyEventResult.ignored; + setState(() { + _captured = label; + _capturedClass = rust.BridgePttInputClass.keyboard; + }); + return KeyEventResult.handled; + } + + void _captureMouseSideButton(int button) { + setState(() { + _captured = 'mouse-side-button:$button'; + _capturedClass = rust.BridgePttInputClass.mouseSideButton; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + return AlertDialog( + title: Text(l10n.pttConfigureTitle), + content: SizedBox( + width: 360, + child: Focus( + focusNode: _focusNode, + onKeyEvent: _onKeyEvent, + autofocus: true, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (e) { + const int back = 0x08; + const int forward = 0x10; + if (e.buttons == back || e.buttons == forward) { + _captureMouseSideButton(e.buttons); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.pttConfigurePrompt, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _captured == null + ? l10n.pttConfigureWaiting + : '${l10n.pttConfigureCaptured}: $_captured', + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(height: 12), + Text( + l10n.pttConfigurePrivacyNote, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: _captured == null + ? null + : () => Navigator.of(context).pop( + CapturedBinding( + inputClass: _capturedClass, + platformKey: _captured!, + ), + ), + child: Text(l10n.pttConfigureSaveAction), + ), + ], + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/snapshot_view.dart b/apps/chanora_flutter/lib/widgets/snapshot_view.dart new file mode 100644 index 0000000..60f443f --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/snapshot_view.dart @@ -0,0 +1,665 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../services/channel_spacer.dart'; +import '../services/link_trust_service.dart'; +import '../services/snapshot_state_mapper.dart'; +import '../services/ts3_server_link.dart'; +import '../src/rust/api.dart' as rust; +import 'bbcode_text.dart'; +import 'talk_power_warning.dart'; + +/// Connected-server snapshot with welcome text, channels, and clients. +class SnapshotView extends StatefulWidget { + /// Construct a snapshot view. + const SnapshotView({ + super.key, + required this.snapshot, + required this.audioStats, + required this.currentVoiceChannelId, + required this.pendingVoiceChannelId, + required this.localInputMuted, + required this.localOutputMuted, + required this.hasJoinPending, + required this.canJoinVoiceChannel, + required this.onJoinChannel, + required this.onJoinChannelWithPassword, + this.onTs3ServerLink, + }); + + /// Current bridge snapshot. + final rust.BridgeSnapshot snapshot; + + /// Latest audio stats, used for local speaking state. + final rust.BridgeAudioStats? audioStats; + + /// Current voice channel id. + final BigInt? currentVoiceChannelId; + + /// Pending join target, if any. + final BigInt? pendingVoiceChannelId; + + /// Local input mute state. + final bool localInputMuted; + + /// Local output mute state. + final bool localOutputMuted; + + /// True while a channel join is in flight. + final bool hasJoinPending; + + /// True when the local client may join voice channels. + final bool canJoinVoiceChannel; + + /// Join an unlocked channel. + final ValueChanged onJoinChannel; + + /// Join a password-protected channel. + final ValueChanged onJoinChannelWithPassword; + + /// Handle TeamSpeak server links embedded in server-provided text. + final Ts3ServerLinkHandler? onTs3ServerLink; + + @override + State createState() => _SnapshotViewState(); +} + +class _SnapshotViewState extends State { + static const _indentPerLevel = 12.0; + static const _expandColumnWidth = 28.0; + static const _channelIconColumnWidth = 24.0; + static const _channelTextGap = 8.0; + static const _userRowStartIndent = 32.0; + + final _scrollController = ScrollController(); + final Map _channelExpandedById = {}; + bool _welcomeExpanded = true; + double _welcomeHeight = 0; + final _welcomeKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _welcomeKey.currentContext; + if (ctx != null) { + final box = ctx.findRenderObject() as RenderBox?; + if (box != null && mounted) { + setState(() => _welcomeHeight = box.size.height); + } + } + }); + } + + void _onScroll() { + if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) { + setState(() => _welcomeExpanded = false); + } + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + final tree = _buildChannelTree(widget.snapshot.channels); + final clientsByChannel = >{}; + for (final c in widget.snapshot.clients) { + if (!c.isServerQuery) { + clientsByChannel.putIfAbsent(c.channel, () => []).add(c); + } + } + + return ListView( + controller: _scrollController, + children: [ + Text( + l10n.countChannelsAndClients( + widget.snapshot.channels.length, + widget.snapshot.clients.length, + ), + style: theme.textTheme.bodyMedium, + ), + if (widget.snapshot.welcomeMessage.isNotEmpty) ...[ + const SizedBox(height: 8), + _WelcomeMessageTile( + key: _welcomeKey, + welcomeMessage: widget.snapshot.welcomeMessage, + expanded: _welcomeExpanded, + onToggle: () => + setState(() => _welcomeExpanded = !_welcomeExpanded), + onTs3ServerLink: widget.onTs3ServerLink, + ), + ], + const Divider(height: 24), + for (final node in tree.roots) + ..._channelTreeRows(theme, node, clientsByChannel, 0), + ], + ); + } + + List _channelTreeRows( + ThemeData theme, + _ChannelTreeNode node, + Map> clientsByChannel, + int depth, + ) { + final channel = node.channel; + final clients = clientsByChannel[channel.id] ?? const []; + final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty; + final expanded = _isChannelExpanded(channel.id); + final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel; + + return [ + _channelTile( + theme, + channel, + channelIndent: channelIndent, + hasVisibleChildren: hasVisibleChildren, + expanded: expanded, + onToggleExpanded: hasVisibleChildren + ? () => _toggleChannelExpanded(channel.id) + : null, + ), + if (expanded) ...[ + for (final client in clients) _clientTile(theme, client, channelIndent), + for (final child in node.children) + ..._channelTreeRows(theme, child, clientsByChannel, depth + 1), + ], + ]; + } + + Widget _channelTile( + ThemeData theme, + rust.BridgeChannel channel, { + required double channelIndent, + required bool hasVisibleChildren, + required bool expanded, + required VoidCallback? onToggleExpanded, + }) { + final spacer = parseSpacerChannelName(channel.name); + final onTap = + widget.hasJoinPending || + !widget.canJoinVoiceChannel || + channel.id == widget.currentVoiceChannelId + ? null + : () => channel.hasPassword + ? widget.onJoinChannelWithPassword(channel) + : widget.onJoinChannel(channel); + + if (spacer.isSpacer) { + return InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Row( + children: [ + SizedBox(width: channelIndent), + _expandButton( + theme, + hasVisibleChildren: hasVisibleChildren, + expanded: expanded, + onPressed: onToggleExpanded, + ), + const SizedBox(width: _channelTextGap), + Expanded(child: _SpacerChannelContent(spacer: spacer)), + ], + ), + ), + ); + } + + return InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Row( + children: [ + SizedBox(width: channelIndent), + _expandButton( + theme, + hasVisibleChildren: hasVisibleChildren, + expanded: expanded, + onPressed: onToggleExpanded, + ), + SizedBox( + width: _channelIconColumnWidth, + child: Align( + alignment: Alignment.centerLeft, + child: Icon( + Icons.tag, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: _channelTextGap), + Expanded( + child: Text( + channel.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (channel.hasPassword) ...[ + const SizedBox(width: 8), + Icon( + Icons.lock_outline, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), + ), + ); + } + + Widget _clientTile( + ThemeData theme, + rust.BridgeClient client, + double channelIndent, + ) { + final status = _clientVoiceStatusIcon(theme, client); + final nameStyle = client.isServerQuery + ? TextStyle(color: theme.colorScheme.onSurfaceVariant) + : status.isSpeaking + ? TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ) + : null; + + final decoration = status.isSpeaking + ? BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.55), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withValues(alpha: 0.14), + blurRadius: 8, + spreadRadius: 1, + ), + ], + ) + : null; + + return Padding( + padding: EdgeInsets.only( + left: channelIndent + _userRowStartIndent, + right: 8, + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + decoration: decoration, + child: ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: status.icon, + title: Text(client.name, style: nameStyle), + ), + ), + ); + } + + Widget _expandButton( + ThemeData theme, { + required bool hasVisibleChildren, + required bool expanded, + required VoidCallback? onPressed, + }) { + if (!hasVisibleChildren) { + return const SizedBox( + width: _expandColumnWidth, + height: _expandColumnWidth, + ); + } + return Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: SizedBox( + width: _expandColumnWidth, + height: _expandColumnWidth, + child: Icon( + expanded ? Icons.expand_more : Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + bool _isChannelExpanded(BigInt channelId) { + return _channelExpandedById[channelId] ?? true; + } + + void _toggleChannelExpanded(BigInt channelId) { + setState(() { + _channelExpandedById[channelId] = !_isChannelExpanded(channelId); + }); + } + + ({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon( + ThemeData theme, + rust.BridgeClient client, + ) { + final isSelf = client.id == widget.snapshot.ownClientId; + final inCurrentChannel = client.channel == widget.currentVoiceChannelId; + final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted; + final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted; + final neededTalkPower = snapshotNeededTalkPower( + widget.snapshot, + client.channel, + ); + final talkPowerBlocked = + isSelf && + inCurrentChannel && + isTalkPowerBlocked( + talkPower: client.talkPower, + neededTalkPower: neededTalkPower, + talkPowerGranted: client.talkPowerGranted, + ); + final rawSpeaking = isSelf + ? (widget.audioStats?.pttActive ?? false) + : client.isSpeaking; + final transmitAllowed = + !outputMuted && + !inputMuted && + (!isSelf || (inCurrentChannel && !talkPowerBlocked)); + final speaking = rawSpeaking && transmitAllowed; + + final IconData icon; + final Color color; + final String tooltip; + if (outputMuted) { + icon = Icons.volume_off; + color = theme.colorScheme.error; + tooltip = 'Speaker muted'; + } else if (inputMuted) { + icon = Icons.mic_off; + color = theme.colorScheme.error; + tooltip = 'Microphone muted'; + } else if (talkPowerBlocked) { + icon = Icons.volume_off; + color = theme.colorScheme.error; + tooltip = + 'Insufficient talk power (${client.talkPower} < $neededTalkPower)'; + } else if (speaking) { + icon = isSelf ? Icons.mic : Icons.volume_up; + color = theme.colorScheme.primary; + tooltip = 'Speaking'; + } else if (inCurrentChannel) { + icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined; + color = theme.colorScheme.onSurfaceVariant; + tooltip = 'Not speaking'; + } else { + icon = Icons.person_outline; + color = theme.colorScheme.onSurfaceVariant; + tooltip = 'Outside current channel'; + } + + return ( + icon: Tooltip( + message: tooltip, + child: Icon(icon, color: color), + ), + isSpeaking: speaking, + ); + } +} + +class _SpacerChannelContent extends StatelessWidget { + const _SpacerChannelContent({required this.spacer}); + + final SpacerChannelNameParseResult spacer; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.onSurfaceVariant; + + if (spacer.isBlankSpacer) { + return const SizedBox(height: 20); + } + + if (spacer.specialType != null) { + return SizedBox( + height: 22, + child: CustomPaint( + painter: _SpacerLinePainter( + color: color.withValues(alpha: 0.72), + type: spacer.specialType!, + ), + ), + ); + } + + if (spacer.isRepeating) { + return LayoutBuilder( + builder: (context, constraints) { + final pattern = spacer.text.isEmpty ? ' ' : spacer.text; + final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp( + 1, + 256, + ); + return Text( + channelSpacerLabel( + formatSpacerChannelName( + SpacerChannelNameFormatOptions( + alignment: spacer.alignment, + isRepeating: true, + uniqueSuffix: spacer.uniqueSuffix, + text: pattern, + ), + ), + repeatColumns: estimatedColumns, + ), + maxLines: 1, + overflow: TextOverflow.clip, + softWrap: false, + style: theme.textTheme.bodyMedium?.copyWith(color: color), + ); + }, + ); + } + + return Text( + spacer.text, + textAlign: switch (spacer.alignment) { + SpacerAlignment.left => TextAlign.left, + SpacerAlignment.right => TextAlign.right, + SpacerAlignment.center => TextAlign.center, + null => TextAlign.center, + }, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _SpacerLinePainter extends CustomPainter { + const _SpacerLinePainter({required this.color, required this.type}); + + final Color color; + final SpacerSpecialType type; + + @override + void paint(Canvas canvas, Size size) { + final y = size.height / 2; + final paint = Paint() + ..color = color + ..strokeCap = StrokeCap.square + ..strokeWidth = 1.4; + + switch (type) { + case SpacerSpecialType.solidLine: + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + case SpacerSpecialType.dashLine: + _drawPattern(canvas, size.width, y, paint, const [8, 5]); + case SpacerSpecialType.dotLine: + final dotPaint = Paint()..color = color; + for (var x = 1.5; x < size.width; x += 7) { + canvas.drawCircle(Offset(x, y), 1.5, dotPaint); + } + case SpacerSpecialType.dashDotLine: + _drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]); + case SpacerSpecialType.dashDotDotLine: + _drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]); + } + } + + void _drawPattern( + Canvas canvas, + double width, + double y, + Paint paint, + List pattern, + ) { + var x = 0.0; + var index = 0; + while (x < width) { + final length = pattern[index % pattern.length]; + if (index.isEven) { + final end = x + length > width ? width : x + length; + canvas.drawLine(Offset(x, y), Offset(end, y), paint); + } + x += length; + index += 1; + } + } + + @override + bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.type != type; + } +} + +class _ChannelTree { + const _ChannelTree({required this.roots}); + + final List<_ChannelTreeNode> roots; +} + +class _ChannelTreeNode { + _ChannelTreeNode(this.channel); + + final rust.BridgeChannel channel; + final List<_ChannelTreeNode> children = []; +} + +_ChannelTree _buildChannelTree(List channels) { + final byParent = >{}; + final knownIds = {for (final channel in channels) channel.id}; + + for (final channel in channels) { + final parent = knownIds.contains(channel.parent) + ? channel.parent + : BigInt.zero; + byParent.putIfAbsent(parent, () => []).add(channel); + } + + _ChannelTreeNode buildNode(rust.BridgeChannel channel) { + final node = _ChannelTreeNode(channel); + for (final child in byParent[channel.id] ?? const []) { + node.children.add(buildNode(child)); + } + return node; + } + + return _ChannelTree( + roots: [ + for (final channel + in byParent[BigInt.zero] ?? const []) + buildNode(channel), + ], + ); +} + +class _WelcomeMessageTile extends StatelessWidget { + const _WelcomeMessageTile({ + super.key, + required this.welcomeMessage, + required this.expanded, + required this.onToggle, + this.onTs3ServerLink, + }); + + final String welcomeMessage; + final bool expanded; + final VoidCallback onToggle; + final Ts3ServerLinkHandler? onTs3ServerLink; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + HapticFeedback.selectionClick(); + onToggle(); + }, + borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Icon( + expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + 'Server welcome message', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + AnimatedCrossFade( + firstChild: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: BbCodeText( + welcomeMessage, + linkTrust: LinkTrustService.instance, + onTs3ServerLink: onTs3ServerLink, + ), + ), + secondChild: const SizedBox(width: double.infinity), + crossFadeState: expanded + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + ), + ], + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/talk_power_warning.dart b/apps/chanora_flutter/lib/widgets/talk_power_warning.dart new file mode 100644 index 0000000..07cd28f --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/talk_power_warning.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +bool isTalkPowerBlocked({ + required int? talkPower, + required int? neededTalkPower, + required bool? talkPowerGranted, +}) { + return talkPower != null && + neededTalkPower != null && + talkPower < neededTalkPower && + talkPowerGranted != true; +} + +class TalkPowerWarning extends StatelessWidget { + const TalkPowerWarning({ + super.key, + required this.talkPower, + required this.neededTalkPower, + required this.talkPowerGranted, + }); + + final int? talkPower; + final int? neededTalkPower; + final bool? talkPowerGranted; + + @override + Widget build(BuildContext context) { + if (!isTalkPowerBlocked( + talkPower: talkPower, + neededTalkPower: neededTalkPower, + talkPowerGranted: talkPowerGranted, + )) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber, size: 18, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Insufficient talk power ($talkPower < $neededTalkPower)', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index 09d608c..ca3f30f 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -3,14 +3,14 @@ // `BridgeEvent::VoiceState` stream the bridge publishes from the // core's transmit-mode selector + release-tail timer. -import 'dart:async' show unawaited; - import 'package:flutter/material.dart'; -import 'package:haptic_kit/haptic_kit.dart'; import '../l10n/generated/app_localizations.dart'; import 'ptt_capability_badge.dart'; +import 'voice_compact.dart'; +import 'voice_level_meter.dart'; import 'voice_platform.dart'; +import 'voice_status_summary.dart'; import '../src/rust/api.dart' as rust; /// Voice bar — surfaces the live voice state, mode badge, hard-mute @@ -30,27 +30,16 @@ class VoiceBar extends StatelessWidget { required this.pttBackendId, required this.pttBoundInputClass, required this.pttBoundKeyLabel, - required this.onToggleMute, - required this.onToggleOutputMute, required this.onConfigure, required this.onPttHeldChanged, + this.talkPowerBlocked = false, }); - /// True when the session is currently joined to a voice channel. final bool inChannel; - - /// Active transmit mode. final rust.BridgeTransmitMode transmitMode; - - /// Hard-mute clamp state. final bool hardMute; - - /// Speaker (output) mute state. Mirrors the server-broadcast - /// `ClientOutputMuted` flag plus the engine's local output - /// silencer — toggling this hushes incoming voice immediately - /// AND tells the server so other clients see the headphone-off - /// icon next to our name. final bool outputMuted; + final bool talkPowerBlocked; /// Configured release-tail in milliseconds (0..=500). Surfaced as /// a hint underneath the mode badge. @@ -77,12 +66,6 @@ class VoiceBar extends StatelessWidget { /// Platform-neutral key label captured by the binding dialog. final String pttBoundKeyLabel; - /// Toggle the hard-mute clamp. - final VoidCallback onToggleMute; - - /// Toggle speaker (output) mute. - final VoidCallback onToggleOutputMute; - /// Open the voice settings dialog. This is the SINGLE entry point /// for transmit-mode selection, PTT key binding, and release-tail /// configuration. The capability badge below is information-only @@ -98,17 +81,6 @@ class VoiceBar extends StatelessWidget { /// timer handles the trailing tail (SDD-096). final ValueChanged onPttHeldChanged; - String _modeLabel(AppL10n l10n) { - switch (transmitMode) { - case rust.BridgeTransmitMode.ptt: - return l10n.voiceModePtt; - case rust.BridgeTransmitMode.continuous: - return l10n.voiceModeContinuous; - case rust.BridgeTransmitMode.voiceActivity: - return l10n.voiceModeVoiceActivity; - } - } - @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); @@ -116,344 +88,181 @@ class VoiceBar extends StatelessWidget { final stats = audioStats; final levelActive = stats?.pttActive ?? false; final isPtt = transmitMode == rust.BridgeTransmitMode.ptt; + final summary = voiceStatusSummary( + l10n: l10n, + transmitMode: transmitMode, + releaseTailMs: releaseTailMs, + pttBoundKeyLabel: pttBoundKeyLabel, + isTouchOnly: isTouchOnlyPttHost, + inputMuted: hardMute, + outputMuted: outputMuted, + pttActive: stats?.pttActive ?? false, + talkPower: talkPowerBlocked ? 0 : null, + neededTalkPower: talkPowerBlocked ? 1 : null, + talkPowerGranted: false, + ); - return Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Row 1: channel pill + mute toggle. The pill is - // wrapped in `Flexible` so very long channel names - // truncate with an ellipsis instead of overflowing the - // Voice Bar's column width (320 dp in the wide layout) - // and pushing the mute icons under the adjacent channel - // tree. - Row( - children: [ - if (inChannel && channelName.isNotEmpty) ...[ - Flexible( - flex: 100, - fit: FlexFit.loose, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.tag, - size: 14, - color: theme.colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - channelName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - ], + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 0), + child: Container( + decoration: BoxDecoration( + color: summary.talkPowerBlocked + ? Colors.amber.withValues(alpha: 0.18) + : summary.muted + ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) + : theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: summary.talkPowerBlocked + ? Colors.amber.shade700 + : summary.muted + ? theme.colorScheme.error + : theme.colorScheme.outlineVariant, + width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Row 1: mode badge + Row( + children: [ + Icon( + transmitMode == rust.BridgeTransmitMode.ptt + ? Icons.radio_button_checked + : Icons.podcasts, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + summary.modeLabel, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), ), + TextButton.icon( + icon: const Icon(Icons.tune, size: 16), + label: Text(l10n.voiceSettingsTitle), + onPressed: onConfigure, + ), ], - const SizedBox(width: 8), - const Spacer(), - IconButton( - tooltip: l10n.voiceOutputMuteLabel, - icon: Icon(outputMuted ? Icons.headset_off : Icons.headset), - isSelected: outputMuted, - selectedIcon: const Icon(Icons.headset_off), - onPressed: onToggleOutputMute, - ), - IconButton( - tooltip: l10n.voiceHardMuteLabel, - icon: Icon(hardMute ? Icons.mic_off : Icons.mic), - isSelected: hardMute, - selectedIcon: const Icon(Icons.mic_off), - onPressed: onToggleMute, + ), + // Status row: talk power / mic / speaker state. + if (inChannel) ...[ + const SizedBox(height: 2), + Text( + summary.talkPowerBlocked + ? 'Insufficient talk power' + : summary.statusText, + style: theme.textTheme.bodySmall?.copyWith( + color: summary.talkPowerBlocked + ? Colors.amber.shade700 + : summary.muted + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), ), ], - ), - const SizedBox(height: 6), - // Row 2: mode badge - Row( - children: [ - Icon( - transmitMode == rust.BridgeTransmitMode.ptt - ? Icons.radio_button_checked - : Icons.podcasts, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Expanded( + // Row 3: PTT-only secondary content. + // + // On hardware-keyboard hosts (Windows / macOS / Linux / + // Web) this is a one-line bound-key + release-tail hint + // sitting right under the mode badge. + // + // On touch-only hosts (iOS / iPadOS / Android) the + // on-screen Push to Talk button is rendered AT THE + // BOTTOM of the Voice Bar (see below) so it sits + // closest to the user's thumb when the Voice Bar is + // pinned to the bottom of a narrow-layout screen. The + // release-tail value is folded into the small print + // under the button rather than shown here. + if (isPtt && !isTouchOnlyPttHost) + Padding( + padding: const EdgeInsets.only(left: 22, top: 2), child: Text( - _modeLabel(l10n), - style: theme.textTheme.bodyMedium?.copyWith( + '${l10n.voiceModePtt}: ' + '${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}' + ' · ' + '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', + style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), - TextButton.icon( - icon: const Icon(Icons.tune, size: 16), - label: Text(l10n.voiceSettingsTitle), - onPressed: onConfigure, - ), - ], - ), - // Row 3: PTT-only secondary content. - // - // On hardware-keyboard hosts (Windows / macOS / Linux / - // Web) this is a one-line bound-key + release-tail hint - // sitting right under the mode badge. - // - // On touch-only hosts (iOS / iPadOS / Android) the - // on-screen Push to Talk button is rendered AT THE - // BOTTOM of the Voice Bar (see below) so it sits - // closest to the user's thumb when the Voice Bar is - // pinned to the bottom of a narrow-layout screen. The - // release-tail value is folded into the small print - // under the button rather than shown here. - if (isPtt && !isTouchOnlyPttHost) - Padding( - padding: const EdgeInsets.only(left: 22, top: 2), - child: Text( - '${l10n.voiceModePtt}: ' - '${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}' - ' · ' - '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: 6), - // Row 4: level meter - _LevelMeter(active: levelActive), - const SizedBox(height: 4), - if (stats != null) - Text( - l10n.audioStatsLine( - stats.framesSent, - stats.framesReceived, - stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff, - ), - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 6), - // PTT capability badge — only relevant when PTT mode is - // active. Hidden for Continuous / Voice Activity since - // there's no key binding to surface a capability for. - // The badge is information-only; the user reaches the - // bind-key flow through the Voice Bar's settings gear - // (single configuration entry point — see the comment - // on `onConfigure`). - // - // Still shown on touch-only mobile hosts because iOS P0 - // acceptance requires an explicit `L0Focused` badge and - // explanation that global hotkeys are not available in - // the iOS sandbox. - if (isPtt) - PttCapabilityBadge( - level: pttLevel, - backendId: pttBackendId, - boundInputClass: pttBoundInputClass, - ), - // On touch-only mobile hosts the Push to Talk button is - // the LAST element of the Voice Bar so it lands closest - // to the user's thumb when the Voice Bar is pinned to - // the bottom of a narrow-layout screen. The release- - // tail value sits above the button so the user sees - // how long their voice continues after they let go. - if (isPtt && isTouchOnlyPttHost) ...[ + const SizedBox(height: 6), + // Row 4: level meter + VoiceLevelMeter(active: levelActive), const SizedBox(height: 4), - Center( - child: Text( - '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (stats != null) + Text( + l10n.audioStatsLine( + stats.framesSent, + stats.framesReceived, + stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff, ), + style: theme.textTheme.bodySmall, ), - ), - const SizedBox(height: 8), - _PttHoldButton( - active: levelActive, - onHeldChanged: onPttHeldChanged, - ), - ], - // Leave-voice button intentionally absent: TeamSpeak's - // model is "user is always in some channel", not - // Discord's join/leave-voice. To stop being heard / - // hearing others, mute mic and/or speaker via the - // icons at the top of the bar. To physically move, - // tap a different channel in the tree below. - ], - ), - ), - ); - } -} - -class _LevelMeter extends StatelessWidget { - const _LevelMeter({required this.active}); - - final bool active; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - height: 8, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: FractionallySizedBox( - alignment: AlignmentDirectional.centerStart, - widthFactor: active ? 0.75 : 0.05, - child: Container( - decoration: BoxDecoration( - color: active - ? theme.colorScheme.primary - : theme.colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ); - } -} - -/// On-screen push-to-talk button for touch-only mobile platforms -/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in -/// favour of a bound key. -/// -/// Behaviour: -/// * `onPanDown` (finger touches the button) → fires -/// `onHeldChanged(true)`. The Rust release-tail timer treats -/// this as `key_down`. -/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) → -/// fires `onHeldChanged(false)` → `key_up` → tail expires → -/// mic closes. -/// -/// Using `GestureDetector` rather than `Listener` because we want -/// gesture-arena semantics: if the user starts dragging the -/// channel-tree underneath, the PTT should release. `onPanCancel` -/// fires in that case. -/// -/// The button visually mirrors the `_LevelMeter` state via the -/// `active` flag so the user gets feedback that holding actually -/// engaged the mic. -class _PttHoldButton extends StatefulWidget { - const _PttHoldButton({required this.active, required this.onHeldChanged}); - - final bool active; - final ValueChanged onHeldChanged; - - @override - State<_PttHoldButton> createState() => _PttHoldButtonState(); -} - -class _PttHoldButtonState extends State<_PttHoldButton> { - bool _pressed = false; - - @override - void initState() { - super.initState(); - unawaited(Haptics.prepare().catchError((_) => false)); - } - - void _setHeld(bool held) { - if (_pressed == held) return; - setState(() => _pressed = held); - widget.onHeldChanged(held); - _playPressHaptic(held); - } - - void _playPressHaptic(bool held) { - final haptic = held - ? Haptics.impact(HapticImpactStyle.medium) - : Haptics.selection(); - unawaited(haptic.catchError((_) {})); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final activeNow = _pressed || widget.active; - final l10n = AppL10n.of(context); - - return Semantics( - button: true, - liveRegion: true, - label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk, - hint: l10n.pttHoldToTalkSemanticsHint, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => _setHeld(true), - onTapUp: (_) => _setHeld(false), - onTapCancel: () => _setHeld(false), - onPanDown: (_) => _setHeld(true), - onPanEnd: (_) => _setHeld(false), - onPanCancel: () => _setHeld(false), - child: ExcludeSemantics( - child: AnimatedContainer( - duration: const Duration(milliseconds: 80), - height: 64, - decoration: BoxDecoration( - color: activeNow - ? theme.colorScheme.primary - : theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - boxShadow: activeNow - ? [ - BoxShadow( - color: theme.colorScheme.primary.withAlpha(100), - blurRadius: 12, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - activeNow ? Icons.mic : Icons.mic_none, - color: activeNow - ? theme.colorScheme.onPrimary - : theme.colorScheme.onPrimaryContainer, - size: 24, - ), - const SizedBox(width: 10), - Text( - activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: activeNow - ? theme.colorScheme.onPrimary - : theme.colorScheme.onPrimaryContainer, + const SizedBox(height: 6), + // PTT capability badge — only relevant when PTT mode is + // active. Hidden for Continuous / Voice Activity since + // there's no key binding to surface a capability for. + // The badge is information-only; the user reaches the + // bind-key flow through the Voice Bar's settings gear + // (single configuration entry point — see the comment + // on `onConfigure`). + // + // Still shown on touch-only mobile hosts because iOS P0 + // acceptance requires an explicit `L0Focused` badge and + // explanation that global hotkeys are not available in + // the iOS sandbox. + if (isPtt) + PttCapabilityBadge( + level: pttLevel, + backendId: pttBackendId, + boundInputClass: pttBoundInputClass, + ), + // On touch-only mobile hosts the Push to Talk button is + // the LAST element of the Voice Bar so it lands closest + // to the user's thumb when the Voice Bar is pinned to + // the bottom of a narrow-layout screen. The release- + // tail value sits above the button so the user sees + // how long their voice continues after they let go. + if (isPtt && isTouchOnlyPttHost) ...[ + const SizedBox(height: 4), + Center( + child: Text( + '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), - ], - ), - ), + ), + const SizedBox(height: 8), + VoicePttButton( + active: levelActive, + onHeldChanged: onPttHeldChanged, + height: 64, + borderRadius: 12, + iconSize: 24, + iconGap: 10, + blurRadius: 12, + spreadRadius: 0, + listenForPan: true, + labelLetterSpacing: null, + ), + ], + // Leave-voice button intentionally absent: TeamSpeak's + // model is "user is always in some channel", not + // Discord's join/leave-voice. To stop being heard / + // hearing others, mute mic and/or speaker via the + // icons at the top of the bar. To physically move, + // tap a different channel in the tree below. + ], ), ), ), diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 46e1fc1..e2797a1 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -5,27 +5,25 @@ // rc.8 follow-up: the AppBar gear icon was removed; the modal sheet // is now the **single** voice-controls surface on mobile. Mode + // release-tail are surfaced inline (radio buttons + slider) inside -// the modal. Audio output route picker is driven by `audio_session` -// (Ryan Heise, 865k downloads): we enumerate AVAudioSession's -// available inputs + current route ourselves and render a Discord/ -// WhatsApp-style 'Choose audio' bottom-sheet. Switching is via -// AVAudioSession.setPreferredInput(port) + overrideOutputAudioPort -// (.speaker | .none). We previously tried `audio_router 1.1.1` -// whose iOS path is AVRoutePickerView (the AirPlay button) \u2014 -// wrong UI: that only lists AirPlay output destinations, not the -// speaker/receiver/Bluetooth choices we want. +// the modal. -import 'dart:async' show StreamSubscription, Timer, unawaited; +import 'dart:async' show Timer, unawaited; import 'dart:io' show Platform; -import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:haptic_kit/haptic_kit.dart'; +import 'package:haptic_kit/haptic_kit.dart' show Haptics; import '../l10n/generated/app_localizations.dart'; +import 'audio_output_tile.dart'; +import 'audio_processing_config_state.dart'; import 'ptt_capability_badge.dart'; +import 'talk_power_warning.dart'; +import 'voice_haptics.dart'; +import 'voice_level_meter.dart'; +import 'voice_settings_controls.dart'; +import 'voice_status_summary.dart'; import '../src/rust/api.dart' as rust; /// Two-line status chip that summarises the current voice state. @@ -42,6 +40,9 @@ class VoiceStatusChip extends StatelessWidget { required this.onTap, this.inputMuted = false, this.outputMuted = false, + this.talkPower, + this.neededTalkPower, + this.talkPowerGranted, }); /// Current transmit mode. @@ -65,6 +66,15 @@ class VoiceStatusChip extends StatelessWidget { /// True when local speaker is muted. final bool outputMuted; + /// Own client's talk power. + final int? talkPower; + + /// Talk power required to speak in current channel. + final int? neededTalkPower; + + /// True when server granted talk power regardless of numeric value. + final bool? talkPowerGranted; + /// Open the voice details modal. final VoidCallback onTap; @@ -73,48 +83,23 @@ class VoiceStatusChip extends StatelessWidget { final theme = Theme.of(context); final l10n = AppL10n.of(context); - final stats = audioStats; - // For PTT: pttActive = button held. - // For Continuous: pttActive = always true (always transmitting). - // For VoiceActivity: pttActive = VAD gate open (speech detected). - final micOn = switch (transmitMode) { - rust.BridgeTransmitMode.continuous => true, - _ => stats?.pttActive ?? false, - }; - - final modeLabel = switch (transmitMode) { - rust.BridgeTransmitMode.ptt => l10n.voiceModePtt, - rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous, - rust.BridgeTransmitMode.voiceActivity => l10n.voiceModeVoiceActivity, - }; - - String line1; - if (transmitMode == rust.BridgeTransmitMode.ptt) { - if (isTouchOnly) { - line1 = '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'; - } else { - line1 = - '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'; - } - } else { - line1 = modeLabel; - } - - final tailText = transmitMode == rust.BridgeTransmitMode.ptt - ? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}' - : null; - final micText = inputMuted - ? '${l10n.voiceMicOff} (muted)' - : outputMuted - ? 'Speaker muted' - : (micOn ? l10n.voiceMicOn : l10n.voiceMicOff); - final line2 = tailText == null ? micText : '$tailText \u00b7 $micText'; - - final muted = inputMuted || outputMuted; + final summary = voiceStatusSummary( + l10n: l10n, + transmitMode: transmitMode, + releaseTailMs: releaseTailMs, + pttBoundKeyLabel: pttBoundKeyLabel, + isTouchOnly: isTouchOnly, + inputMuted: inputMuted, + outputMuted: outputMuted, + pttActive: audioStats?.pttActive ?? false, + talkPower: talkPower, + neededTalkPower: neededTalkPower, + talkPowerGranted: talkPowerGranted, + ); return Semantics( button: true, - label: '${l10n.voiceSheetTitle}: $line1, $line2', + label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', hint: l10n.voiceSettingsTitle, child: Material( type: MaterialType.transparency, @@ -128,25 +113,29 @@ class VoiceStatusChip extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: muted + color: summary.talkPowerBlocked + ? Colors.amber.withValues(alpha: 0.18) + : summary.muted ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) : theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( - color: muted + color: summary.talkPowerBlocked + ? Colors.amber.shade700 + : summary.muted ? theme.colorScheme.error : theme.colorScheme.outlineVariant, - width: muted ? 1.5 : 0.5, + width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, ), ), child: Row( children: [ Icon( - micOn + summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, size: 12, - color: micOn + color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline, ), @@ -157,7 +146,7 @@ class VoiceStatusChip extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - line1, + summary.line1, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( @@ -165,7 +154,7 @@ class VoiceStatusChip extends StatelessWidget { ), ), Text( - line2, + summary.line2, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( @@ -198,6 +187,14 @@ class VoicePttButton extends StatefulWidget { super.key, required this.active, required this.onHeldChanged, + this.height = 56, + this.borderRadius = 16, + this.iconSize = 28, + this.iconGap = 12, + this.blurRadius = 16, + this.spreadRadius = 2, + this.listenForPan = false, + this.labelLetterSpacing = 0.4, }); /// True while the engine reports the gate open. @@ -206,6 +203,30 @@ class VoicePttButton extends StatefulWidget { /// Called with `true` on finger-down, `false` on finger-up or cancel. final ValueChanged onHeldChanged; + /// Button height. + final double height; + + /// Outer corner radius. + final double borderRadius; + + /// Mic icon size. + final double iconSize; + + /// Space between icon and label. + final double iconGap; + + /// Active-state shadow blur radius. + final double blurRadius; + + /// Active-state shadow spread radius. + final double spreadRadius; + + /// Also react to pan start/end/cancel in addition to tap gestures. + final bool listenForPan; + + /// Optional label letter spacing. + final double? labelLetterSpacing; + @override State createState() => _VoicePttButtonState(); } @@ -216,21 +237,14 @@ class _VoicePttButtonState extends State { @override void initState() { super.initState(); - unawaited(Haptics.prepare().catchError((_) => false)); + prepareVoiceHaptics(); } void _setHeld(bool held) { if (_pressed == held) return; setState(() => _pressed = held); widget.onHeldChanged(held); - _playPressHaptic(held); - } - - void _playPressHaptic(bool held) { - final haptic = held - ? Haptics.impact(HapticImpactStyle.medium) - : Haptics.selection(); - unawaited(haptic.catchError((_) {})); + playVoicePttHaptic(held); } @override @@ -249,21 +263,24 @@ class _VoicePttButtonState extends State { onTapDown: (_) => _setHeld(true), onTapUp: (_) => _setHeld(false), onTapCancel: () => _setHeld(false), + onPanDown: widget.listenForPan ? (_) => _setHeld(true) : null, + onPanEnd: widget.listenForPan ? (_) => _setHeld(false) : null, + onPanCancel: widget.listenForPan ? () => _setHeld(false) : null, child: ExcludeSemantics( child: AnimatedContainer( duration: const Duration(milliseconds: 80), - height: 56, + height: widget.height, decoration: BoxDecoration( color: activeNow ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(widget.borderRadius), boxShadow: activeNow ? [ BoxShadow( color: theme.colorScheme.primary.withAlpha(100), - blurRadius: 16, - spreadRadius: 2, + blurRadius: widget.blurRadius, + spreadRadius: widget.spreadRadius, offset: const Offset(0, 2), ), ] @@ -278,14 +295,14 @@ class _VoicePttButtonState extends State { color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, - size: 28, + size: widget.iconSize, ), - const SizedBox(width: 12), + SizedBox(width: widget.iconGap), Text( activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, - letterSpacing: 0.4, + letterSpacing: widget.labelLetterSpacing, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, @@ -329,6 +346,9 @@ Future showVoiceDetailsSheet( required ValueChanged onModeChanged, required ValueChanged onReleaseTailChanged, required ValueChanged onAudioConfigChanged, + int? talkPower, + int? neededTalkPower, + bool? talkPowerGranted, }) async { await showModalBottomSheet( context: context, @@ -356,6 +376,9 @@ Future showVoiceDetailsSheet( onModeChanged: onModeChanged, onReleaseTailChanged: onReleaseTailChanged, onAudioConfigChanged: onAudioConfigChanged, + talkPower: talkPower, + neededTalkPower: neededTalkPower, + talkPowerGranted: talkPowerGranted, ), ); }, @@ -376,6 +399,9 @@ class _VoiceSheetBody extends StatefulWidget { required this.onModeChanged, required this.onReleaseTailChanged, required this.onAudioConfigChanged, + this.talkPower, + this.neededTalkPower, + this.talkPowerGranted, }); final ScrollController scrollController; @@ -390,6 +416,9 @@ class _VoiceSheetBody extends StatefulWidget { final ValueChanged onModeChanged; final ValueChanged onReleaseTailChanged; final ValueChanged onAudioConfigChanged; + final int? talkPower; + final int? neededTalkPower; + final bool? talkPowerGranted; @override State<_VoiceSheetBody> createState() => _VoiceSheetBodyState(); @@ -410,26 +439,14 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { int _rxRate = 0; // frames/s int _rateTickCount = 0; - // Audio processing state. - late bool _nsEnabled; - late bool _aecEnabled; - late bool _agcEnabled; - late bool _hpfEnabled; - late bool _preferHardware; - late rust.BridgeVadBackend _vadBackend; + late final AudioProcessingConfigState _audioProcessing; @override void initState() { super.initState(); - final c = widget.initialAudioConfig; - _nsEnabled = c.ns != rust.BridgeEffectOwner.off; - _aecEnabled = c.aec != rust.BridgeEffectOwner.off; - _agcEnabled = c.agc != rust.BridgeEffectOwner.off; - _hpfEnabled = c.hpfEnabled; - _preferHardware = c.aec == rust.BridgeEffectOwner.platform; - _vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad - : c.vadBackend; + _audioProcessing = AudioProcessingConfigState.fromConfig( + widget.initialAudioConfig, + ); // Poll audio stats at 80 ms so TX/RX counters and the level meter // update in real time while the sheet is open, independent of the parent. @@ -460,75 +477,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { } rust.BridgeAudioProcessingConfig _buildConfig() { - final c = widget.initialAudioConfig; - final isSonora = - c.iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental; - final isAndroid = Platform.isAndroid; - - if (isAndroid) { - final owner = _preferHardware - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.webrtcApm; - return rust.BridgeAudioProcessingConfig( - route: c.route, - iosMode: c.iosMode, - processingBackend: _preferHardware - ? rust.BridgeAudioBackend.platformVoiceProcessing - : rust.BridgeAudioBackend.webrtcApm, - vadBackend: _vadBackend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad - : _vadBackend, - aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off, - ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off, - agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off, - hpfEnabled: _hpfEnabled, - limiterEnabled: c.limiterEnabled, - vadHangoverMs: c.vadHangoverMs, - vadPreRollMs: c.vadPreRollMs, - vadMinTxMs: c.vadMinTxMs, - debugWavDumpEnabled: c.debugWavDumpEnabled, - ); - } - - // iOS / macOS: VPIO vs Sonora paths. - // VPIO owns enabled effects on the default path. The experimental raw path - // delegates app-side processing to WebRTC APM. - final aecOwner = isSonora - ? (_aecEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : rust.BridgeEffectOwner.platform; - final nsOwner = isSonora - ? (_nsEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : (_nsEnabled - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.off); - final agcOwner = isSonora - ? (_agcEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : (_agcEnabled - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.off); - return rust.BridgeAudioProcessingConfig( - route: c.route, - iosMode: c.iosMode, - processingBackend: c.processingBackend, - vadBackend: _vadBackend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad - : _vadBackend, - aec: aecOwner, - ns: nsOwner, - agc: agcOwner, - hpfEnabled: _hpfEnabled, - limiterEnabled: c.limiterEnabled, - vadHangoverMs: c.vadHangoverMs, - vadPreRollMs: c.vadPreRollMs, - vadMinTxMs: c.vadMinTxMs, - debugWavDumpEnabled: c.debugWavDumpEnabled, - ); + return _audioProcessing.buildConfig(base: widget.initialAudioConfig); } void _notifyAudioConfig() { @@ -577,13 +526,13 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { // 1) Audio output route picker tile (mobile only). if (showRoutePicker) ...[ - const _AudioOutputTile(), + const AudioOutputTile(), const SizedBox(height: 8), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), ], - // 2) Mode \u2014 inline radio rows. + // Transmit mode. Text( l10n.voiceModeLabel, style: theme.textTheme.labelLarge?.copyWith( @@ -661,7 +610,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { const SizedBox(height: 12), // 4) Level meter + live TX/RX stats. - _LevelMeter(active: levelActive), + VoiceLevelMeter(active: levelActive), const SizedBox(height: 6), _StatsRow( txRate: _txRate, @@ -671,7 +620,20 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { transmitting: levelActive, ), - // 5) Audio processing — always visible, all modes. + if (isTalkPowerBlocked( + talkPower: widget.talkPower, + neededTalkPower: widget.neededTalkPower, + talkPowerGranted: widget.talkPowerGranted, + )) ...[ + const SizedBox(height: 8), + TalkPowerWarning( + talkPower: widget.talkPower, + neededTalkPower: widget.neededTalkPower, + talkPowerGranted: widget.talkPowerGranted, + ), + ], + + // Audio processing. const SizedBox(height: 12), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), @@ -683,99 +645,151 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { ), const SizedBox(height: 4), - // Android: hardware (JNI) vs software (WebRTC APM) + // Android HW/SW selector. if (Platform.isAndroid) ...[ - _AudioToggleRow( - label: 'Prefer hardware effects', - subtitle: _preferHardware - ? 'Try JNI hardware · software fallback' - : 'Software WebRTC AEC3 · NS · AGC2', - value: _preferHardware, - onChanged: (v) { - setState(() => _preferHardware = v); + const VoiceSubHeader('Processing backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: androidProcessingSegments, + selected: {_audioProcessing.preferHardware}, + onSelectionChanged: (s) { + setState(() => _audioProcessing.preferHardware = s.first); _notifyAudioConfig(); }, ), ], - _AudioToggleRow( + AudioProcessingToggleRow( + dense: true, label: 'Noise suppression', subtitle: 'Wiener filter', - value: _nsEnabled, + value: _audioProcessing.nsEnabled, onChanged: (v) { - setState(() => _nsEnabled = v); + setState(() => _audioProcessing.nsEnabled = v); _notifyAudioConfig(); }, ), - _AudioToggleRow( + AudioProcessingToggleRow( + dense: true, label: 'Echo cancellation', subtitle: () { if (Platform.isAndroid) { return 'WebRTC AEC3 · adaptive filter'; } return widget.initialAudioConfig.iosMode == - rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing + rust + .BridgeIosVoiceProcessingMode + .platformVoiceProcessing ? 'Always on · managed by platform VPIO' : 'AEC3 adaptive filter · 80 ms tail'; }(), - value: - () { - if (Platform.isAndroid) return _aecEnabled; - return widget.initialAudioConfig.iosMode == - rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing - ? true - : _aecEnabled; - }(), - // AEC is always on in VPIO — disable the toggle. - // On Android, AEC is user-selectable. + value: () { + if (Platform.isAndroid) return _audioProcessing.aecEnabled; + return widget.initialAudioConfig.iosMode == + rust + .BridgeIosVoiceProcessingMode + .platformVoiceProcessing + ? true + : _audioProcessing.aecEnabled; + }(), onChanged: () { - if (Platform.isAndroid) { - return (v) { - setState(() => _aecEnabled = v); + if (Platform.isAndroid) { + return (v) { + setState(() => _audioProcessing.aecEnabled = v); + _notifyAudioConfig(); + }; + } + return widget.initialAudioConfig.iosMode == + rust + .BridgeIosVoiceProcessingMode + .platformVoiceProcessing + ? null + : (v) { + setState(() => _audioProcessing.aecEnabled = v); _notifyAudioConfig(); }; - } - return widget.initialAudioConfig.iosMode == - rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing - ? null - : (v) { - setState(() => _aecEnabled = v); - _notifyAudioConfig(); - }; - }(), + }(), ), - _AudioToggleRow( + AudioProcessingToggleRow( + dense: true, label: 'Auto gain control', subtitle: 'AGC2 · −18 dBFS target', - value: _agcEnabled, + value: _audioProcessing.agcEnabled, onChanged: (v) { - setState(() => _agcEnabled = v); + setState(() => _audioProcessing.agcEnabled = v); _notifyAudioConfig(); }, ), - _AudioToggleRow( + AudioProcessingToggleRow( + dense: true, label: 'High-pass filter', subtitle: '80 Hz · DC removal', - value: _hpfEnabled, + value: _audioProcessing.hpfEnabled, onChanged: (v) { - setState(() => _hpfEnabled = v); + setState(() => _audioProcessing.hpfEnabled = v); + _notifyAudioConfig(); + }, + ), + AudioProcessingToggleRow( + dense: true, + label: 'Peak limiter', + subtitle: '−1 dBFS soft-knee · 2 ms look-ahead', + value: _audioProcessing.limiterEnabled, + onChanged: (v) { + setState(() => _audioProcessing.limiterEnabled = v); _notifyAudioConfig(); }, ), - // VAD backend selector. + // iOS mode selector. + if (Platform.isIOS) ...[ + const VoiceSubHeader('Processing backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: iosProcessingSegments, + selected: {_audioProcessing.iosMode}, + onSelectionChanged: (s) { + setState(() => _audioProcessing.iosMode = s.first); + _notifyAudioConfig(); + }, + ), + ], + + // VAD backend. const SizedBox(height: 8), Text( - 'Voice activity detection', + 'Voice activity detection (VAD)', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 2), - _VadBackendRow( - value: _vadBackend, + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: vadBackendSegments, + selected: {_audioProcessing.vadBackend}, + onSelectionChanged: (s) { + setState(() => _audioProcessing.vadBackend = s.first); + _notifyAudioConfig(); + }, + ), + + // Debug. + const SizedBox(height: 8), + Text( + 'Debug', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + AudioProcessingToggleRow( + dense: true, + label: 'WAV dump', + subtitle: 'Record raw/processed mic to temp dir', + value: _audioProcessing.debugWavDump, onChanged: (v) { - setState(() => _vadBackend = v); + setState(() => _audioProcessing.debugWavDump = v); _notifyAudioConfig(); }, ), @@ -853,96 +867,6 @@ class _StatsRow extends StatelessWidget { // ── Audio processing helper widgets ────────────────────────────────────── -/// Compact toggle row for a single DSP stage. -class _AudioToggleRow extends StatelessWidget { - const _AudioToggleRow({ - required this.label, - required this.subtitle, - required this.value, - required this.onChanged, - }); - - final String label; - final String subtitle; - final bool value; - final ValueChanged? onChanged; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final disabled = onChanged == null; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: disabled - ? theme.colorScheme.onSurfaceVariant.withAlpha(120) - : null, - ), - ), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withAlpha( - disabled ? 80 : 160, - ), - ), - ), - ], - ), - ), - Switch(value: value, onChanged: onChanged), - ], - ), - ); - } -} - -/// Segmented VAD backend selector. -class _VadBackendRow extends StatelessWidget { - const _VadBackendRow({required this.value, required this.onChanged}); - - final rust.BridgeVadBackend value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SegmentedButton( - style: SegmentedButton.styleFrom( - textStyle: theme.textTheme.labelSmall, - visualDensity: VisualDensity.compact, - ), - segments: const [ - ButtonSegment( - value: rust.BridgeVadBackend.webrtcVad, - label: Text('WebRTC'), - icon: Icon(Icons.speed, size: 14), - ), - ButtonSegment( - value: rust.BridgeVadBackend.sileroOnnx, - label: Text('Silero'), - icon: Icon(Icons.psychology, size: 14), - ), - ButtonSegment( - value: rust.BridgeVadBackend.tenVad, - label: Text('TEN'), - icon: Icon(Icons.graphic_eq, size: 14), - ), - ], - selected: {value}, - onSelectionChanged: (s) => onChanged(s.first), - ); - } -} - // ── Mode row ────────────────────────────────────────────────────────────── class _ModeRow extends StatelessWidget { @@ -991,9 +915,16 @@ class _ModeRow extends StatelessWidget { Icon(icon, size: 18, color: color), const SizedBox(width: 8), Expanded( - child: Text( - label, - style: theme.textTheme.bodyLarge?.copyWith(color: color), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodyLarge?.copyWith( + color: color, + ), + ), + ], ), ), ], @@ -1004,700 +935,3 @@ class _ModeRow extends StatelessWidget { ); } } - -/// Tile that displays the current audio output route + opens the -/// native picker on tap. Subscribes to `currentDeviceStream` so the -/// row auto-updates when the user plugs in headphones, connects -/// AirPods, etc. -/// Tile that displays the **active** audio output port (Speaker / -/// iPhone receiver / AirPods / wired headset / etc.) and opens a -/// 'Choose audio' bottom sheet on tap. Backed by `audio_session`: -/// -/// * `AVAudioSession.currentRoute.outputs` for the active label. -/// * `AVAudioSession.availableInputs` for the picker list of -/// selectable inputs (Built-in mic, BT HFP, wired headset, USB). -/// * `routeChangeStream` for live updates. -/// * `setPreferredInput(port)` for input selection (also moves -/// the matching output for HFP/headset/wired). -/// * `overrideOutputAudioPort(.speaker | .none)` for the -/// speakerphone <-> earpiece toggle. -/// -/// This is the same primitive used by Discord / WhatsApp / FaceTime -/// for their VoIP audio chooser. It is NOT the AirPlay picker -/// (`AVRoutePickerView`), which is a different UI for streaming -/// audio to other devices. -class _AudioOutputTile extends StatefulWidget { - const _AudioOutputTile(); - - @override - State<_AudioOutputTile> createState() => _AudioOutputTileState(); -} - -class _AudioOutputTileState extends State<_AudioOutputTile> { - static const _androidOutputChannel = MethodChannel('app.audio_output'); - static const _androidOutputEvents = EventChannel('app.audio_output/events'); - - AVAudioSessionPortDescription? _activeOutput; - StreamSubscription? _routeSub; - StreamSubscription? _androidOutputSub; - List<_AndroidAudioOutputDevice> _androidDevices = const []; - bool _androidLoading = false; - - @override - void initState() { - super.initState(); - if (!kIsWeb && Platform.isAndroid) { - _refreshAndroidDevices(); - _androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen(( - _, - ) { - if (!mounted) return; - _refreshAndroidDevices(); - }); - return; - } - _refresh(); - // Live updates when the user plugs / unplugs / connects a - // headset / BT device while the modal sheet is open. - _routeSub = AVAudioSession().routeChangeStream.listen((_) { - if (!mounted) return; - _refresh(); - }); - } - - @override - void dispose() { - _routeSub?.cancel(); - _routeSub = null; - _androidOutputSub?.cancel(); - _androidOutputSub = null; - super.dispose(); - } - - Future _refreshAndroidDevices() async { - if (_androidLoading) return; - setState(() => _androidLoading = true); - try { - final raw = - await _androidOutputChannel.invokeListMethod( - 'getOutputDevices', - ) ?? - const []; - final devices = raw - .whereType>() - .map(_AndroidAudioOutputDevice.fromMap) - .toList(); - if (!mounted) return; - setState(() { - _androidDevices = devices; - _androidLoading = false; - }); - } catch (_) { - if (!mounted) return; - setState(() => _androidLoading = false); - } - } - - Future _refresh() async { - try { - final route = await AVAudioSession().currentRoute; - if (!mounted) return; - setState(() { - // The 'output' port we want to display is whichever output - // the system has currently routed to. There is normally one. - _activeOutput = route.outputs.isEmpty ? null : route.outputs.first; - }); - } catch (_) { - // Suppress \u2014 AVAudioSession may transiently throw on first - // call before the session is active. - } - } - - static String _portLabel( - AVAudioSessionPort? type, - String fallback, - AppL10n l10n, - ) { - switch (type) { - case AVAudioSessionPort.builtInSpeaker: - return l10n.audioRouteSpeaker; - case AVAudioSessionPort.builtInReceiver: - return l10n.audioRouteReceiver; - case AVAudioSessionPort.bluetoothHfp: - case AVAudioSessionPort.bluetoothA2dp: - case AVAudioSessionPort.bluetoothLe: - return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback; - case AVAudioSessionPort.headphones: - case AVAudioSessionPort.headsetMic: - return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback; - case AVAudioSessionPort.carAudio: - return l10n.audioRouteCarAudio; - case AVAudioSessionPort.airPlay: - return l10n.audioRouteAirplay; - case AVAudioSessionPort.builtInMic: - // Built-in mic is implied 'iPhone' \u2014 only seen if we somehow - // end up with an input listed as an output. - return fallback.isEmpty ? l10n.audioRouteReceiver : fallback; - case null: - default: - return fallback.isEmpty ? l10n.audioRouteUnknown : fallback; - } - } - - static IconData _portIcon(AVAudioSessionPort? type) { - switch (type) { - case AVAudioSessionPort.builtInSpeaker: - return Icons.volume_up; - case AVAudioSessionPort.builtInReceiver: - return Icons.phone_in_talk; - case AVAudioSessionPort.bluetoothHfp: - case AVAudioSessionPort.bluetoothA2dp: - case AVAudioSessionPort.bluetoothLe: - return Icons.bluetooth_audio; - case AVAudioSessionPort.headphones: - case AVAudioSessionPort.headsetMic: - return Icons.headset; - case AVAudioSessionPort.carAudio: - return Icons.directions_car; - case AVAudioSessionPort.airPlay: - return Icons.airplay; - default: - return Icons.speaker; - } - } - - Future _openPicker() async { - if (!kIsWeb && Platform.isAndroid) { - final selectedDeviceId = await showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (ctx) => _AndroidAudioOutputPickerSheet( - devices: _androidDevices, - loading: _androidLoading, - onRefresh: _refreshAndroidDevices, - ), - ); - if (selectedDeviceId == null) return; - try { - if (selectedDeviceId == 'auto') { - await _androidOutputChannel.invokeMethod( - 'clearCommunicationDevice', - ); - } else { - final changed = await _androidOutputChannel.invokeMethod( - 'setCommunicationDevice', - {'deviceId': selectedDeviceId}, - ); - if (changed != true && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppL10n.of(context).audioRouteCannotSelect), - ), - ); - } - } - await _refreshAndroidDevices(); - } catch (_) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)), - ); - } - return; - } - - await showModalBottomSheet( - context: context, - showDragHandle: true, - isScrollControlled: true, - builder: (ctx) { - return const _AudioOutputPickerSheet(); - }, - ); - // Refresh after the picker closes (user may have changed route). - await _refresh(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppL10n.of(context); - if (!kIsWeb && Platform.isAndroid) { - final selected = _androidDevices.where((d) => d.isSelected).firstOrNull; - final label = selected == null - ? l10n.audioRouteSystemDefault - : _androidDeviceLabel(selected.type, selected.name, l10n); - return InkWell( - onTap: _openPicker, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), - child: Row( - children: [ - Icon( - _androidDeviceIcon(selected?.type), - color: theme.colorScheme.primary, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.audioOutputLabel, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - _androidLoading ? '${l10n.audioRouteUnknown}…' : label, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: theme.colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ); - } - - final port = _activeOutput; - final label = _portLabel(port?.portType, port?.portName ?? '', l10n); - return InkWell( - onTap: _openPicker, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), - child: Row( - children: [ - Icon(_portIcon(port?.portType), color: theme.colorScheme.primary), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.audioOutputLabel, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - label, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: theme.colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ); - } - - static String _androidDeviceLabel( - String type, - String fallback, - AppL10n l10n, - ) => switch (type) { - 'speaker' => l10n.audioRouteSpeaker, - 'earpiece' => l10n.audioRouteEarpiece, - 'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset, - 'bluetoothA2dp' || - 'bluetoothSco' || - 'bluetoothLe' => l10n.audioRouteBluetooth, - 'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback, - 'hdmi' => l10n.audioRouteCarAudio, - _ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback, - }; - - static IconData _androidDeviceIcon(String? type) => switch (type) { - 'speaker' => Icons.volume_up, - 'earpiece' => Icons.phone_in_talk, - 'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset, - 'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio, - 'hdmi' => Icons.tv, - _ => Icons.speaker, - }; -} - -class _AndroidAudioOutputPickerSheet extends StatelessWidget { - const _AndroidAudioOutputPickerSheet({ - required this.devices, - required this.loading, - required this.onRefresh, - }); - - final List<_AndroidAudioOutputDevice> devices; - final bool loading; - final Future Function() onRefresh; - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final theme = Theme.of(context); - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), - const SizedBox(height: 16), - _PickerRow( - icon: Icons.speaker, - label: l10n.audioRouteSystemDefault, - selected: !devices.any((d) => d.isSelected), - onTap: () => Navigator.of(context).pop('auto'), - ), - if (loading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ) - else if (devices.isEmpty) - TextButton.icon( - onPressed: onRefresh, - icon: const Icon(Icons.refresh), - label: Text(l10n.audioRouteRefreshDevices), - ) - else - for (final device in devices) - _PickerRow( - icon: _AudioOutputTileState._androidDeviceIcon(device.type), - label: _AudioOutputTileState._androidDeviceLabel( - device.type, - device.name, - l10n, - ), - selected: device.isSelected, - onTap: device.isAvailableForCommunication - ? () => Navigator.of(context).pop(device.id) - : null, - ), - ], - ), - ), - ); - } -} - -class _AndroidAudioOutputDevice { - const _AndroidAudioOutputDevice({ - required this.id, - required this.name, - required this.type, - required this.isSelected, - required this.isAvailableForCommunication, - }); - - final String id; - final String name; - final String type; - final bool isSelected; - final bool isAvailableForCommunication; - - factory _AndroidAudioOutputDevice.fromMap(Map map) { - return _AndroidAudioOutputDevice( - id: map['id']?.toString() ?? '', - name: map['name']?.toString() ?? '', - type: map['type']?.toString() ?? 'unknown', - isSelected: map['isSelected'] == true, - isAvailableForCommunication: map['isAvailableForCommunication'] == true, - ); - } -} - -/// 'Choose audio' bottom sheet that lists every selectable route -/// (Speaker, iPhone receiver, every connected BT / wired / USB -/// input). Tap to switch; Speaker / Receiver use -/// `overrideOutputAudioPort(.speaker | .none)`, other ports use -/// `setPreferredInput(port)` which also moves the paired output -/// (e.g. AirPods). -class _AudioOutputPickerSheet extends StatefulWidget { - const _AudioOutputPickerSheet(); - - @override - State<_AudioOutputPickerSheet> createState() => - _AudioOutputPickerSheetState(); -} - -class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { - Set _availableInputs = const {}; - AVAudioSessionRouteDescription? _route; - StreamSubscription? _routeSub; - bool _loading = true; - - @override - void initState() { - super.initState(); - _refresh(); - _routeSub = AVAudioSession().routeChangeStream.listen((_) { - if (!mounted) return; - _refresh(); - }); - } - - @override - void dispose() { - _routeSub?.cancel(); - _routeSub = null; - super.dispose(); - } - - Future _refresh() async { - try { - final session = AVAudioSession(); - final inputs = await session.availableInputs; - final route = await session.currentRoute; - if (!mounted) return; - setState(() { - _availableInputs = inputs; - _route = route; - _loading = false; - }); - } catch (_) { - if (!mounted) return; - setState(() => _loading = false); - } - } - - Future _selectSpeaker() async { - try { - // Apple-documented quirk: in .voiceChat mode, calling - // setPreferredInput(builtInMic) AFTER overrideOutputAudioPort(.speaker) - // causes iOS to recalculate the route. Built-in mic naturally - // pairs with the receiver (not the speaker), so the system - // SILENTLY REVERTS the speaker override and routes audio - // back through the earpiece. Net: the await chain returns - // successfully ('no exception'), but the user hears no - // change. - // - // Fix: do NOT call setPreferredInput when forcing speaker. - // The speaker override is sufficient on its own \u2014 input - // remains on whatever the system was already using (built-in - // mic by default, or BT/wired if connected and selected - // elsewhere). For consistency, only switch input when the - // user explicitly picks a non-speaker input row. - await AVAudioSession().overrideOutputAudioPort( - AVAudioSessionPortOverride.speaker, - ); - unawaited(Haptics.selection().catchError((_) {})); - debugPrint('chanora: audio output -> speakerphone (override applied)'); - } catch (e, st) { - debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); - } - if (!mounted) return; - Navigator.of(context).pop(); - } - - Future _selectReceiver() async { - try { - // Same quirk in reverse: removing the speaker override - // (.none) is enough to restore the .voiceChat default route, - // which is the built-in receiver. Calling setPreferredInput - // explicitly here is redundant and risks the same recalc - // race that broke _selectSpeaker before. - await AVAudioSession().overrideOutputAudioPort( - AVAudioSessionPortOverride.none, - ); - unawaited(Haptics.selection().catchError((_) {})); - debugPrint('chanora: audio output -> receiver (override cleared)'); - } catch (e, st) { - debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); - } - if (!mounted) return; - Navigator.of(context).pop(); - } - - Future _selectInput(AVAudioSessionPortDescription port) async { - try { - // Drop any speakerphone override so the route follows the - // selected input. BT / wired headset / USB inputs pair their - // OWN output (the user hears audio through the same device - // they speak into), so .none + setPreferredInput is the - // correct combo here. - await AVAudioSession().overrideOutputAudioPort( - AVAudioSessionPortOverride.none, - ); - await AVAudioSession().setPreferredInput(port); - unawaited(Haptics.selection().catchError((_) {})); - debugPrint('chanora: audio output -> ${port.portType}'); - } catch (e, st) { - debugPrint('chanora: _selectInput FAILED: $e\n$st'); - } - if (!mounted) return; - Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppL10n.of(context); - - if (_loading) { - return const SafeArea( - child: Padding( - padding: EdgeInsets.all(40), - child: Center(child: CircularProgressIndicator()), - ), - ); - } - - final currentOutputType = _route?.outputs.isNotEmpty == true - ? _route!.outputs.first.portType - : null; - final currentInputUid = _route?.inputs.isNotEmpty == true - ? _route!.inputs.first.uid - : null; - - // Whether the active route is the speakerphone override (built-in - // speaker is the output but the actual session category isn't - // playback \u2014 it's playAndRecord + override). - final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker; - final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver; - - // Non-built-in inputs (BT / wired / USB / car audio) for the - // device-specific rows. Built-in mic is rendered as 'iPhone - // (receiver)' above; we filter it out here. - final externalInputs = _availableInputs - .where((p) => p.portType != AVAudioSessionPort.builtInMic) - .toList(); - - return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), - const SizedBox(height: 16), - _PickerRow( - icon: Icons.volume_up, - label: l10n.audioRouteSpeaker, - selected: isSpeaker, - onTap: _selectSpeaker, - ), - _PickerRow( - icon: Icons.phone_in_talk, - label: l10n.audioRouteReceiver, - selected: isReceiver, - onTap: _selectReceiver, - ), - for (final port in externalInputs) - _PickerRow( - icon: _AudioOutputTileState._portIcon(port.portType), - label: _AudioOutputTileState._portLabel( - port.portType, - port.portName, - l10n, - ), - selected: !isSpeaker && port.uid == currentInputUid, - onTap: () => _selectInput(port), - ), - ], - ), - ), - ); - } -} - -class _PickerRow extends StatelessWidget { - const _PickerRow({ - required this.icon, - required this.label, - required this.selected, - required this.onTap, - }); - - final IconData icon; - final String label; - final bool selected; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final enabled = onTap != null; - final color = selected - ? theme.colorScheme.primary - : enabled - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant.withAlpha(130); - return Semantics( - button: true, - selected: selected, - enabled: enabled, - label: label, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: ExcludeSemantics( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), - child: Row( - children: [ - Icon(icon, color: color), - const SizedBox(width: 16), - Expanded( - child: Text( - label, - style: theme.textTheme.bodyLarge?.copyWith(color: color), - ), - ), - if (selected) - Icon(Icons.check, color: theme.colorScheme.primary), - ], - ), - ), - ), - ), - ); - } -} - -class _LevelMeter extends StatelessWidget { - const _LevelMeter({required this.active}); - - final bool active; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - height: 8, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: FractionallySizedBox( - alignment: AlignmentDirectional.centerStart, - widthFactor: active ? 0.75 : 0.05, - child: Container( - decoration: BoxDecoration( - color: active - ? theme.colorScheme.primary - : theme.colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ); - } -} diff --git a/apps/chanora_flutter/lib/widgets/voice_haptics.dart b/apps/chanora_flutter/lib/widgets/voice_haptics.dart new file mode 100644 index 0000000..77d5cc7 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_haptics.dart @@ -0,0 +1,16 @@ +import 'dart:async' show unawaited; + +import 'package:haptic_kit/haptic_kit.dart'; + +/// Prepare mobile haptics without surfacing platform failures. +void prepareVoiceHaptics() { + unawaited(Haptics.prepare().catchError((_) => false)); +} + +/// Play the standard touch PTT haptic without surfacing platform failures. +void playVoicePttHaptic(bool held) { + final haptic = held + ? Haptics.impact(HapticImpactStyle.medium) + : Haptics.selection(); + unawaited(haptic.catchError((_) {})); +} diff --git a/apps/chanora_flutter/lib/widgets/voice_level_meter.dart b/apps/chanora_flutter/lib/widgets/voice_level_meter.dart new file mode 100644 index 0000000..87fce59 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_level_meter.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// Shared compact level meter used by voice surfaces. +class VoiceLevelMeter extends StatelessWidget { + const VoiceLevelMeter({super.key, required this.active}); + + final bool active; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: FractionallySizedBox( + alignment: AlignmentDirectional.centerStart, + widthFactor: active ? 0.75 : 0.05, + child: Container( + decoration: BoxDecoration( + color: active + ? theme.colorScheme.primary + : theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index ce00bdb..8fc3bfe 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -7,15 +7,19 @@ // - VAD backend // - iOS voice processing mode -// ignore_for_file: deprecated_member_use - import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; +import 'audio_device_list_tile.dart'; +import 'audio_output_tile.dart'; +import 'audio_processing_config_state.dart'; +import 'ptt_capability_badge.dart'; +import 'talk_power_warning.dart'; import 'voice_platform.dart'; +import 'voice_settings_controls.dart'; import '../src/rust/api.dart' as rust; bool get _isAndroid { @@ -50,11 +54,23 @@ class VoiceSettingsDialog extends StatefulWidget { required this.initialMode, required this.initialReleaseTailMs, required this.initialAudioConfig, + this.pttLevel = '', + this.pttBackendId = '', + this.pttBoundInputClass = '', + this.talkPower, + this.neededTalkPower, + this.talkPowerGranted, }); final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final rust.BridgeAudioProcessingConfig initialAudioConfig; + final String pttLevel; + final String pttBackendId; + final String pttBoundInputClass; + final int? talkPower; + final int? neededTalkPower; + final bool? talkPowerGranted; @override State createState() => _VoiceSettingsDialogState(); @@ -64,16 +80,7 @@ class _VoiceSettingsDialogState extends State { late rust.BridgeTransmitMode _mode; late double _releaseTail; - // Audio processing state — mirrors BridgeAudioProcessingConfig fields. - late bool _nsEnabled; - late bool _aecEnabled; - late bool _agcEnabled; - late bool _hpfEnabled; - late bool _limiterEnabled; - late rust.BridgeVadBackend _vadBackend; - late rust.BridgeIosVoiceProcessingMode _iosMode; - late bool _debugWavDump; - late bool _preferHardware; // Android only: try JNI hardware effects + late final AudioProcessingConfigState _audioProcessing; @override void initState() { @@ -81,93 +88,15 @@ class _VoiceSettingsDialogState extends State { _mode = widget.initialMode; _releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble(); - final c = widget.initialAudioConfig; - _nsEnabled = c.ns != rust.BridgeEffectOwner.off; - _aecEnabled = c.aec != rust.BridgeEffectOwner.off; - _agcEnabled = c.agc != rust.BridgeEffectOwner.off; - _hpfEnabled = c.hpfEnabled; - _limiterEnabled = c.limiterEnabled; - _vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad - : c.vadBackend; - _iosMode = c.iosMode; - _debugWavDump = c.debugWavDumpEnabled; - _preferHardware = c.aec == rust.BridgeEffectOwner.platform - || c.ns == rust.BridgeEffectOwner.platform - || c.agc == rust.BridgeEffectOwner.platform; + _audioProcessing = AudioProcessingConfigState.fromConfig( + widget.initialAudioConfig, + ); } rust.BridgeAudioProcessingConfig _buildConfig() { - final c = widget.initialAudioConfig; - final isSonora = - _iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental; - - if (_isAndroid) { - final owner = _preferHardware - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.webrtcApm; - return rust.BridgeAudioProcessingConfig( - route: c.route, - iosMode: _iosMode, - processingBackend: _preferHardware - ? rust.BridgeAudioBackend.platformVoiceProcessing - : rust.BridgeAudioBackend.webrtcApm, - vadBackend: _vadBackend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad - : _vadBackend, - aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off, - ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off, - agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off, - hpfEnabled: _hpfEnabled, - limiterEnabled: _limiterEnabled, - vadHangoverMs: c.vadHangoverMs, - vadPreRollMs: c.vadPreRollMs, - vadMinTxMs: c.vadMinTxMs, - debugWavDumpEnabled: _debugWavDump, - ); - } - - // iOS / macOS: VPIO vs Sonora paths. - // In VPIO mode, enabled effects are platform-owned. The experimental raw - // path uses WebRTC APM ownership so config validation stays honest. - final aecOwner = isSonora - ? (_aecEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : rust.BridgeEffectOwner.platform; // VPIO always owns AEC - final nsOwner = isSonora - ? (_nsEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : (_nsEnabled - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.off); - final agcOwner = isSonora - ? (_agcEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : (_agcEnabled - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.off); - final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad - : _vadBackend; - return rust.BridgeAudioProcessingConfig( - route: c.route, - iosMode: _iosMode, - processingBackend: isSonora - ? rust.BridgeAudioBackend.webrtcApm - : rust.BridgeAudioBackend.platformVoiceProcessing, - vadBackend: vadBackend, - aec: aecOwner, - ns: nsOwner, - agc: agcOwner, - hpfEnabled: _hpfEnabled, - limiterEnabled: _limiterEnabled, - vadHangoverMs: c.vadHangoverMs, - vadPreRollMs: c.vadPreRollMs, - vadMinTxMs: c.vadMinTxMs, - debugWavDumpEnabled: _debugWavDump, + return _audioProcessing.buildConfig( + base: widget.initialAudioConfig, + isAndroid: _isAndroid, ); } @@ -176,7 +105,8 @@ class _VoiceSettingsDialogState extends State { final l10n = AppL10n.of(context); final theme = Theme.of(context); final platformVpio = - _iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing; + _audioProcessing.iosMode == + rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing; return AlertDialog( title: Text(l10n.voiceSettingsTitle), contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), @@ -188,24 +118,12 @@ class _VoiceSettingsDialogState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Transmit mode ────────────────────────────────────── - _sectionHeader(theme, l10n.voiceModeLabel), - _radioTile( - value: rust.BridgeTransmitMode.ptt, - groupValue: _mode, - title: Text(l10n.voiceModePtt), - onSelected: (v) => _mode = v, - ), - _radioTile( - value: rust.BridgeTransmitMode.continuous, - groupValue: _mode, - title: Text(l10n.voiceModeContinuous), - onSelected: (v) => _mode = v, - ), - _radioTile( - value: rust.BridgeTransmitMode.voiceActivity, - groupValue: _mode, - title: Text(l10n.voiceModeVoiceActivity), - onSelected: (v) => _mode = v, + VoiceSectionHeader(l10n.voiceModeLabel), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: transmitModeSegments, + selected: {_mode}, + onSelectionChanged: (s) => setState(() => _mode = s.first), ), // ── PTT options ──────────────────────────────────────── @@ -257,133 +175,148 @@ class _VoiceSettingsDialogState extends State { // ── Audio processing ─────────────────────────────────── const Divider(height: 24), - _sectionHeader(theme, 'Audio processing'), + const VoiceSectionHeader('Audio processing'), // iOS mode selector (iOS only) if (_isIos) ...[ - _subHeader(theme, 'Processing backend'), - _radioTile( - value: - rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, - groupValue: _iosMode, - title: const Text('Platform (VPIO)'), - subtitle: _tileSubtitle('Apple AEC · NS · AGC'), - onSelected: (v) => _iosMode = v, - ), - _radioTile( - value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental, - groupValue: _iosMode, - title: const Text('Sonora (experimental)'), - subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'), - onSelected: (v) => _iosMode = v, + const VoiceSubHeader('Processing backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: iosProcessingSegments, + selected: {_audioProcessing.iosMode}, + onSelectionChanged: (s) => + setState(() => _audioProcessing.iosMode = s.first), ), const SizedBox(height: 4), ], // Android HW/SW selector if (_isAndroid) ...[ - _subHeader(theme, 'Processing backend'), - _radioTile( - value: true, - groupValue: _preferHardware, - title: const Text('Platform (auto)'), - subtitle: _tileSubtitle( - 'Try hardware JNI effects · software fallback', - ), - onSelected: (v) => setState(() => _preferHardware = v), - ), - _radioTile( - value: false, - groupValue: _preferHardware, - title: const Text('WebRTC APM'), - subtitle: _tileSubtitle( - 'Software AEC3 · NS · AGC2', - ), - onSelected: (v) => setState(() => _preferHardware = v), + const VoiceSubHeader('Processing backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: androidProcessingSegments, + selected: {_audioProcessing.preferHardware}, + onSelectionChanged: (s) => + setState(() => _audioProcessing.preferHardware = s.first), ), const SizedBox(height: 4), ], // DSP toggles - _subHeader(theme, 'DSP stages'), - _switchTile( - title: 'Noise suppression (NS)', + const VoiceSubHeader('DSP stages'), + AudioProcessingToggleRow( + label: 'Noise suppression (NS)', subtitle: 'Wiener filter · stationary noise', - value: _nsEnabled, - onSelected: (v) => _nsEnabled = v, + value: _audioProcessing.nsEnabled, + onChanged: (v) => + setState(() => _audioProcessing.nsEnabled = v), ), - _switchTile( - title: 'Echo cancellation (AEC3)', + AudioProcessingToggleRow( + label: 'Echo cancellation (AEC3)', subtitle: _isAndroid ? 'WebRTC AEC3 · adaptive filter' : platformVpio - ? 'Managed by platform VPIO' - : 'Adaptive NLMS · 80 ms tail', - value: _aecEnabled, + ? 'Managed by platform VPIO' + : 'Adaptive NLMS · 80 ms tail', + value: _audioProcessing.aecEnabled, // AEC is always on in VPIO mode — disable the toggle. - onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null, + onChanged: (_isAndroid || !platformVpio) + ? (v) => setState(() => _audioProcessing.aecEnabled = v) + : null, ), - _switchTile( - title: 'Auto gain control (AGC2)', + AudioProcessingToggleRow( + label: 'Auto gain control (AGC2)', subtitle: 'RNN VAD-gated · −18 dBFS target', - value: _agcEnabled, - onSelected: (v) => _agcEnabled = v, + value: _audioProcessing.agcEnabled, + onChanged: (v) => + setState(() => _audioProcessing.agcEnabled = v), ), - _switchTile( - title: 'High-pass filter (HPF)', + AudioProcessingToggleRow( + label: 'High-pass filter (HPF)', subtitle: '80 Hz Butterworth · DC removal', - value: _hpfEnabled, - onSelected: (v) => _hpfEnabled = v, + value: _audioProcessing.hpfEnabled, + onChanged: (v) => + setState(() => _audioProcessing.hpfEnabled = v), ), - _switchTile( - title: 'Peak limiter', + AudioProcessingToggleRow( + label: 'Peak limiter', subtitle: '−1 dBFS soft-knee · 2 ms look-ahead', - value: _limiterEnabled, - onSelected: (v) => _limiterEnabled = v, + value: _audioProcessing.limiterEnabled, + onChanged: (v) => + setState(() => _audioProcessing.limiterEnabled = v), ), + if (isTalkPowerBlocked( + talkPower: widget.talkPower, + neededTalkPower: widget.neededTalkPower, + talkPowerGranted: widget.talkPowerGranted, + )) ...[ + const SizedBox(height: 8), + TalkPowerWarning( + talkPower: widget.talkPower, + neededTalkPower: widget.neededTalkPower, + talkPowerGranted: widget.talkPowerGranted, + ), + ], + // ── VAD ──────────────────────────────────────────────── const Divider(height: 24), - _sectionHeader(theme, 'Voice activity detection (VAD)'), + const VoiceSectionHeader('Voice activity detection (VAD)'), - _subHeader(theme, 'Backend'), - _radioTile( - value: rust.BridgeVadBackend.webrtcVad, - groupValue: _vadBackend, - title: const Text('WebRTC VAD'), - subtitle: _tileSubtitle( - 'Fast · energy-based · always available', - ), - onSelected: (v) => _vadBackend = v, - ), - _radioTile( - value: rust.BridgeVadBackend.sileroOnnx, - groupValue: _vadBackend, - title: const Text('Silero v6 (ONNX)'), - subtitle: _tileSubtitle( - 'Neural · 32 ms frames · requires model file', - ), - onSelected: (v) => _vadBackend = v, - ), - _radioTile( - value: rust.BridgeVadBackend.tenVad, - groupValue: _vadBackend, - title: const Text('TEN VAD'), - subtitle: _tileSubtitle( - 'Neural · 16 kHz · native runtime optional', - ), - onSelected: (v) => _vadBackend = v, + const VoiceSubHeader('Backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: vadBackendSegments, + selected: {_audioProcessing.vadBackend}, + onSelectionChanged: (s) => + setState(() => _audioProcessing.vadBackend = s.first), ), const SizedBox(height: 8), + // ── PTT capability badge ──────────────────────────────── + if (_mode == rust.BridgeTransmitMode.ptt && + widget.pttLevel.isNotEmpty) ...[ + const Divider(height: 24), + const VoiceSectionHeader('PTT capability'), + PttCapabilityBadge( + level: widget.pttLevel, + backendId: widget.pttBackendId, + boundInputClass: widget.pttBoundInputClass, + ), + ], + + // ── Audio output route picker (mobile only) ───────────── + if (_isAndroid || _isIos) ...[ + const Divider(height: 24), + const VoiceSectionHeader('Audio output'), + const AudioOutputTile(), + ], + + // ── Audio devices (desktop only, SRS-026) ────────────── + if (!_isAndroid && !_isIos) ...[ + const Divider(height: 24), + const VoiceSectionHeader('Audio devices'), + const AudioDeviceListTile( + label: 'Input', + kind: AudioDeviceKind.input, + ), + const AudioDeviceListTile( + label: 'Output', + kind: AudioDeviceKind.output, + ), + const SizedBox(height: 8), + ], + // ── Debug ────────────────────────────────────────────── const Divider(height: 24), - _sectionHeader(theme, 'Debug'), - _switchTile( - title: 'WAV dump', + const VoiceSectionHeader('Debug'), + AudioProcessingToggleRow( + label: 'WAV dump', subtitle: 'Record raw/processed mic to temp dir', - value: _debugWavDump, - onSelected: (v) => _debugWavDump = v, + value: _audioProcessing.debugWavDump, + onChanged: (v) => + setState(() => _audioProcessing.debugWavDump = v), ), const SizedBox(height: 8), ], @@ -409,54 +342,4 @@ class _VoiceSettingsDialogState extends State { ], ); } - - Widget _radioTile({ - required T value, - required T groupValue, - required Widget title, - Widget? subtitle, - required ValueChanged onSelected, - }) => RadioListTile( - dense: true, - value: value, - groupValue: groupValue, - title: title, - subtitle: subtitle, - onChanged: (v) { - if (v == null) return; - setState(() => onSelected(v)); - }, - ); - - Widget _switchTile({ - required String title, - required String subtitle, - required bool value, - required ValueChanged? onSelected, - }) => SwitchListTile( - dense: true, - title: Text(title), - subtitle: _tileSubtitle(subtitle), - value: value, - onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)), - ); - - Widget _tileSubtitle(String text) => - Text(text, style: const TextStyle(fontSize: 11)); - - Widget _sectionHeader(ThemeData theme, String text) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(text, style: theme.textTheme.titleSmall), - ); - - Widget _subHeader(ThemeData theme, String text) => Padding( - padding: const EdgeInsets.only(top: 8, bottom: 2), - child: Text( - text, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.primary, - letterSpacing: 0.5, - ), - ), - ); } diff --git a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart new file mode 100644 index 0000000..e4df35c --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; + +import '../src/rust/api.dart' as rust; + +/// Shared compact style for voice settings segmented buttons. +ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) { + return SegmentedButton.styleFrom( + textStyle: theme.textTheme.labelSmall, + visualDensity: VisualDensity.compact, + ); +} + +/// Transmit mode selector segments. +const transmitModeSegments = [ + ButtonSegment( + value: rust.BridgeTransmitMode.ptt, + label: Text('PTT'), + icon: Icon(Icons.radio_button_checked, size: 14), + ), + ButtonSegment( + value: rust.BridgeTransmitMode.continuous, + label: Text('Always'), + icon: Icon(Icons.podcasts, size: 14), + ), + ButtonSegment( + value: rust.BridgeTransmitMode.voiceActivity, + label: Text('VAD'), + icon: Icon(Icons.graphic_eq, size: 14), + ), +]; + +/// Android hardware/WebRTC selector segments. +const androidProcessingSegments = [ + ButtonSegment( + value: true, + label: Text('Hardware'), + icon: Icon(Icons.phone_android, size: 14), + ), + ButtonSegment( + value: false, + label: Text('WebRTC'), + icon: Icon(Icons.science_outlined, size: 14), + ), +]; + +/// iOS VPIO/Sonora selector segments. +const iosProcessingSegments = [ + ButtonSegment( + value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + label: Text('VPIO'), + icon: Icon(Icons.phone_iphone, size: 14), + ), + ButtonSegment( + value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental, + label: Text('Sonora'), + icon: Icon(Icons.science_outlined, size: 14), + ), +]; + +/// Voice activity detector selector segments. +const vadBackendSegments = [ + ButtonSegment( + value: rust.BridgeVadBackend.webrtcVad, + label: Text('WebRTC'), + icon: Icon(Icons.speed, size: 14), + ), + ButtonSegment( + value: rust.BridgeVadBackend.sileroOnnx, + label: Text('Silero'), + icon: Icon(Icons.psychology, size: 14), + ), + ButtonSegment( + value: rust.BridgeVadBackend.tenVad, + label: Text('TEN'), + icon: Icon(Icons.graphic_eq, size: 14), + ), +]; + +/// Section subheader used by both voice settings surfaces. +class VoiceSubHeader extends StatelessWidget { + /// Construct a voice settings subheader. + const VoiceSubHeader(this.text, {super.key}); + + /// Header text. + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 2), + child: Text( + text, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + letterSpacing: 0.5, + ), + ), + ); + } +} + +/// Section header used by the full voice settings dialog. +class VoiceSectionHeader extends StatelessWidget { + /// Construct a voice settings section header. + const VoiceSectionHeader(this.text, {super.key}); + + /// Header text. + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(text, style: theme.textTheme.titleSmall), + ); + } +} + +/// Shared audio-processing switch row. +class AudioProcessingToggleRow extends StatelessWidget { + /// Construct an audio-processing switch row. + const AudioProcessingToggleRow({ + super.key, + required this.label, + required this.subtitle, + required this.value, + required this.onChanged, + this.dense = false, + }); + + /// Primary row label. + final String label; + + /// Secondary row detail. + final String subtitle; + + /// Current switch value. + final bool value; + + /// Called when the switch changes. Null disables the row. + final ValueChanged? onChanged; + + /// Use the compact inline row layout used by the mobile sheet. + final bool dense; + + @override + Widget build(BuildContext context) { + if (dense) return _buildDense(context); + return SwitchListTile( + dense: true, + title: Text(label), + subtitle: Text(subtitle, style: const TextStyle(fontSize: 11)), + value: value, + onChanged: onChanged, + ); + } + + Widget _buildDense(BuildContext context) { + final theme = Theme.of(context); + final disabled = onChanged == null; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: disabled + ? theme.colorScheme.onSurfaceVariant.withAlpha(120) + : null, + ), + ), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withAlpha( + disabled ? 80 : 160, + ), + ), + ), + ], + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ); + } +} diff --git a/apps/chanora_flutter/lib/widgets/voice_status_summary.dart b/apps/chanora_flutter/lib/widgets/voice_status_summary.dart new file mode 100644 index 0000000..f240621 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_status_summary.dart @@ -0,0 +1,89 @@ +import '../l10n/generated/app_localizations.dart'; +import '../src/rust/api.dart' as rust; +import 'talk_power_warning.dart'; + +class VoiceStatusSummary { + const VoiceStatusSummary({ + required this.modeLabel, + required this.line1, + required this.line2, + required this.statusText, + required this.micOn, + required this.talkPowerBlocked, + required this.muted, + }); + + final String modeLabel; + final String line1; + final String line2; + final String statusText; + final bool micOn; + final bool talkPowerBlocked; + final bool muted; +} + +String voiceModeLabel(AppL10n l10n, rust.BridgeTransmitMode transmitMode) { + switch (transmitMode) { + case rust.BridgeTransmitMode.ptt: + return l10n.voiceModePtt; + case rust.BridgeTransmitMode.continuous: + return l10n.voiceModeContinuous; + case rust.BridgeTransmitMode.voiceActivity: + return l10n.voiceModeVoiceActivity; + } +} + +VoiceStatusSummary voiceStatusSummary({ + required AppL10n l10n, + required rust.BridgeTransmitMode transmitMode, + required int releaseTailMs, + required String pttBoundKeyLabel, + required bool isTouchOnly, + required bool inputMuted, + required bool outputMuted, + required bool pttActive, + int? talkPower, + int? neededTalkPower, + bool? talkPowerGranted, +}) { + final modeLabel = voiceModeLabel(l10n, transmitMode); + final talkPowerBlocked = isTalkPowerBlocked( + talkPower: talkPower, + neededTalkPower: neededTalkPower, + talkPowerGranted: talkPowerGranted, + ); + final micOn = inputMuted || outputMuted || talkPowerBlocked + ? false + : switch (transmitMode) { + rust.BridgeTransmitMode.continuous => true, + _ => pttActive, + }; + + final line1 = transmitMode == rust.BridgeTransmitMode.ptt + ? isTouchOnly + ? '$modeLabel \u00b7 ${l10n.voicePttHoldHint}' + : '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}' + : modeLabel; + final tailText = transmitMode == rust.BridgeTransmitMode.ptt + ? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}' + : null; + final statusText = talkPowerBlocked + ? 'Insufficient permission' + : inputMuted + ? '${l10n.voiceMicOff} (muted)' + : outputMuted + ? 'Speaker muted' + : micOn + ? l10n.voiceMicOn + : l10n.voiceMicOff; + + return VoiceStatusSummary( + modeLabel: modeLabel, + line1: line1, + line2: tailText == null ? statusText : '$tailText \u00b7 $statusText', + statusText: statusText, + micOn: micOn, + talkPowerBlocked: talkPowerBlocked, + muted: inputMuted || outputMuted, + ); +} diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock index 5f98783..957683c 100644 --- a/apps/chanora_flutter/pubspec.lock +++ b/apps/chanora_flutter/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -177,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" dart_style: dependency: transitive description: @@ -637,6 +637,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + url: "https://pub.dev" + source: hosted + version: "13.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + url: "https://pub.dev" + source: hosted + version: "7.1.0" shared_preferences: dependency: "direct main" description: @@ -850,6 +866,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 3457543..554940c 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -34,9 +34,6 @@ dependencies: sdk: flutter intl: any - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 flutter_rust_bridge: 2.12.0 freezed_annotation: ^3.1.0 connectivity_plus: ^7.1.1 @@ -80,6 +77,7 @@ dependencies: flutter_foreground_task: ^9.2.2 url_launcher: ^6.3.2 shared_preferences: ^2.5.5 + share_plus: ^13.1.0 dev_dependencies: flutter_test: diff --git a/apps/chanora_flutter/test/services/android_audio_output_devices_test.dart b/apps/chanora_flutter/test/services/android_audio_output_devices_test.dart new file mode 100644 index 0000000..c1656f6 --- /dev/null +++ b/apps/chanora_flutter/test/services/android_audio_output_devices_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/android_audio_output_devices.dart'; + +void main() { + test('parses Android audio output devices and skips non-maps', () { + final devices = parseAndroidAudioOutputDevices([ + { + 'id': 12, + 'name': 'Speaker', + 'type': 'speaker', + 'isSelected': true, + 'isAvailableForCommunication': true, + }, + 'bad', + {'id': null, 'name': null}, + ]); + + expect(devices, hasLength(2)); + expect(devices.first.id, '12'); + expect(devices.first.name, 'Speaker'); + expect(devices.first.type, 'speaker'); + expect(devices.first.isSelected, isTrue); + expect(devices.first.isAvailableForCommunication, isTrue); + expect(devices.last.id, isEmpty); + expect(devices.last.name, isEmpty); + expect(devices.last.type, 'unknown'); + }); + + test('finds selected Android audio output device', () { + final selected = selectedAndroidAudioOutputDevice([ + const AndroidAudioOutputDevice( + id: '1', + name: 'Speaker', + type: 'speaker', + isSelected: false, + isAvailableForCommunication: true, + ), + const AndroidAudioOutputDevice( + id: '2', + name: 'Headset', + type: 'wiredHeadset', + isSelected: true, + isAvailableForCommunication: true, + ), + ]); + + expect(selected?.id, '2'); + expect(selectedAndroidAudioOutputDevice(const []), isNull); + }); +} diff --git a/apps/chanora_flutter/test/services/app_bootstrap_test.dart b/apps/chanora_flutter/test/services/app_bootstrap_test.dart new file mode 100644 index 0000000..2b5a58b --- /dev/null +++ b/apps/chanora_flutter/test/services/app_bootstrap_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/app_bootstrap.dart'; + +void main() { + test('app version appends platform build number', () { + expect( + appVersionFromBuildNumber( + semverBaseline: 'v1.2.3-rc.4', + buildNumber: '56', + ), + 'v1.2.3-rc.4+56', + ); + }); + + test('app version keeps baseline when build number is empty', () { + expect( + appVersionFromBuildNumber(semverBaseline: 'v1.2.3-rc.4', buildNumber: ''), + 'v1.2.3-rc.4', + ); + }); +} diff --git a/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart b/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart new file mode 100644 index 0000000..d53b4f6 --- /dev/null +++ b/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/audio_lifecycle_service.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; + +void main() { + test('parseBridgeAudioRoute maps platform route names', () { + expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece); + expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker); + expect( + parseBridgeAudioRoute('WiredHeadset'), + rust.BridgeAudioRoute.wiredHeadset, + ); + expect( + parseBridgeAudioRoute('BluetoothHfp'), + rust.BridgeAudioRoute.bluetoothHfp, + ); + expect( + parseBridgeAudioRoute('BluetoothA2dp'), + rust.BridgeAudioRoute.bluetoothA2Dp, + ); + expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown); + expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown); + }); +} diff --git a/apps/chanora_flutter/test/services/channel_join_error_mapper_test.dart b/apps/chanora_flutter/test/services/channel_join_error_mapper_test.dart new file mode 100644 index 0000000..974d32a --- /dev/null +++ b/apps/chanora_flutter/test/services/channel_join_error_mapper_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/services/channel_join_error_mapper.dart'; +import 'package:chanora_flutter/src/rust/lib.dart' as rust_err; + +void main() { + Future loadEnglishL10n(WidgetTester tester) async { + late AppL10n l10n; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Builder( + builder: (context) { + l10n = AppL10n.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + return l10n; + } + + testWidgets('maps known TS3 server rejection codes', (tester) async { + final l10n = await loadEnglishL10n(tester); + + expect( + channelJoinErrorMessage( + l10n, + const rust_err.BridgeError.serverRejected( + code: 0x030d, + message: 'invalid password', + ), + ), + l10n.channelJoinFailedPassword, + ); + expect( + channelJoinErrorMessage( + l10n, + const rust_err.BridgeError.serverRejected( + code: 0x0a08, + message: 'insufficient permissions', + ), + ), + l10n.channelJoinFailedPermission, + ); + }); + + testWidgets('falls back for generic and unknown errors', (tester) async { + final l10n = await loadEnglishL10n(tester); + + expect( + channelJoinErrorMessage( + l10n, + const rust_err.BridgeError.serverRejected( + code: 0xffff, + message: 'custom server error', + ), + ), + l10n.channelJoinFailedGeneric('custom server error'), + ); + expect( + channelJoinErrorMessage(l10n, 'plain error'), + l10n.channelJoinFailedGeneric('plain error'), + ); + }); +} diff --git a/apps/chanora_flutter/test/services/channel_spacer_test.dart b/apps/chanora_flutter/test/services/channel_spacer_test.dart new file mode 100644 index 0000000..3d11ae2 --- /dev/null +++ b/apps/chanora_flutter/test/services/channel_spacer_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/channel_spacer.dart'; + +void main() { + test('classifies only valid bracketed spacer tags as spacer channels', () { + expect(isSpacerChannelName('[Spacer0]Lobby'), isTrue); + expect(isSpacerChannelName('[cSpacer]Lobby'), isTrue); + expect(isSpacerChannelName('[*spacer0]#=='), isTrue); + expect(isSpacerChannelName('Lobby spacer room'), isFalse); + expect(isSpacerChannelName('prefix [Spacer0]Lobby'), isFalse); + expect(isSpacerChannelName('[xSpacer0]Lobby'), isFalse); + }); + + test('parses valid spacer names without requiring numeric suffixes', () { + final parsed = parseSpacerChannelName('[cSpAcErabc-01] Lobby '); + + expect(parsed.isSpacer, isTrue); + expect(parsed.isValid, isTrue); + expect(parsed.alignment, SpacerAlignment.center); + expect(parsed.isRepeating, isFalse); + expect(parsed.uniqueSuffix, 'abc-01'); + expect(parsed.text, ' Lobby '); + expect(parsed.specialType, isNull); + expect(parsed.isBlankSpacer, isFalse); + expect(parsed.reason, isNull); + }); + + test('parses all special separator line values', () { + expect( + parseSpacerChannelName('[Spacer0]___').specialType, + SpacerSpecialType.solidLine, + ); + expect( + parseSpacerChannelName('[Spacer0]---').specialType, + SpacerSpecialType.dashLine, + ); + expect( + parseSpacerChannelName('[Spacer0]...').specialType, + SpacerSpecialType.dotLine, + ); + expect( + parseSpacerChannelName('[Spacer0]-.-').specialType, + SpacerSpecialType.dashDotLine, + ); + expect( + parseSpacerChannelName('[Spacer0]-..').specialType, + SpacerSpecialType.dashDotDotLine, + ); + }); + + test('parses repeating spacer text exactly', () { + final parsed = parseSpacerChannelName('[*spacer0]#=='); + + expect(parsed.isSpacer, isTrue); + expect(parsed.isValid, isTrue); + expect(parsed.isRepeating, isTrue); + expect(parsed.uniqueSuffix, '0'); + expect(parsed.text, '#=='); + expect(channelSpacerLabel('[*spacer0]#=='), startsWith('#==#==')); + }); + + test('parses known blank-looking right spacer', () { + final parsed = parseSpacerChannelName('[rSpacer0].'); + + expect(parsed.isSpacer, isTrue); + expect(parsed.alignment, SpacerAlignment.right); + expect(parsed.text, '.'); + expect(parsed.isBlankSpacer, isTrue); + expect(channelSpacerLabel('[rSpacer0].'), isEmpty); + }); + + test('reports malformed spacer-like names', () { + final missingBracket = parseSpacerChannelName('[cSpacer0'); + expect(missingBracket.isSpacer, isFalse); + expect(missingBracket.isValid, isFalse); + expect(missingBracket.reason, 'missing closing bracket'); + + final invalidFlag = parseSpacerChannelName('[xSpacer0]Lobby'); + expect(invalidFlag.isSpacer, isFalse); + expect(invalidFlag.isValid, isFalse); + expect(invalidFlag.reason, 'invalid spacer tag'); + + final duplicateAlignment = parseSpacerChannelName('[lcSpacer0]Lobby'); + expect(duplicateAlignment.isSpacer, isFalse); + expect(duplicateAlignment.isValid, isFalse); + expect(duplicateAlignment.reason, 'multiple alignment flags'); + }); + + test('reports ordinary channel names as non-spacers', () { + final parsed = parseSpacerChannelName('A channel with spacer in its name'); + + expect(parsed.isSpacer, isFalse); + expect(parsed.isValid, isFalse); + expect(parsed.reason, 'not a spacer channel name'); + expect( + channelSpacerLabel('A channel with spacer in its name'), + 'A channel with spacer in its name', + ); + }); + + test('formats spacer names deterministically', () { + final formatted = formatSpacerChannelName( + const SpacerChannelNameFormatOptions( + alignment: SpacerAlignment.right, + isRepeating: true, + uniqueSuffix: 'abc', + text: '#==', + ), + ); + + expect(formatted, '[*rSpacerabc]#=='); + + final parsed = parseSpacerChannelName(formatted); + expect(parsed.isSpacer, isTrue); + expect(parsed.alignment, SpacerAlignment.right); + expect(parsed.isRepeating, isTrue); + expect(parsed.uniqueSuffix, 'abc'); + expect(parsed.text, '#=='); + }); + + test('formats existing display labels through the utility', () { + expect(channelSpacerLabel('[cSpacer]Lobby'), 'Lobby'); + expect(channelSpacerLabel('[Spacer0]___'), '────────'); + expect(channelSpacerLabel('Lobby'), 'Lobby'); + }); +} diff --git a/apps/chanora_flutter/test/services/connection_phase_state_test.dart b/apps/chanora_flutter/test/services/connection_phase_state_test.dart new file mode 100644 index 0000000..14cb2cf --- /dev/null +++ b/apps/chanora_flutter/test/services/connection_phase_state_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart'; +import 'package:chanora_flutter/services/connection_phase_state.dart'; + +void main() { + final l10n = AppL10nEn(); + + test('connection phases expose shared predicates', () { + expect(ConnectionPhase.idle.isServerReachable, isFalse); + expect(ConnectionPhase.connecting.isServerReachable, isFalse); + expect(ConnectionPhase.synchronizing.isServerReachable, isTrue); + expect(ConnectionPhase.connected.isServerReachable, isTrue); + expect(ConnectionPhase.reconnecting.isServerReachable, isTrue); + expect(ConnectionPhase.disconnected.isServerReachable, isFalse); + + expect(ConnectionPhase.synchronizing.canOpenChat, isTrue); + expect(ConnectionPhase.connected.canOpenChat, isTrue); + expect(ConnectionPhase.reconnecting.canOpenChat, isFalse); + expect(ConnectionPhase.connected.canDisconnect, isTrue); + }); + + test('connection status text maps all phases', () { + expect( + connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n), + l10n.statusIdle, + ); + expect( + connectionStatusText(phase: ConnectionPhase.connecting, l10n: l10n), + l10n.statusConnecting, + ); + expect( + connectionStatusText(phase: ConnectionPhase.synchronizing, l10n: l10n), + 'Synchronizing...', + ); + expect( + connectionStatusText( + phase: ConnectionPhase.connected, + l10n: l10n, + serverName: 'Server', + ), + 'Connected to Server', + ); + expect( + connectionStatusText( + phase: ConnectionPhase.reconnecting, + l10n: l10n, + reconnectAttempt: 2, + reconnectDelay: 5, + ), + l10n.statusReconnecting(2, 5), + ); + expect( + connectionStatusText( + phase: ConnectionPhase.reconnecting, + l10n: l10n, + lostReason: 'network', + ), + 'Connection lost: network', + ); + expect( + connectionStatusText(phase: ConnectionPhase.disconnected, l10n: l10n), + l10n.statusIdle, + ); + }); + + test('connection phases map to token icons', () { + final scheme = ColorScheme.fromSeed(seedColor: Colors.indigo); + + expect(ConnectionPhase.idle.tokens(scheme).icon, Icons.cloud_off); + expect(ConnectionPhase.connecting.tokens(scheme).icon, Icons.sync); + expect( + ConnectionPhase.synchronizing.tokens(scheme).icon, + Icons.hourglass_top, + ); + expect(ConnectionPhase.connected.tokens(scheme).icon, Icons.cloud_done); + expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt); + expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off); + }); +} diff --git a/apps/chanora_flutter/test/services/snapshot_state_mapper_test.dart b/apps/chanora_flutter/test/services/snapshot_state_mapper_test.dart new file mode 100644 index 0000000..af67869 --- /dev/null +++ b/apps/chanora_flutter/test/services/snapshot_state_mapper_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/snapshot_state_mapper.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; + +void main() { + rust.BridgeChannel channel({int neededTalkPower = 0}) { + return rust.BridgeChannel( + id: BigInt.one, + parent: BigInt.zero, + name: 'Lobby', + order: 0, + hasPassword: false, + neededTalkPower: neededTalkPower, + ); + } + + rust.BridgeClient client({ + BigInt? id, + BigInt? channelId, + int talkPower = 0, + bool talkPowerGranted = false, + bool inputMuted = false, + bool outputMuted = false, + }) { + return rust.BridgeClient( + id: id ?? BigInt.from(7), + channel: channelId ?? BigInt.one, + name: 'Me', + inputMuted: inputMuted, + outputMuted: outputMuted, + isSpeaking: false, + isServerQuery: false, + talkPower: talkPower, + talkPowerGranted: talkPowerGranted, + ); + } + + rust.BridgeSnapshot snapshot({ + required List channels, + required List clients, + BigInt? ownClientId, + }) { + return rust.BridgeSnapshot( + serverName: 'Server', + welcomeMessage: '', + platform: '', + version: '', + channels: channels, + clients: clients, + ownClientId: ownClientId ?? BigInt.from(7), + ); + } + + test('extracts own channel and mute state', () { + final state = ownClientSnapshotState( + snapshot( + channels: [channel()], + clients: [client(inputMuted: true, outputMuted: true)], + ), + ); + + expect(state?.channelId, BigInt.one); + expect(state?.inputMuted, isTrue); + expect(state?.outputMuted, isTrue); + expect(state?.talkPowerOk, isTrue); + expect(state?.talkPower, 0); + expect(state?.talkPowerGranted, isFalse); + expect(state?.neededTalkPower, 0); + }); + + test('detects insufficient talk power', () { + final state = ownClientSnapshotState( + snapshot( + channels: [channel(neededTalkPower: 20)], + clients: [client(talkPower: 10)], + ), + ); + + expect(state?.talkPowerOk, isFalse); + expect(state?.talkPower, 10); + expect(state?.neededTalkPower, 20); + }); + + test('accepts granted or sufficient talk power', () { + final sufficient = ownClientSnapshotState( + snapshot( + channels: [channel(neededTalkPower: 20)], + clients: [client(talkPower: 20)], + ), + ); + final granted = ownClientSnapshotState( + snapshot( + channels: [channel(neededTalkPower: 20)], + clients: [client(talkPowerGranted: true)], + ), + ); + + expect(sufficient?.talkPowerOk, isTrue); + expect(granted?.talkPowerOk, isTrue); + }); + + test('returns null when own client is absent', () { + final state = ownClientSnapshotState( + snapshot( + channels: [channel()], + clients: [client(id: BigInt.from(9))], + ), + ); + + expect(state, isNull); + }); + + test('resolves channel name by id', () { + final snap = snapshot( + channels: [ + channel(), + rust.BridgeChannel( + id: BigInt.from(2), + parent: BigInt.zero, + name: 'Raid Room', + order: 1, + hasPassword: false, + neededTalkPower: 0, + ), + ], + clients: [client()], + ); + + expect(snapshotChannelName(snap, BigInt.from(2)), 'Raid Room'); + }); + + test('returns empty channel name when snapshot or channel is absent', () { + final snap = snapshot(channels: [channel()], clients: [client()]); + + expect(snapshotChannelName(null, BigInt.one), isEmpty); + expect(snapshotChannelName(snap, null), isEmpty); + expect(snapshotChannelName(snap, BigInt.from(99)), isEmpty); + }); + + test('resolves needed talk power by channel id', () { + final snap = snapshot( + channels: [channel(neededTalkPower: 30)], + clients: [client()], + ); + + expect(snapshotNeededTalkPower(snap, BigInt.one), 30); + expect(snapshotNeededTalkPower(null, BigInt.one), isNull); + expect(snapshotNeededTalkPower(snap, null), isNull); + expect(snapshotNeededTalkPower(snap, BigInt.from(99)), isNull); + }); +} diff --git a/apps/chanora_flutter/test/services/ts3_server_link_test.dart b/apps/chanora_flutter/test/services/ts3_server_link_test.dart new file mode 100644 index 0000000..bbdc3b8 --- /dev/null +++ b/apps/chanora_flutter/test/services/ts3_server_link_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/ts3_server_link.dart'; + +void main() { + test('parses encoded add-bookmark TeamSpeak link', () { + final link = parseTs3ServerLink( + 'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/', + ); + + expect(link, isNotNull); + expect(link!.host, 'teamspeak.app'); + expect(link.hostWithPort, 'teamspeak.app'); + expect(link.addBookmark, 'Vigorous Pro'); + }); + + test('parses full TeamSpeak server link parameters', () { + final link = parseTs3ServerLink( + 'ts3server://ts3.hoster.com?port=9987&nickname=UserNickname' + '&password=serverPassword&channel=MyDefaultChannel&cid=123' + '&channelpassword=defaultChannelPassword&token=TokenKey' + '&addbookmark=MyBookMarkLabel', + ); + + expect(link, isNotNull); + expect(link!.host, 'ts3.hoster.com'); + expect(link.hostWithPort, 'ts3.hoster.com:9987'); + expect(link.port, 9987); + expect(link.nickname, 'UserNickname'); + expect(link.password, 'serverPassword'); + expect(link.channel, 'MyDefaultChannel'); + expect(link.cid, '123'); + expect(link.channelPassword, 'defaultChannelPassword'); + expect(link.token, 'TokenKey'); + expect(link.addBookmark, 'MyBookMarkLabel'); + }); + + test('keeps explicit host port without duplicating port query', () { + final link = parseTs3ServerLink('ts3server://ts3.hoster.com:9987'); + + expect(link, isNotNull); + expect(link!.hostWithPort, 'ts3.hoster.com:9987'); + }); +} diff --git a/apps/chanora_flutter/test/services/ui_preferences_service_test.dart b/apps/chanora_flutter/test/services/ui_preferences_service_test.dart index d3da1c8..6671dcf 100644 --- a/apps/chanora_flutter/test/services/ui_preferences_service_test.dart +++ b/apps/chanora_flutter/test/services/ui_preferences_service_test.dart @@ -16,7 +16,6 @@ void main() { expect(settings.host, isEmpty); expect(settings.nickname, isEmpty); - expect(settings.showPokeDialogs, isTrue); }); test('saves and loads host and nickname independently', () async { @@ -33,21 +32,6 @@ void main() { expect(settings.nickname, 'Chanora'); }); - test('saves poke dialog preference independently', () async { - await service.saveSettings(showPokeDialogs: false); - var settings = await service.loadSettings(); - - expect(settings.showPokeDialogs, isFalse); - expect(settings.host, isEmpty); - expect(settings.nickname, isEmpty); - - await service.saveSettings(host: 'example.com'); - settings = await service.loadSettings(); - - expect(settings.showPokeDialogs, isFalse); - expect(settings.host, 'example.com'); - }); - test('tracks permission explanation flag', () async { expect(await service.hasExplainedPermissions(), isFalse); diff --git a/apps/chanora_flutter/test/widget_test.dart b/apps/chanora_flutter/test/widget_test.dart index b3a3f9f..ccb3286 100644 --- a/apps/chanora_flutter/test/widget_test.dart +++ b/apps/chanora_flutter/test/widget_test.dart @@ -190,6 +190,34 @@ void main() { expect(states, [true, false]); }); + testWidgets('on-screen PTT can release from pan gestures', (tester) async { + final states = []; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: VoicePttButton( + active: false, + listenForPan: true, + onHeldChanged: states.add, + ), + ), + ), + ); + + final center = tester.getCenter(find.byType(VoicePttButton)); + final gesture = await tester.startGesture(center); + await tester.pump(); + await gesture.moveBy(const Offset(0, 24)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + expect(states, [true, false]); + }); + testWidgets('iOS permission service maps channel states and settings', ( tester, ) async { diff --git a/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart b/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart new file mode 100644 index 0000000..48793b6 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/audio_device_list_tile.dart'; + +void main() { + testWidgets( + 'audio device tile renders loading state before devices resolve', + (tester) async { + final pendingDevices = Completer(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AudioDeviceListTile( + label: 'Input', + kind: AudioDeviceKind.input, + loadDevices: () => pendingDevices.future, + ), + ), + ), + ); + + expect(find.text('Input'), findsOneWidget); + expect(find.text('Loading...'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }, + ); +} diff --git a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart new file mode 100644 index 0000000..2aaeca5 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/audio_processing_config_state.dart'; + +void main() { + const baseConfig = rust.BridgeAudioProcessingConfig( + route: rust.BridgeAudioRoute.unknown, + iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, + vadBackend: rust.BridgeVadBackend.disabled, + aec: rust.BridgeEffectOwner.platform, + ns: rust.BridgeEffectOwner.off, + agc: rust.BridgeEffectOwner.webrtcApm, + hpfEnabled: true, + limiterEnabled: false, + vadHangoverMs: 500, + vadPreRollMs: 160, + vadMinTxMs: 200, + debugWavDumpEnabled: true, + ); + + test('normalizes hidden disabled VAD backend for UI state', () { + final state = AudioProcessingConfigState.fromConfig(baseConfig); + + expect(state.vadBackend, rust.BridgeVadBackend.webrtcVad); + expect(state.preferHardware, isTrue); + expect(state.nsEnabled, isFalse); + expect(state.aecEnabled, isTrue); + expect(state.agcEnabled, isTrue); + }); + + test('default config uses platform processing and Silero VAD', () { + expect( + defaultAudioProcessingConfig.processingBackend, + rust.BridgeAudioBackend.platformVoiceProcessing, + ); + expect( + defaultAudioProcessingConfig.vadBackend, + rust.BridgeVadBackend.sileroOnnx, + ); + expect(defaultAudioProcessingConfig.aec, rust.BridgeEffectOwner.platform); + expect(defaultAudioProcessingConfig.ns, rust.BridgeEffectOwner.platform); + expect(defaultAudioProcessingConfig.agc, rust.BridgeEffectOwner.platform); + }); + + test('builds Android hardware config consistently', () { + final state = AudioProcessingConfigState.fromConfig(baseConfig) + ..preferHardware = true + ..nsEnabled = true + ..aecEnabled = false + ..agcEnabled = true; + + final config = state.buildConfig(base: baseConfig, isAndroid: true); + + expect( + config.processingBackend, + rust.BridgeAudioBackend.platformVoiceProcessing, + ); + expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad); + expect(config.aec, rust.BridgeEffectOwner.off); + expect(config.ns, rust.BridgeEffectOwner.platform); + expect(config.agc, rust.BridgeEffectOwner.platform); + expect(config.debugWavDumpEnabled, isTrue); + }); + + test('builds Sonora config with WebRTC-owned enabled effects', () { + final state = AudioProcessingConfigState.fromConfig(baseConfig) + ..iosMode = rust.BridgeIosVoiceProcessingMode.sonoraExperimental + ..nsEnabled = true + ..aecEnabled = false + ..agcEnabled = true; + + final config = state.buildConfig(base: baseConfig, isAndroid: false); + + expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm); + expect(config.aec, rust.BridgeEffectOwner.off); + expect(config.ns, rust.BridgeEffectOwner.webrtcApm); + expect(config.agc, rust.BridgeEffectOwner.webrtcApm); + }); +} diff --git a/apps/chanora_flutter/test/widgets/bbcode_text_test.dart b/apps/chanora_flutter/test/widgets/bbcode_text_test.dart new file mode 100644 index 0000000..653a46c --- /dev/null +++ b/apps/chanora_flutter/test/widgets/bbcode_text_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chanora_flutter/services/link_trust_service.dart'; +import 'package:chanora_flutter/services/ts3_server_link.dart'; +import 'package:chanora_flutter/widgets/bbcode_text.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('renders links without underline decoration', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: BbCodeText( + 'https://example.com', + linkTrust: LinkTrustService.instance, + ), + ), + ); + + final linkText = tester.widget(find.text('https://example.com')); + expect(linkText.style?.color, Colors.blue); + expect(linkText.style?.decoration, isNull); + }); + + testWidgets('handles TeamSpeak server links without browser launch', ( + tester, + ) async { + Ts3ServerLink? tapped; + + await tester.pumpWidget( + MaterialApp( + home: BbCodeText( + 'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/', + linkTrust: LinkTrustService.instance, + onTs3ServerLink: (link) async => tapped = link, + ), + ), + ); + + await tester.tap( + find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'), + ); + await tester.pump(); + + expect(tapped, isNotNull); + expect(tapped!.host, 'teamspeak.app'); + expect(tapped!.addBookmark, 'Vigorous Pro'); + }); +} diff --git a/apps/chanora_flutter/test/widgets/chat_views_test.dart b/apps/chanora_flutter/test/widgets/chat_views_test.dart index 9c5b98a..88224cb 100644 --- a/apps/chanora_flutter/test/widgets/chat_views_test.dart +++ b/apps/chanora_flutter/test/widgets/chat_views_test.dart @@ -331,25 +331,98 @@ void main() { ); }); - testWidgets('chat hub does not expose pokes as a chat tab', (tester) async { + testWidgets('chat sidebar keeps server and channel fixed above privates', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: ChatPage( messages: [ entry(rust.BridgeMessageTarget.poke(BigInt.from(2))), + ChatEntry( + senderId: BigInt.from(2), + senderName: 'Alpha', + message: 'Private', + target: rust.BridgeMessageTarget.client(BigInt.from(2)), + ), entry(const rust.BridgeMessageTarget.server()), ], - snapshot: snapshot(channels: const [], clients: const []), + snapshot: snapshot( + channels: const [], + clients: [ + client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero), + ], + ), ), ), ); - expect(find.text('Direct Messages'), findsOneWidget); - expect(find.text('Server Activity'), findsOneWidget); + expect(find.text('Server'), findsWidgets); + expect(find.text('Channel'), findsOneWidget); + expect(find.text('Alpha'), findsWidgets); expect(find.text('Pokes'), findsNothing); expect(find.text('No pokes'), findsNothing); + + final serverTop = tester.getTopLeft(find.text('Server').first).dy; + final channelTop = tester.getTopLeft(find.text('Channel')).dy; + final privateTop = tester.getTopLeft(find.text('Alpha').first).dy; + + expect(serverTop, lessThan(channelTop)); + expect(channelTop, lessThan(privateTop)); }); + testWidgets( + 'plus opens user picker and close removes selected private chat', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ChatPage( + messages: [ + ChatEntry( + senderId: BigInt.from(2), + senderName: 'Alpha', + message: 'Private', + target: rust.BridgeMessageTarget.client(BigInt.from(2)), + ), + ], + snapshot: snapshot( + channels: const [], + clients: [ + client( + id: BigInt.from(2), + name: 'Alpha', + channelId: BigInt.zero, + ), + client( + id: BigInt.from(3), + name: 'Bravo', + channelId: BigInt.zero, + ), + ], + ), + initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)), + initialClientName: 'Alpha', + ), + ), + ); + + expect(find.text('Alpha'), findsWidgets); + expect(find.byTooltip('Close chat'), findsOneWidget); + + await tester.tap(find.byTooltip('Close chat')); + await tester.pump(); + + expect(find.text('Alpha'), findsNothing); + expect(find.text('Server Activity'), findsOneWidget); + + await tester.tap(find.byTooltip('New private chat')); + await tester.pumpAndSettle(); + + expect(find.text('Search clients...'), findsOneWidget); + expect(find.text('Bravo'), findsOneWidget); + }, + ); + testWidgets('channel chat displays localized poke history in English', ( tester, ) async { diff --git a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart new file mode 100644 index 0000000..60da270 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/snapshot_view.dart'; + +void main() { + rust.BridgeChannel channel({ + required int id, + int parent = 0, + required String name, + int order = 0, + bool hasPassword = false, + int neededTalkPower = 0, + }) { + return rust.BridgeChannel( + id: BigInt.from(id), + parent: BigInt.from(parent), + name: name, + order: order, + hasPassword: hasPassword, + neededTalkPower: neededTalkPower, + ); + } + + rust.BridgeClient client({ + required int id, + required int channelId, + required String name, + bool speaking = false, + int talkPower = 0, + bool talkPowerGranted = false, + }) { + return rust.BridgeClient( + id: BigInt.from(id), + channel: BigInt.from(channelId), + name: name, + inputMuted: false, + outputMuted: false, + isSpeaking: speaking, + isServerQuery: false, + talkPower: talkPower, + talkPowerGranted: talkPowerGranted, + ); + } + + Widget snapshotHarness({ + required List channels, + required List clients, + BigInt? ownClientId, + BigInt? currentVoiceChannelId, + rust.BridgeAudioStats? audioStats, + }) { + return MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: SnapshotView( + snapshot: rust.BridgeSnapshot( + serverName: 'Server', + welcomeMessage: '', + platform: '', + version: '', + channels: channels, + clients: clients, + ownClientId: ownClientId ?? BigInt.from(100), + ), + audioStats: audioStats, + currentVoiceChannelId: currentVoiceChannelId, + pendingVoiceChannelId: null, + localInputMuted: false, + localOutputMuted: false, + hasJoinPending: false, + canJoinVoiceChannel: true, + onJoinChannel: (_) {}, + onJoinChannelWithPassword: (_) {}, + ), + ), + ); + } + + testWidgets('renders channel tree with users and subchannels expanded', ( + tester, + ) async { + await tester.pumpWidget( + snapshotHarness( + channels: [ + channel(id: 1, name: 'Default Channel'), + channel(id: 2, parent: 1, name: 'Default Sub Channel'), + channel(id: 3, name: 'Quiet Zone'), + ], + clients: [ + client(id: 100, channelId: 1, name: 'Alice'), + client(id: 101, channelId: 2, name: 'Bob'), + client(id: 102, channelId: 3, name: 'Carol'), + ], + ownClientId: BigInt.from(100), + currentVoiceChannelId: BigInt.from(1), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Default Channel'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Default Sub Channel'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(find.text('Quiet Zone'), findsOneWidget); + expect(find.text('Carol'), findsOneWidget); + + expect( + tester.getTopLeft(find.text('Alice')).dy, + greaterThan(tester.getTopLeft(find.text('Default Channel')).dy), + ); + expect( + tester.getTopLeft(find.text('Default Sub Channel')).dy, + greaterThan(tester.getTopLeft(find.text('Alice')).dy), + ); + expect( + tester.getTopLeft(find.text('Bob')).dy, + greaterThan(tester.getTopLeft(find.text('Default Sub Channel')).dy), + ); + }); + + testWidgets('collapsing a channel hides users and child channels', ( + tester, + ) async { + await tester.pumpWidget( + snapshotHarness( + channels: [ + channel(id: 1, name: 'Default Channel'), + channel(id: 2, parent: 1, name: 'Default Sub Channel'), + channel(id: 3, name: 'Quiet Zone'), + ], + clients: [ + client(id: 100, channelId: 1, name: 'Alice'), + client(id: 101, channelId: 2, name: 'Bob'), + client(id: 102, channelId: 3, name: 'Carol'), + ], + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.expand_more).first); + await tester.pumpAndSettle(); + + expect(find.text('Default Channel'), findsOneWidget); + expect(find.text('Alice'), findsNothing); + expect(find.text('Default Sub Channel'), findsNothing); + expect(find.text('Bob'), findsNothing); + expect(find.text('Quiet Zone'), findsOneWidget); + expect(find.text('Carol'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.chevron_right).first); + await tester.pumpAndSettle(); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Default Sub Channel'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + }); + + testWidgets('channel tree uses compact fixed columns', (tester) async { + await tester.pumpWidget( + snapshotHarness( + channels: [ + channel(id: 1, name: 'Default Channel'), + channel(id: 2, parent: 1, name: 'Default Sub Channel'), + channel(id: 3, name: 'Empty Channel'), + ], + clients: [ + client(id: 100, channelId: 1, name: 'Alice'), + client(id: 101, channelId: 2, name: 'Bob'), + ], + ), + ); + + await tester.pumpAndSettle(); + + final parentX = tester.getTopLeft(find.text('Default Channel')).dx; + final parentUserX = tester.getTopLeft(find.text('Alice')).dx; + final childX = tester.getTopLeft(find.text('Default Sub Channel')).dx; + final childUserX = tester.getTopLeft(find.text('Bob')).dx; + + expect(find.text('Channels'), findsNothing); + expect(find.byIcon(Icons.tag), findsNWidgets(3)); + expect(parentX, inInclusiveRange(58, 66)); + expect(parentUserX - parentX, inInclusiveRange(22, 28)); + expect(childX - parentX, inInclusiveRange(10, 14)); + expect(childUserX - childX, inInclusiveRange(22, 28)); + }); + + testWidgets('password channel shows lock at row end', (tester) async { + await tester.pumpWidget( + snapshotHarness( + channels: [channel(id: 1, name: 'Locked Channel', hasPassword: true)], + clients: const [], + ), + ); + + await tester.pumpAndSettle(); + + final rowRight = tester.getTopRight(find.text('Locked Channel')).dx; + final lockLeft = tester.getTopLeft(find.byIcon(Icons.lock_outline)).dx; + + expect(find.byIcon(Icons.tag), findsOneWidget); + expect(lockLeft, greaterThan(rowRight)); + }); + + testWidgets('current user row does not show speaking background while idle', ( + tester, + ) async { + await tester.pumpWidget( + snapshotHarness( + channels: [channel(id: 1, name: 'Default Channel')], + clients: [client(id: 100, channelId: 1, name: 'Alice')], + ownClientId: BigInt.from(100), + currentVoiceChannelId: BigInt.from(1), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(find.byIcon(Icons.tag), findsOneWidget); + expect(find.byIcon(Icons.mic_none), findsOneWidget); + expect(find.byType(ListTile), findsOneWidget); + + final userHighlight = tester.widget( + find.ancestor( + of: find.text('Alice'), + matching: find.byType(AnimatedContainer), + ), + ); + expect(userHighlight.decoration, isNull); + }); + + testWidgets( + 'local voice activity does not light speaking state when blocked', + (tester) async { + await tester.pumpWidget( + snapshotHarness( + channels: [ + channel(id: 1, name: 'Default Channel', neededTalkPower: 10), + ], + clients: [client(id: 100, channelId: 1, name: 'Alice', talkPower: 0)], + ownClientId: BigInt.from(100), + currentVoiceChannelId: BigInt.from(1), + audioStats: const rust.BridgeAudioStats( + framesSent: 1, + framesReceived: 0, + pttActive: true, + ), + ), + ); + + await tester.pumpAndSettle(); + + final userText = tester.widget(find.text('Alice')); + expect(find.byIcon(Icons.volume_off), findsOneWidget); + expect(find.byIcon(Icons.mic), findsNothing); + expect(userText.style?.fontWeight, isNot(FontWeight.w600)); + }, + ); + + testWidgets('spacer channels render as layout rows and keep channel taps', ( + tester, + ) async { + final spacerChannel = rust.BridgeChannel( + id: BigInt.from(42), + parent: BigInt.from(7), + name: '[cSpacerabc]Spacer Heading', + order: 99, + hasPassword: false, + neededTalkPower: 12, + ); + final normalChannel = rust.BridgeChannel( + id: BigInt.from(43), + parent: BigInt.zero, + name: 'Normal Room', + order: 100, + hasPassword: false, + neededTalkPower: 0, + ); + rust.BridgeChannel? tapped; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: SnapshotView( + snapshot: rust.BridgeSnapshot( + serverName: 'Server', + welcomeMessage: '', + platform: '', + version: '', + channels: [spacerChannel, normalChannel], + clients: const [], + ownClientId: BigInt.one, + ), + audioStats: null, + currentVoiceChannelId: null, + pendingVoiceChannelId: null, + localInputMuted: false, + localOutputMuted: false, + hasJoinPending: false, + canJoinVoiceChannel: true, + onJoinChannel: (channel) => tapped = channel, + onJoinChannelWithPassword: (_) {}, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('Spacer Heading'), findsOneWidget); + expect(find.text('[cSpacerabc]Spacer Heading'), findsNothing); + expect(find.byIcon(Icons.tag), findsOneWidget); + + final spacerText = tester.widget(find.text('Spacer Heading')); + expect(spacerText.textAlign, TextAlign.center); + + await tester.tap(find.text('Spacer Heading')); + await tester.pump(); + + expect(tapped, isNotNull); + expect(tapped!.id, BigInt.from(42)); + expect(tapped!.parent, BigInt.from(7)); + expect(tapped!.name, '[cSpacerabc]Spacer Heading'); + expect(tapped!.order, 99); + expect(tapped!.hasPassword, isFalse); + expect(tapped!.neededTalkPower, 12); + }); + + testWidgets('separator spacers render as line painters without raw text', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: SnapshotView( + snapshot: rust.BridgeSnapshot( + serverName: 'Server', + welcomeMessage: '', + platform: '', + version: '', + channels: [ + rust.BridgeChannel( + id: BigInt.from(1), + parent: BigInt.zero, + name: '[spacer]---', + order: 1, + hasPassword: false, + neededTalkPower: 0, + ), + ], + clients: const [], + ownClientId: BigInt.one, + ), + audioStats: null, + currentVoiceChannelId: null, + pendingVoiceChannelId: null, + localInputMuted: false, + localOutputMuted: false, + hasJoinPending: false, + canJoinVoiceChannel: true, + onJoinChannel: (_) {}, + onJoinChannelWithPassword: (_) {}, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('[spacer]---'), findsNothing); + expect(find.text('---'), findsNothing); + expect(find.byType(CustomPaint), findsWidgets); + expect(find.byIcon(Icons.tag), findsNothing); + }); + + testWidgets('repeating and blank spacers hide raw tags', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: Scaffold( + body: SnapshotView( + snapshot: rust.BridgeSnapshot( + serverName: 'Server', + welcomeMessage: '', + platform: '', + version: '', + channels: [ + rust.BridgeChannel( + id: BigInt.from(1), + parent: BigInt.zero, + name: '[*spacer0]#==', + order: 1, + hasPassword: false, + neededTalkPower: 0, + ), + rust.BridgeChannel( + id: BigInt.from(2), + parent: BigInt.zero, + name: '[rSpacer0].', + order: 2, + hasPassword: false, + neededTalkPower: 0, + ), + ], + clients: const [], + ownClientId: BigInt.one, + ), + audioStats: null, + currentVoiceChannelId: null, + pendingVoiceChannelId: null, + localInputMuted: false, + localOutputMuted: false, + hasJoinPending: false, + canJoinVoiceChannel: true, + onJoinChannel: (_) {}, + onJoinChannelWithPassword: (_) {}, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('[*spacer0]#=='), findsNothing); + expect(find.text('[rSpacer0].'), findsNothing); + expect(find.text('.'), findsNothing); + expect(find.textContaining('#==#=='), findsOneWidget); + expect(find.byIcon(Icons.tag), findsNothing); + }); +} diff --git a/apps/chanora_flutter/test/widgets/talk_power_warning_test.dart b/apps/chanora_flutter/test/widgets/talk_power_warning_test.dart new file mode 100644 index 0000000..c3b3607 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/talk_power_warning_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/widgets/talk_power_warning.dart'; + +void main() { + test('talk power policy only blocks insufficient ungranted clients', () { + expect( + isTalkPowerBlocked( + talkPower: 5, + neededTalkPower: 10, + talkPowerGranted: false, + ), + isTrue, + ); + expect( + isTalkPowerBlocked( + talkPower: 10, + neededTalkPower: 10, + talkPowerGranted: false, + ), + isFalse, + ); + expect( + isTalkPowerBlocked( + talkPower: 5, + neededTalkPower: 10, + talkPowerGranted: true, + ), + isFalse, + ); + expect( + isTalkPowerBlocked( + talkPower: null, + neededTalkPower: 10, + talkPowerGranted: false, + ), + isFalse, + ); + }); + + testWidgets('talk power warning hides when not blocked', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: TalkPowerWarning( + talkPower: 10, + neededTalkPower: 10, + talkPowerGranted: false, + ), + ), + ); + + expect(find.textContaining('Insufficient talk power'), findsNothing); + expect(find.byType(SizedBox), findsOneWidget); + }); + + testWidgets('talk power warning renders blocked values', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: TalkPowerWarning( + talkPower: 5, + neededTalkPower: 10, + talkPowerGranted: false, + ), + ), + ), + ); + + expect(find.text('Insufficient talk power (5 < 10)'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber), findsOneWidget); + }); +} diff --git a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart new file mode 100644 index 0000000..727b16d --- /dev/null +++ b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/voice_settings_controls.dart'; + +void main() { + test('shared transmit mode segments expose all modes', () { + expect(transmitModeSegments.map((s) => s.value), [ + rust.BridgeTransmitMode.ptt, + rust.BridgeTransmitMode.continuous, + rust.BridgeTransmitMode.voiceActivity, + ]); + }); + + test('shared Android processing segments expose hardware and WebRTC', () { + expect(androidProcessingSegments.map((s) => s.value), [true, false]); + }); + + test('shared iOS processing segments expose VPIO and Sonora', () { + expect(iosProcessingSegments.map((s) => s.value), [ + rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + rust.BridgeIosVoiceProcessingMode.sonoraExperimental, + ]); + }); + + test('shared VAD segments expose supported non-disabled backends', () { + expect(vadBackendSegments.map((s) => s.value), [ + rust.BridgeVadBackend.webrtcVad, + rust.BridgeVadBackend.sileroOnnx, + rust.BridgeVadBackend.tenVad, + ]); + }); + + testWidgets('shared segmented style applies compact visual density', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + final style = voiceSegmentedButtonStyle(Theme.of(context)); + return SegmentedButton( + style: style, + segments: androidProcessingSegments, + selected: const {true}, + ); + }, + ), + ), + ); + + expect(find.byType(SegmentedButton), findsOneWidget); + }); + + testWidgets('audio processing toggle row renders dense layout', ( + tester, + ) async { + var value = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return AudioProcessingToggleRow( + dense: true, + label: 'Noise suppression', + subtitle: 'Wiener filter', + value: value, + onChanged: (next) => setState(() => value = next), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(Switch)); + await tester.pump(); + + expect(value, isTrue); + expect(find.text('Noise suppression'), findsOneWidget); + expect(find.text('Wiener filter'), findsOneWidget); + }); +} diff --git a/apps/chanora_flutter/test/widgets/voice_status_summary_test.dart b/apps/chanora_flutter/test/widgets/voice_status_summary_test.dart new file mode 100644 index 0000000..2490c31 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/voice_status_summary_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/voice_status_summary.dart'; + +void main() { + final l10n = AppL10nEn(); + + test('voice mode labels follow localization', () { + expect( + voiceModeLabel(l10n, rust.BridgeTransmitMode.ptt), + l10n.voiceModePtt, + ); + expect( + voiceModeLabel(l10n, rust.BridgeTransmitMode.continuous), + l10n.voiceModeContinuous, + ); + expect( + voiceModeLabel(l10n, rust.BridgeTransmitMode.voiceActivity), + l10n.voiceModeVoiceActivity, + ); + }); + + test('PTT summary shows touch hold hint and release tail', () { + final summary = voiceStatusSummary( + l10n: l10n, + transmitMode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 200, + pttBoundKeyLabel: '', + isTouchOnly: true, + inputMuted: false, + outputMuted: false, + pttActive: false, + ); + + expect(summary.line1, '${l10n.voiceModePtt} · ${l10n.voicePttHoldHint}'); + expect(summary.line2, '200${l10n.voiceReleaseTailHint} release tail · off'); + expect(summary.statusText, l10n.voiceMicOff); + expect(summary.micOn, isFalse); + }); + + test('PTT summary shows bound key on hardware hosts', () { + final summary = voiceStatusSummary( + l10n: l10n, + transmitMode: rust.BridgeTransmitMode.ptt, + releaseTailMs: 120, + pttBoundKeyLabel: 'Space', + isTouchOnly: false, + inputMuted: false, + outputMuted: false, + pttActive: true, + ); + + expect(summary.line1, '${l10n.voiceModePtt} · Space'); + expect(summary.micOn, isTrue); + expect(summary.statusText, l10n.voiceMicOn); + }); + + test('continuous mode is active unless muted or talk power blocked', () { + final active = voiceStatusSummary( + l10n: l10n, + transmitMode: rust.BridgeTransmitMode.continuous, + releaseTailMs: 0, + pttBoundKeyLabel: '', + isTouchOnly: false, + inputMuted: false, + outputMuted: false, + pttActive: false, + ); + final muted = voiceStatusSummary( + l10n: l10n, + transmitMode: rust.BridgeTransmitMode.continuous, + releaseTailMs: 0, + pttBoundKeyLabel: '', + isTouchOnly: false, + inputMuted: true, + outputMuted: false, + pttActive: false, + ); + + expect(active.micOn, isTrue); + expect(muted.micOn, isFalse); + expect(muted.statusText, '${l10n.voiceMicOff} (muted)'); + }); + + test('talk power block overrides active mic state', () { + final summary = voiceStatusSummary( + l10n: l10n, + transmitMode: rust.BridgeTransmitMode.continuous, + releaseTailMs: 0, + pttBoundKeyLabel: '', + isTouchOnly: false, + inputMuted: false, + outputMuted: false, + pttActive: true, + talkPower: 5, + neededTalkPower: 10, + talkPowerGranted: false, + ); + + expect(summary.talkPowerBlocked, isTrue); + expect(summary.micOn, isFalse); + expect(summary.statusText, 'Insufficient permission'); + }); +} diff --git a/apps/chanora_flutter/windows/flutter/generated_plugins.cmake b/apps/chanora_flutter/windows/flutter/generated_plugins.cmake index 0b0bda0..4467694 100644 --- a/apps/chanora_flutter/windows/flutter/generated_plugins.cmake +++ b/apps/chanora_flutter/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus + share_plus url_launcher_windows ) diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 9a5528e..cebbe60 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -60,13 +60,55 @@ pub use chanora_audio::{ }; pub use chanora_audio::{PttBinding, PttInputClass}; pub use chanora_diagnostics::{ - DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, RedactingLogLayer, Redactor, + DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, ProtocolEventRecorder, + RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY, }; pub use chanora_protocol::{ - ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot, + ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget, + ProtocolError, ServerSnapshot, }; pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore}; +/// Privacy-safe snapshot of the active PTT capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PttDescriptorSnapshot { + /// Stable capability level name. + pub level: String, + /// Stable backend identifier. + pub backend_id: String, + /// Coarse bound input class; empty when no binding is active. + pub bound_input_class: String, +} + +impl From for PttDescriptorSnapshot { + fn from(desc: PttBackendDescriptor) -> Self { + Self { + 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(), + } + } +} + +/// Persisted PTT binding state exposed to callers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedPttBinding { + /// Stable input category string (`""`, `"keyboard"`, or + /// `"mouse-side-button"`). + pub input_class: String, + /// Display-only key label; empty when no binding is active. + pub key_label: String, +} + +impl PersistedPttBinding { + fn empty() -> Self { + Self { + input_class: String::new(), + key_label: String::new(), + } + } +} + /// Errors that can arise during top-level orchestration. #[derive(Debug, Error)] pub enum CoreError { @@ -202,6 +244,22 @@ pub enum SessionEvent { /// Platform-provided resume hint. For begin events this is false. should_resume: bool, }, + /// A text message was received from the server. + ChatMessage { + /// Client id of the sender. + sender_id: u64, + /// Nickname of the sender. + sender_name: String, + /// Message content. + message: String, + /// Target scope (server/channel/private/poke). + target: MessageTarget, + }, + /// Audio route changed (speaker/earpiece/BT/wired headset). + AudioRouteChanged { + /// New audio route. + route: AudioRoute, + }, } /// Bridge-safe mirror of channel-join projection sync state. @@ -261,6 +319,43 @@ pub enum NetworkState { /// fall behind we'd rather skip than block the supervisor. const EVENT_CHANNEL_CAPACITY: usize = 64; +/// Network diagnostics snapshot collected across connection lifetimes. +#[derive(Debug, Clone, Default)] +struct NetworkDiagnostics { + /// Total count of connects (including the initial one). + connect_count: u64, + /// Count of disconnects (graceful + loss). + disconnect_count: u64, + /// Recent loss reasons (last 8, ring buffer). + loss_reasons: Vec, +} + +impl NetworkDiagnostics { + fn record_connect(&mut self) { + self.connect_count = self.connect_count.saturating_add(1); + } + fn record_loss(&mut self, reason: &str) { + self.disconnect_count = self.disconnect_count.saturating_add(1); + if self.loss_reasons.len() >= 8 { + self.loss_reasons.remove(0); + } + self.loss_reasons.push(reason.to_string()); + } + fn summary(&self) -> String { + let mut s = format!( + "connects: {}\ndisconnects: {}\n", + self.connect_count, self.disconnect_count + ); + if !self.loss_reasons.is_empty() { + s.push_str(&format!( + "loss_reasons: [{}]\n", + self.loss_reasons.join(", ") + )); + } + s + } +} + struct SupervisorInner { /// Optional cached AudioEngineConfig — set when start_audio is /// first called, used to re-create the engine after a reconnect. @@ -287,10 +382,6 @@ struct ConnectedState { /// Supervisor task handle. Awaited on disconnect for clean /// teardown. supervisor: Option>, - /// Connection config used to dial this connection; retained for - /// future diagnostics. The supervisor task holds its own clone. - #[allow(dead_code)] - cfg: ConnectConfig, /// Audio supervision state. Wrapped in Arc> so the /// supervisor and the public API both see updates. sup_inner: Arc>, @@ -345,13 +436,6 @@ pub struct ChanoraSession { /// Release-tail timer (SDD-096). Drives the selector's /// `ptt_held` input from PTT key edges. release_tail: Arc, - /// Missed-key-up watchdog (SAD-079 / DEC-028). Subscribes to - /// `voice_selector.subscribe_ptt_held()` so it only fires when - /// an actual PTT key has been "stuck" for the configured - /// timeout (default 30 s). Lives on the session because it - /// must outlive engine restarts. Spawned lazily on the first - /// `start_audio` because it needs a tokio runtime context. - ptt_watchdog: Arc>>, /// 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 @@ -361,6 +445,16 @@ pub struct ChanoraSession { /// the binding survives app restarts. pending_binding: Arc>>, next_connection_epoch: Arc>, + /// SRS-100: Network diagnostics snapshot across connection + /// lifetimes (reconnect counts, loss reasons). + network_diag: Arc>, + /// SRS-097/098: Protocol event recorder for diagnostic export + /// and state-sync replay verification. + event_recorder: Arc>, + /// SRS-026: Preferred input device name. + preferred_input_device: Arc>>, + /// SRS-026: Preferred output device name. + preferred_output_device: Arc>>, } impl ChanoraSession { @@ -387,8 +481,13 @@ impl ChanoraSession { voice_selector: selector, release_tail, pending_binding: Arc::new(Mutex::new(None)), - ptt_watchdog: Arc::new(Mutex::new(None)), next_connection_epoch: Arc::new(Mutex::new(1)), + network_diag: Arc::new(Mutex::new(NetworkDiagnostics::default())), + event_recorder: Arc::new(Mutex::new(chanora_diagnostics::ProtocolEventRecorder::new( + 256, + ))), + preferred_input_device: Arc::new(Mutex::new(None)), + preferred_output_device: Arc::new(Mutex::new(None)), } } @@ -440,16 +539,16 @@ impl ChanoraSession { // 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() { + let stored_binding = store.get_ptt_binding(); + if !stored_binding.input_class.is_empty() || !stored_binding.platform_key.is_empty() { + let input_class = match stored_binding.input_class.as_str() { "keyboard" => PttInputClass::Keyboard, "mouse-side-button" => PttInputClass::MouseSideButton, _ => PttInputClass::None, }; let binding = PttBinding { input_class, - platform_key, + platform_key: stored_binding.platform_key, }; *self.pending_binding.lock().await = Some(binding); } @@ -589,32 +688,94 @@ impl ChanoraSession { audio_running: false, })); - let supervisor = tokio::spawn(supervisor_loop( - self.inner.clone(), - self.events_tx.clone(), - cfg.clone(), - lost_rx, - probe, + let supervisor = tokio::spawn(supervisor_loop(SupervisorContext { + state_arc: self.inner.clone(), + events_tx: self.events_tx.clone(), + initial_cfg: cfg.clone(), + initial_lost_rx: lost_rx, + initial_probe: probe, cancel_rx, - sup_inner.clone(), - self.network_tx.subscribe(), - self.voice_selector.clone(), - self.pending_binding.clone(), - self.release_tail.clone(), - self.next_connection_epoch.clone(), - )); + sup_inner: sup_inner.clone(), + network_rx: self.network_tx.subscribe(), + network_diag: self.network_diag.clone(), + event_recorder: self.event_recorder.clone(), + voice_selector: self.voice_selector.clone(), + pending_binding: self.pending_binding.clone(), + release_tail: self.release_tail.clone(), + next_connection_epoch: self.next_connection_epoch.clone(), + })); let _ = self.events_tx.send(SessionEvent::Connected { server_name: snap.server_name.clone(), }); + // Record connect (SRS-100). + { + let mut diag = self.network_diag.lock().await; + diag.record_connect(); + } + // Record protocol event (SRS-097). + { + let mut rec = self.event_recorder.lock().await; + rec.record_connected(&snap.server_name); + } + + // Auto-save as a recent server (SRS-085). If a bookmark with + // the same host+port already exists, update it; otherwise + // create a new one with a default display name. + { + let bm_guard = self.bookmark_store.lock().await; + if let Some(repo) = bm_guard.as_ref() { + let host = cfg.address.clone(); + let display = snap.server_name.clone(); + let nickname = cfg.nickname.clone(); + let password = cfg.password.clone(); + let bm = Bookmark { + id: 0, + display_name: display, + host, + nickname, + password, + }; + if let Err(e) = repo.upsert_or_add(&bm) { + warn!(target: "chanora_core", error = %e, "could not auto-save recent server"); + } + } + } + + // Forward inbound chat messages from the protocol adapter + // to the event broadcast stream. Chat is infrequent (~human + // typing rate), so a simple loop with try_recv + yield is fine. + if let Some(chat_rx) = client.take_chat_rx() { + let ev_tx = self.events_tx.clone(); + tokio::spawn(async move { + use tokio::time::{sleep, Duration}; + let mut rx = chat_rx; + loop { + match rx.try_recv() { + Ok(msg) => { + let _ = ev_tx.send(SessionEvent::ChatMessage { + sender_id: msg.sender_id.0, + sender_name: msg.sender_name, + message: msg.message, + target: msg.target, + }); + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + sleep(Duration::from_millis(200)).await; + } + } + } + }); + } + *guard = Some(ConnectedState { protocol: client, audio: None, ptt_controller: None, cancel_tx: Some(cancel_tx), supervisor: Some(supervisor), - cfg, sup_inner, join_state, channel_passwords: HashMap::new(), @@ -701,11 +862,56 @@ impl ChanoraSession { self.inner.lock().await.is_some() } + /// Send a text message to the specified target. + pub async fn send_text_message( + &self, + message: String, + target: MessageTarget, + ) -> Result<(), CoreError> { + if message.trim().is_empty() { + return Ok(()); + } + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + state.protocol.send_text_message(message, target).await?; + Ok(()) + } + + /// Get a network diagnostics summary (SRS-100). + pub async fn network_diagnostics_summary(&self) -> String { + let diag = self.network_diag.lock().await; + diag.summary() + } + + /// Drain and return recorded protocol events (SRS-097). + pub async fn drain_protocol_events(&self) -> Vec { + let mut rec = self.event_recorder.lock().await; + rec.drain() + } + + /// Record a platform lifecycle event (SRS-138). + pub async fn record_lifecycle_event(&self, state: &str) { + let mut rec = self.event_recorder.lock().await; + rec.record_lifecycle(state); + } + /// Start the audio engine attached to the current connection. /// Fails if not connected. Idempotent — calling twice replaces /// the engine. Stores the config so the supervisor can restart /// audio after a reconnect. pub async fn start_audio(&self, mut cfg: AudioEngineConfig) -> Result<(), CoreError> { + // Apply stored device preferences (SRS-026). + { + let dev_in = self.preferred_input_device.lock().await; + let dev_out = self.preferred_output_device.lock().await; + if cfg.input_device_name.is_none() { + cfg.input_device_name = dev_in.clone(); + } + if cfg.output_device_name.is_none() { + cfg.output_device_name = dev_out.clone(); + } + } + let mut guard = self.inner.lock().await; let state = guard.as_mut().ok_or(CoreError::NotConnected)?; @@ -782,32 +988,6 @@ impl ChanoraSession { let controller = ptt::PttController::new(self.release_tail.clone()); state.ptt_controller = Some(controller.clone()); - // SAD-079 / DEC-028 missed-key-up watchdog: DISABLED for - // P0 per owner decision (2026-05-16). The original 30 s - // ceiling caused real users to be cut off mid-sentence in - // PTT mode whenever they spoke for longer than the - // timeout. The watchdog's purpose (catching OS-level - // key-up loss when the app loses focus / is minimised / - // hits App Nap) is real, but the fixed-timeout - // implementation is the wrong shape. - // - // P1 redesign options under consideration: - // * Raise ceiling to ~5 min (owner-tunable, per DEC-028) - // * Add Windows GetAsyncKeyState / macOS - // CGEventSourceKeyState / X11 XQueryKeymap polling so - // we detect the actual OS desync directly instead of - // timing out on legitimate long speech - // * Combine with an RMS-silence check once the audio - // level meter (P1) lands, so the watchdog only fires - // when the user has been "transmitting" silence for - // the entire window - // - // Until P1 picks one of those, we ship without the - // watchdog. The existing MissedKeyUpWatchdog code path - // and tests remain in place so the P1 work can re-enable - // it with the chosen detection strategy. - let _ = &self.ptt_watchdog; - // 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 @@ -879,12 +1059,6 @@ impl ChanoraSession { Ok(()) } - /// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The - /// binding flows into the platform backend's `rebind` hook via - /// the [`ptt::PttController`] (SDD-088) and the freshly-published - /// `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 @@ -930,30 +1104,24 @@ impl ChanoraSession { Ok(()) } - /// Returns the active PTT capability descriptor as a triple - /// `(level, backend_id, bound_input_class)`. Useful for the - /// initial UI render before the first `PttCapability` event - /// arrives. Returns the universal Focused descriptor when no - /// audio engine is running. - pub async fn ptt_descriptor(&self) -> (String, String, String) { + /// Returns the active PTT capability descriptor. Useful for the + /// initial UI render before the first `PttCapability` event arrives. + /// Returns the universal Focused descriptor when no audio engine is + /// running. + pub async fn ptt_descriptor(&self) -> PttDescriptorSnapshot { let guard = self.inner.lock().await; let desc = match guard.as_ref().and_then(|s| s.ptt_controller.as_ref()) { Some(controller) => controller.descriptor().await, None => PttBackendDescriptor::focused(), }; - ( - desc.level.as_str().to_string(), - desc.backend_id.to_string(), - desc.bound_input_class.unwrap_or("").to_string(), - ) + desc.into() } - /// 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) { + /// Read the persisted PTT binding. 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) -> PersistedPttBinding { // 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() { @@ -962,15 +1130,21 @@ impl ChanoraSession { PttInputClass::Keyboard => "keyboard", PttInputClass::MouseSideButton => "mouse-side-button", }; - return (class_s.to_string(), b.platform_key); + return PersistedPttBinding { + input_class: class_s.to_string(), + key_label: 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); + let stored_binding = store.get_ptt_binding(); + return PersistedPttBinding { + input_class: stored_binding.input_class, + key_label: stored_binding.key_label, + }; } - (String::new(), String::new()) + PersistedPttBinding::empty() } /// Move our own client to `channel_id`. Optional channel @@ -1040,12 +1214,27 @@ impl ChanoraSession { Ok(()) } - /// Read audio engine statistics: (frames_sent, frames_received, ptt_active). + /// Set per-client output volume (SRS-075). `1.0` is unity, `0.0` + /// mutes. No-op if audio is not started or client has no active + /// voice queue. + pub async fn set_client_volume(&self, client_id: u64, volume: f32) -> Result<(), CoreError> { + let guard = self.inner.lock().await; + let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?; + audio.set_client_volume(client_id, volume); + Ok(()) + } + + /// Read audio engine statistics: (frames_sent, frames_received, transmit_active). pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> { let guard = self.inner.lock().await; let state = guard.as_ref().ok_or(CoreError::NotConnected)?; let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?; - Ok((audio.frames_sent(), audio.frames_received(), audio.ptt())) + Ok(( + audio.frames_sent(), + audio.frames_received(), + audio.transmit_active(), + )) } /// Apply the Rust-owned P1 audio-processing configuration. @@ -1103,7 +1292,8 @@ impl ChanoraSession { Ok(()) } - /// iOS route-change hook (SDD-100). No-op when audio is not running. + /// Route-change hook (SDD-100/SRS-112). Updates audio processing + /// config and notifies Flutter of the new route. pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> { let guard = self.inner.lock().await; if let Some(state) = guard.as_ref() { @@ -1113,6 +1303,9 @@ impl ChanoraSession { audio.set_audio_processing_config(config)?; } } + let _ = self + .events_tx + .send(SessionEvent::AudioRouteChanged { route }); Ok(()) } @@ -1468,6 +1661,19 @@ impl ChanoraSession { self.voice_selector.hard_mute() } + /// Set the preferred input device name (SRS-026). Takes effect + /// on the next audio engine start. + pub async fn set_input_device(&self, name: Option) -> Result<(), CoreError> { + *self.preferred_input_device.lock().await = name; + Ok(()) + } + + /// Set the preferred output device name (SRS-026). + pub async fn set_output_device(&self, name: Option) -> Result<(), CoreError> { + *self.preferred_output_device.lock().await = name; + Ok(()) + } + /// Update the release-tail (SDD-096). Clamped to `0..=500` ms /// inclusive. Persists best-effort and re-emits voice state. pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> { @@ -1588,21 +1794,40 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4); /// declares the connection lost. const WATCHDOG_MAX_MISSES: u32 = 3; -#[allow(clippy::too_many_arguments)] -async fn supervisor_loop( +struct SupervisorContext { state_arc: Arc>>, events_tx: broadcast::Sender, initial_cfg: ConnectConfig, initial_lost_rx: oneshot::Receiver, initial_probe: chanora_protocol::SnapshotProbe, - mut cancel_rx: oneshot::Receiver<()>, + cancel_rx: oneshot::Receiver<()>, sup_inner: Arc>, - mut network_rx: watch::Receiver, + network_rx: watch::Receiver, + network_diag: Arc>, + event_recorder: Arc>, voice_selector: Arc, pending_binding: Arc>>, release_tail: Arc, next_connection_epoch: Arc>, -) { +} + +async fn supervisor_loop(ctx: SupervisorContext) { + let SupervisorContext { + state_arc, + events_tx, + initial_cfg, + initial_lost_rx, + initial_probe, + mut cancel_rx, + sup_inner, + mut network_rx, + network_diag, + event_recorder, + voice_selector, + pending_binding, + release_tail, + next_connection_epoch, + } = ctx; let mut lost_rx = initial_lost_rx; let mut probe = initial_probe; let mut cfg = initial_cfg; @@ -1700,6 +1925,11 @@ async fn supervisor_loop( channels: snap.channels.len() as u32, clients: snap.clients.len() as u32, }); + // Record protocol event (SRS-097). + event_recorder.lock().await.record_snapshot_changed( + snap.channels.len(), + snap.clients.len(), + ); } } Ok(Err(e)) => { @@ -1747,6 +1977,12 @@ async fn supervisor_loop( reason: reason_str.clone(), }); + // Record network diagnostic (SRS-100). + { + let mut diag = network_diag.lock().await; + diag.record_loss(&reason_str); + } + // Stop the audio engine before reconnect — its // voice_out_tx points at the dead protocol client. // Also drop the dead protocol client itself so @@ -1778,6 +2014,11 @@ async fn supervisor_loop( attempt, delay_secs, }); + // Record protocol event (SRS-097). + event_recorder + .lock() + .await + .record_reconnecting(attempt as u64, delay_secs as u64); info!( target: "chanora_core", attempt, @@ -2114,6 +2355,7 @@ mod tests { name: "a".into(), order: 0, has_password: false, + needed_talk_power: None, }, ChannelInfo { id: chanora_protocol::ChannelId(2), @@ -2121,6 +2363,7 @@ mod tests { name: "b".into(), order: 1, has_password: false, + needed_talk_power: None, }, ], clients: vec![ClientInfo { @@ -2131,6 +2374,8 @@ mod tests { output_muted: false, is_speaking: false, is_server_query: false, + talk_power: 0, + talk_power_granted: false, }], own_client_id: 10, }; @@ -2155,6 +2400,7 @@ mod tests { name: "b".into(), order: 1, has_password: false, + needed_talk_power: None, }, ChannelInfo { id: chanora_protocol::ChannelId(1), @@ -2162,6 +2408,7 @@ mod tests { name: "a".into(), order: 0, has_password: false, + needed_talk_power: None, }, ], clients: vec![], diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 0bff277..1a14951 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -28,6 +28,7 @@ audiopus = "0.3.0-rc.0" # Connection type stays inside chanora_protocol. tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] } tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } +rustfft = "6.2.0" [target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies] # Desktop audio I/O for Windows capture/playback and Linux capture. @@ -35,11 +36,6 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } # AudioUnits via `coreaudio-rs` for the voice path. cpal = "0.17.3" -[target.'cfg(not(target_os = "android"))'.dependencies] -# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework -# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD). -reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] } - [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] # Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up). # cpal's Apple path does not expose the voice-processing controls Chanora @@ -64,9 +60,6 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] } [target.'cfg(target_os = "android")'.dependencies] -# Android cross-builds should not pull OpenSSL. Use rustls here while keeping -# native-tls for Apple targets where aws-lc/rustls is problematic for iOS. -reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] } # JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION # when the voice-comm preset is requested. ndk_context is initialised # by the bridge crate's android_init shim. diff --git a/crates/chanora_audio/benches/realtime_capture.rs b/crates/chanora_audio/benches/realtime_capture.rs index a337131..2e2871a 100644 --- a/crates/chanora_audio/benches/realtime_capture.rs +++ b/crates/chanora_audio/benches/realtime_capture.rs @@ -95,7 +95,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let _ = std::fs::write(&path, format!("{}", delta)); + let _ = std::fs::write(&path, delta.to_string()); } } diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 430e7aa..14a7ee8 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -50,7 +50,7 @@ use crate::mobile_voice_backend::{ AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics, AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx, BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend, - SharingModeChoice, + SharingModeChoice, VoiceAudioParams, }; use chanora_protocol::OutPacket; use tsclientlib::audio::AudioHandler; @@ -102,8 +102,8 @@ impl RenderReferenceBuffer { fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) { let idx = self.write_idx.load(Ordering::Relaxed); unsafe { - let slot = - &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES] as *mut [f32; RENDER_REF_SAMPLES]; + let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES] + as *mut [f32; RENDER_REF_SAMPLES]; (*slot).copy_from_slice(frame); } self.write_idx @@ -148,6 +148,7 @@ struct AndroidCaptureState { ten_vad_worker: Option, current_vad_backend: crate::VadBackend, silero_model_epoch: u64, + ten_model_epoch: u64, capture_frame_seq: u64, vad_state: crate::voice_activity::VoiceActivityStateMachine, webrtc_apm_processor: crate::processor::WebRtcApmProcessor, @@ -208,6 +209,7 @@ impl AndroidCaptureState { ten_vad_worker: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), + ten_model_epoch: crate::vad::ten_model_epoch(), capture_frame_seq: 0, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config( @@ -227,8 +229,7 @@ impl AndroidCaptureState { fn ingest_i16(&mut self, samples: &[i16]) { let mut offset = 0; while offset < samples.len() { - let remaining = - crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len; + let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len; let take = remaining.min(samples.len() - offset); self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take] .copy_from_slice(&samples[offset..offset + take]); @@ -249,11 +250,8 @@ impl AndroidCaptureState { while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES { let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES]; - frame.copy_from_slice( - &self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES], - ); - self.pcm_accum - .drain(..crate::frame::FRAME_20MS_SAMPLES); + frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]); + self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES); match self.encoder.encode(&frame, &mut self.opus_out[..]) { Ok(len) => { crate::opus_voice::send_voip_frame( @@ -289,18 +287,26 @@ impl AndroidCaptureState { fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) { if self.fallback_warned_backend != Some(failed_backend) { self.fallback_warned_backend = Some(failed_backend); - warn!( - target: "chanora_audio", - backend = failed_backend.as_str(), - "android: VAD backend unavailable; using WebRTC fallback for runtime detection" - ); + // Warm-up period: ONNX workers need ~100ms to process first frame. + // Don't flag as a problem if the capture has just started. + if self.capture_frame_seq < 128 { + info!( + target: "chanora_audio", + backend = failed_backend.as_str(), + seq = self.capture_frame_seq, + "android: VAD backend warming up; using WebRTC fallback" + ); + } else { + warn!( + target: "chanora_audio", + backend = failed_backend.as_str(), + "android: VAD backend unavailable; using WebRTC fallback for runtime detection" + ); + } } } - fn process_10ms_capture_frame( - &mut self, - samples: &[i16; crate::frame::FRAME_10MS_SAMPLES], - ) { + fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) { let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES]; for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) { *dst = crate::frame::i16_to_f32(src); @@ -327,11 +333,15 @@ impl AndroidCaptureState { // VAD backend switching (mirrors iOS Raw path). let silero_epoch = crate::vad::silero_model_epoch(); - let silero_changed = vad_backend == crate::VadBackend::SileroOnnx - && silero_epoch != self.silero_model_epoch; - if vad_backend != self.current_vad_backend || silero_changed { + let ten_epoch = crate::vad::ten_model_epoch(); + let silero_changed = + vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch; + let ten_changed = + vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch; + if vad_backend != self.current_vad_backend || silero_changed || ten_changed { self.current_vad_backend = vad_backend; self.silero_model_epoch = silero_epoch; + self.ten_model_epoch = ten_epoch; self.fallback_warned_backend = None; match vad_backend { crate::VadBackend::SileroOnnx => { @@ -376,52 +386,52 @@ impl AndroidCaptureState { } else if vad_backend == crate::VadBackend::SileroOnnx { if let Some(worker) = self.silero_vad_worker.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); - if enqueued && !worker.is_stale(capture_seq) { + if !worker.is_stale(capture_seq) { let p = worker.latest_probability(); crate::vad::VadOutput { probability: p, speech: p >= 0.5, } + } else if enqueued { + // Warm-up: worker dispatched but hasn't finished yet. + // Default to no-speech until first result arrives (~100ms). + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) + crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) + crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) } } else if vad_backend == crate::VadBackend::TenVad { if let Some(worker) = self.ten_vad_worker.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); - if enqueued && !worker.is_stale(capture_seq) { + if !worker.is_stale(capture_seq) { let p = worker.latest_probability(); crate::vad::VadOutput { probability: p, speech: p >= 0.5, } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) + crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) + crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) } } else { crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) @@ -572,37 +582,6 @@ impl AudioOutputCallback for OutputCallback { } } -/// Bundle of engine-owned state shared with the Oboe audio callbacks. -/// Mirrors the parameter set that iOS `IosVoiceUnit::start()` receives -/// from the engine (SDD-120 amendment: Android Oboe-only audio path). -pub struct VoiceAudioParams { - /// Opus-encoded voice packets sent on this channel toward the - /// protocol layer. - pub voice_out_tx: mpsc::Sender, - /// PTT transmission gate — true when the user holds the PTT key. - pub transmit_active: Arc, - /// Counter incremented per encoded frame sent. - pub frames_sent: Arc, - /// Pre-encode amplitude scale (1.0 = unity). - pub mic_gain: f32, - /// AudioHandler that inbound decode+mix feeds into; the Oboe output - /// callback pulls mixed stereo f32 from it. - pub handler: Arc>>, - /// Master output gain (f32 bits stored in AtomicU32 for lock-free - /// cross-thread read from the realtime audio callback). - pub output_gain: Arc, - /// True = output silence regardless of incoming voice frames. - pub output_muted: Arc, - /// Optional TransmitModeSelector for VoiceActivity transmit mode. - /// The capture callback calls set_voice_activity_open on this when - /// VAD detects speech. None means VoiceActivity mode is disabled. - pub voice_activity_selector: Option>, - /// Shared audio-processing config (WebRTC APM flags, VAD backend). - pub audio_processing_config: Arc>, - /// Shared audio-processing statistics for diagnostics. - pub audio_processing_stats: Arc, -} - // --- The backend itself ------------------------------------------ /// Android voice-audio backend (SDD-111). Owns one input + one @@ -647,8 +626,7 @@ impl AndroidVoiceUnit { /// `params` bundles the engine-owned state shared with the Oboe /// audio callbacks (SDD-120 amendment: Android Oboe-only audio /// path — capture pipeline, playback pull, and PTT gate). - #[allow(clippy::too_many_arguments)] - pub fn open( + pub(crate) fn open( cfg: &AndroidVoiceStreamConfig, params: VoiceAudioParams, ) -> Result { @@ -761,9 +739,7 @@ impl AndroidVoiceUnit { // // For now: hardware effects require the system session ID. // WebRTC APM software processing handles AEC/NS/AGC/HPF. - let session_id: Option = input_stream - .as_ref() - .and_then(|s| s.get_raw_session_id()); + let session_id: Option = input_stream.as_ref().and_then(|s| s.get_raw_session_id()); // --- Open output stream (SDD-112) -------------------------- let output_builder = AudioStreamBuilder::default() @@ -1558,7 +1534,7 @@ fn load_app_class<'local>( Ok(c) => return Some(c), Err(e) => { let _ = env.exception_clear(); - warn!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader"); + debug!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader"); } } diff --git a/crates/chanora_audio/src/audio_processing.rs b/crates/chanora_audio/src/audio_processing.rs index cd92e83..a0dddec 100644 --- a/crates/chanora_audio/src/audio_processing.rs +++ b/crates/chanora_audio/src/audio_processing.rs @@ -207,12 +207,12 @@ impl AudioProcessingConfig { .to_string(), )); } - if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental { - if self.processing_backend != AudioBackend::WebrtcApm { - return Err(AudioError::InvalidAudioProcessingConfig( - "ios raw processing mode requires the WebRTC APM backend".to_string(), - )); - } + if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental + && self.processing_backend != AudioBackend::WebrtcApm + { + return Err(AudioError::InvalidAudioProcessingConfig( + "ios raw processing mode requires the WebRTC APM backend".to_string(), + )); } Ok(()) } @@ -246,7 +246,7 @@ mod tests { assert_eq!(config.aec, EffectOwner::Platform); assert_eq!(config.ns, EffectOwner::Platform); assert_eq!(config.agc, EffectOwner::Platform); - assert_eq!(config.vad_backend, VadBackend::SileroOnnx); + assert_eq!(config.vad_backend, VadBackend::TenVad); } #[test] diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index ba82d6a..c2882a2 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -55,17 +55,98 @@ use audiopus::coder::Encoder as OpusEncoder; pub struct SessionAudioId(pub u64); /// Audio framing: 48 kHz mono, 20 ms = 960 samples per frame. -/// These constants are framing invariants of the engine and are -/// referenced from per-platform helpers (`try_open_capture` and -/// the CaptureState on cpal platforms; `ios_voice_unit` on iOS once -/// commits 3+4 land). The `allow(dead_code)` is here because in -/// the current commit the iOS VPIO callbacks are still no-op stubs -/// and don't reach these constants yet — they will in commit 3 -/// when the input callback wires into CaptureState. -#[allow(dead_code)] +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] const SAMPLE_RATE: u32 = 48_000; -#[allow(dead_code)] +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] const FRAME_SAMPLES: usize = 48_000 / 50; // 960 + +/// List of available audio devices from the platform. +#[derive(Debug, Clone)] +pub struct AudioDeviceList { + /// Available input (capture) devices. + pub input_devices: Vec, + /// Available output (playback) devices. + pub output_devices: Vec, +} + +/// Info about a single audio device. +#[derive(Debug, Clone)] +pub struct AudioDeviceInfo { + /// Human-readable device name from the OS. + pub name: String, + /// True if the OS reports this as the default device. + pub is_default: bool, +} + +/// Enumerate available audio input and output devices. +/// On mobile platforms (iOS, Android) returns an empty list because +/// device selection is managed by the OS audio session. +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] +pub fn list_audio_devices() -> AudioDeviceList { + use cpal::traits::HostTrait; + let mut list = AudioDeviceList { + input_devices: Vec::new(), + output_devices: Vec::new(), + }; + let Ok(host) = cpal::default_host() else { + return list; + }; + let default_in = host.default_input_device(); + let default_out = host.default_output_device(); + if let Ok(devices) = host.input_devices() { + for d in devices { + let name = d + .description() + .map(|n| n.name().to_owned()) + .unwrap_or_default(); + if !name.is_empty() { + let is_default = default_in + .as_ref() + .is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str())); + list.input_devices + .push(AudioDeviceInfo { name, is_default }); + } + } + } + if let Ok(devices) = host.output_devices() { + for d in devices { + let name = d + .description() + .map(|n| n.name().to_owned()) + .unwrap_or_default(); + if !name.is_empty() { + let is_default = default_out + .as_ref() + .is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str())); + list.output_devices + .push(AudioDeviceInfo { name, is_default }); + } + } + } + list +} + +#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] +/// Enumerate available audio input and output devices. +pub fn list_audio_devices() -> AudioDeviceList { + AudioDeviceList { + input_devices: Vec::new(), + output_devices: Vec::new(), + } +} + /// Engine configuration. #[derive(Clone)] pub struct AudioEngineConfig { @@ -91,6 +172,12 @@ pub struct AudioEngineConfig { /// is rejected on Android because the P0 path intentionally has /// no generic mobile-audio fallback. pub mobile_voice_preset: bool, + /// Optional input device name override. When `None`, the system + /// default input device is used. Set to a device name from + /// [`list_audio_devices`] to pin a specific microphone. + pub input_device_name: Option, + /// Optional output device name override. + pub output_device_name: Option, /// Optional selector used by P1 VoiceActivity to publish VAD state. #[doc(hidden)] pub voice_activity_selector: Option>, @@ -103,6 +190,8 @@ impl std::fmt::Debug for AudioEngineConfig { .field("ptt_initial", &self.ptt_initial) .field("effects", &self.effects) .field("mobile_voice_preset", &self.mobile_voice_preset) + .field("input_device_name", &self.input_device_name) + .field("output_device_name", &self.output_device_name) .field( "voice_activity_selector", &self.voice_activity_selector.as_ref().map(|_| "present"), @@ -118,6 +207,8 @@ impl Default for AudioEngineConfig { ptt_initial: false, effects: crate::AudioEffects::default(), mobile_voice_preset: true, + input_device_name: None, + output_device_name: None, voice_activity_selector: None, } } @@ -145,7 +236,6 @@ pub struct AudioEngine { output_muted: Arc, audio_processing_config: Arc>, audio_processing_stats: Arc, - #[cfg(any(target_os = "ios", target_os = "macos"))] audio_handler: Arc>>, #[cfg(any(target_os = "ios", target_os = "macos"))] voice_out_tx: mpsc::Sender, @@ -204,23 +294,6 @@ pub struct AudioEngine { /// denied microphone permission), PTT becomes a no-op and /// `frames_sent` stays at 0. capture_active: bool, - /// Missed-key-up watchdog (SDD-092). Dropping aborts the task. - /// The watchdog is independent of the PTT input backend — it - /// observes the gate directly. The platform input backend is - /// owned by `chanora_core::ptt::PttController` (SDD-088), not - /// by the engine. - /// - /// In the post-rc.7 architecture this field is unused: the - /// missed-key-up watchdog now lives on the session and - /// subscribes to `TransmitModeSelector::subscribe_ptt_held` - /// rather than the gate. Watching the gate caused the watchdog - /// to fire in Continuous mode (where the gate is intentionally - /// pinned to `true`) which clearing surfaced as the bug - /// "Continuous transmission disabled after some time". The - /// field stays here as `None` for now to preserve the existing - /// engine-stop teardown flow; a follow-up commit can remove it - /// entirely. - ptt_watchdog: Option, } // cpal::Stream is not Send. We keep the engine pinned to the thread @@ -241,7 +314,6 @@ unsafe impl Send for AudioEngine {} unsafe impl Sync for AudioEngine {} #[cfg(any(target_os = "ios", target_os = "macos"))] -#[allow(dead_code)] enum IosVoiceBackend { Vpio(crate::ios_voice_unit::IosVoiceUnit), #[cfg(target_os = "ios")] @@ -260,7 +332,9 @@ impl IosVoiceBackend { } #[cfg(target_os = "macos")] { - Ok(()) + match self { + Self::Vpio(_unit) => Ok(()), + } } } @@ -274,41 +348,23 @@ impl IosVoiceBackend { } #[cfg(target_os = "macos")] { - Ok(()) + match self { + Self::Vpio(_unit) => Ok(()), + } } } } #[cfg(any(target_os = "ios", target_os = "macos"))] -#[allow(clippy::too_many_arguments)] fn open_ios_voice_backend( - handler: Arc>>, - output_gain: Arc, - output_muted: Arc, - voice_out_tx: mpsc::Sender, - transmit_flag_for_capture: Arc, - frames_sent: Arc, - mic_gain: f32, - voice_activity_selector: Option>, - audio_processing_config: Arc>, - audio_processing_stats: Arc, + params: crate::mobile_voice_backend::VoiceAudioParams, ) -> Result { - let _cfg = audio_processing_config.lock().unwrap().clone(); + let _cfg = params.audio_processing_config.lock().unwrap().clone(); #[cfg(target_os = "ios")] { if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental { - match crate::ios_raw_unit::IosRawUnit::start( - handler.clone(), - output_gain.clone(), - output_muted.clone(), - voice_out_tx.clone(), - transmit_flag_for_capture.clone(), - frames_sent.clone(), - mic_gain, - voice_activity_selector.clone(), - audio_processing_config.clone(), - audio_processing_stats.clone(), - ) { + let raw_params = params.clone(); + match crate::ios_raw_unit::IosRawUnit::start(raw_params) { Ok(unit) => { info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected"); return Ok(IosVoiceBackend::Raw(unit)); @@ -324,18 +380,7 @@ fn open_ios_voice_backend( } } - let unit = crate::ios_voice_unit::IosVoiceUnit::start( - handler, - output_gain, - output_muted, - voice_out_tx, - transmit_flag_for_capture, - frames_sent, - mic_gain, - voice_activity_selector, - audio_processing_config, - audio_processing_stats, - )?; + let unit = crate::ios_voice_unit::IosVoiceUnit::start(params)?; Ok(IosVoiceBackend::Vpio(unit)) } @@ -361,23 +406,40 @@ impl AudioEngine { voice_in_rx: mpsc::Receiver, transmit_gate: crate::ptt::AudioTransmitGate, ) -> Result { - #[allow(clippy::needless_return)] - // Apple platforms route to a separate backend (VoiceProcessingIO - // via coreaudio-rs) because cpal does not expose the native - // voice-processing AudioUnit controls Chanora needs for VoIP. - // Windows and Linux stay on the cpal / SDL flow below. - #[cfg(any(target_os = "ios", target_os = "macos"))] - { - return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate); - } - #[cfg(target_os = "android")] - { - return Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate); - } - #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] - { - Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate) - } + Self::start_with_gate_platform(cfg, voice_out_tx, voice_in_rx, transmit_gate) + } + + // Apple platforms route to a separate backend (VoiceProcessingIO + // via coreaudio-rs) because cpal does not expose the native + // voice-processing AudioUnit controls Chanora needs for VoIP. + #[cfg(any(target_os = "ios", target_os = "macos"))] + fn start_with_gate_platform( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, + ) -> Result { + Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate) + } + + #[cfg(target_os = "android")] + fn start_with_gate_platform( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, + ) -> Result { + Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate) + } + + #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] + fn start_with_gate_platform( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, + ) -> Result { + Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate) } /// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output. @@ -398,12 +460,45 @@ impl AudioEngine { host_id = ?host.id(), "starting audio engine: cpal host selected" ); - let in_dev = host - .default_input_device() - .ok_or(AudioError::NoInputDevice)?; - let out_dev = host - .default_output_device() - .ok_or(AudioError::NoOutputDevice)?; + + /// Helper: find a device by name, falling back to default. + fn find_device( + host: &cpal::Host, + default_fn: fn(&cpal::Host) -> Option, + all_fn: fn(&cpal::Host) -> Result, + prefer: Option<&str>, + ) -> Option { + if let Some(name) = prefer { + if let Ok(devices) = all_fn(host) { + for d in devices { + let dn = d + .description() + .map(|n| n.name().to_owned()) + .unwrap_or_default(); + if dn == name { + return Some(d); + } + } + } + } + default_fn(host) + } + + let in_dev = find_device( + &host, + cpal::Host::default_input_device, + cpal::Host::input_devices, + cfg.input_device_name.as_deref(), + ) + .ok_or(AudioError::NoInputDevice)?; + + let out_dev = find_device( + &host, + cpal::Host::default_output_device, + cpal::Host::output_devices, + cfg.output_device_name.as_deref(), + ) + .ok_or(AudioError::NoOutputDevice)?; info!( target: "chanora_audio", @@ -611,19 +706,6 @@ impl AudioEngine { } }); - // Select and arm the desktop PTT backend is no longer the - // engine's job (SDD-088). The PTT controller lives in - // `chanora_core::ptt::PttController`; the engine is - // responsible only for the cpal streams and the - // missed-key-up watchdog (SDD-092). - - // The engine no longer spawns a watchdog against the - // gate (see comment on the `ptt_watchdog` field for the - // rationale). The session spawns the watchdog against the - // selector's `ptt_held` signal instead, so it never fires - // in Continuous mode. - let ptt_watchdog: Option = None; - Ok(Self { transmit_gate, frames_sent, @@ -632,11 +714,11 @@ impl AudioEngine { output_muted, audio_processing_config, audio_processing_stats, + audio_handler, _input_stream: Mutex::new(input_stream), _output_stream: Mutex::new(Some(output_stream)), shutdown_tx: Some(shutdown_tx), capture_active, - ptt_watchdog, }) } @@ -716,7 +798,7 @@ impl AudioEngine { effects: cfg.effects, ..Default::default() }; - let params = crate::android_voice_unit::VoiceAudioParams { + let params = crate::mobile_voice_backend::VoiceAudioParams { voice_out_tx, transmit_active: transmit_flag_for_capture, frames_sent: frames_sent.clone(), @@ -841,8 +923,6 @@ impl AudioEngine { } }); - let ptt_watchdog: Option = None; - Ok(Self { transmit_gate, frames_sent, @@ -851,11 +931,11 @@ impl AudioEngine { output_muted, audio_processing_config, audio_processing_stats, + audio_handler, _android_voice_unit: Mutex::new(Some(android_voice_unit)), audio_mode_stack: Mutex::new(audio_mode_stack), shutdown_tx: Some(shutdown_tx), capture_active, - ptt_watchdog, }) } @@ -907,18 +987,19 @@ impl AudioEngine { // Construct the live iOS voice backend. Platform VPIO stays // the default shipping path; Sonora/RemoteIO remains opt-in. - let ios_voice_backend = open_ios_voice_backend( - audio_handler.clone(), - output_gain.clone(), - output_muted.clone(), - voice_out_tx_for_backend, - transmit_flag_for_capture, - frames_sent.clone(), - cfg.mic_gain, - cfg.voice_activity_selector.clone(), - audio_processing_config.clone(), - audio_processing_stats.clone(), - )?; + let ios_voice_backend = + open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams { + handler: audio_handler.clone(), + output_gain: output_gain.clone(), + output_muted: output_muted.clone(), + voice_out_tx: voice_out_tx_for_backend, + transmit_active: transmit_flag_for_capture, + frames_sent: frames_sent.clone(), + mic_gain: cfg.mic_gain, + voice_activity_selector: cfg.voice_activity_selector.clone(), + audio_processing_config: audio_processing_config.clone(), + audio_processing_stats: audio_processing_stats.clone(), + })?; // Capture is always considered active on iOS — VPIO's // input element is wired up by the AudioUnit itself, no @@ -959,8 +1040,6 @@ impl AudioEngine { } }); - let ptt_watchdog: Option = None; - Ok(Self { transmit_gate, frames_sent, @@ -976,7 +1055,6 @@ impl AudioEngine { _ios_voice_backend: Mutex::new(Some(ios_voice_backend)), shutdown_tx: Some(shutdown_tx), capture_active, - ptt_watchdog, }) } @@ -985,12 +1063,6 @@ impl AudioEngine { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } - // The platform PTT backend is no longer owned by the - // engine (SDD-088); its lifecycle is managed by - // `chanora_core::ptt::PttController`. The engine only - // needs to abort its watchdog and drop the audio streams. - // Aborting the watchdog cancels its tokio task. - self.ptt_watchdog.take(); // Drop the streams, which stops their callback threads. // Each platform has a slightly different backend; the // common contract is that dropping the wrapper stops @@ -1088,18 +1160,18 @@ impl AudioEngine { pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> { #[cfg(any(target_os = "ios", target_os = "macos"))] { - let backend = open_ios_voice_backend( - self.audio_handler.clone(), - self.output_gain.clone(), - self.output_muted.clone(), - self.voice_out_tx.clone(), - self.transmit_gate.flag_arc(), - self.frames_sent.clone(), - self.mic_gain, - self.voice_activity_selector.clone(), - self.audio_processing_config.clone(), - self.audio_processing_stats.clone(), - )?; + let backend = open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams { + handler: self.audio_handler.clone(), + output_gain: self.output_gain.clone(), + output_muted: self.output_muted.clone(), + voice_out_tx: self.voice_out_tx.clone(), + transmit_active: self.transmit_gate.flag_arc(), + frames_sent: self.frames_sent.clone(), + mic_gain: self.mic_gain, + voice_activity_selector: self.voice_activity_selector.clone(), + audio_processing_config: self.audio_processing_config.clone(), + audio_processing_stats: self.audio_processing_stats.clone(), + })?; let mut guard = self._ios_voice_backend.lock().unwrap(); *guard = Some(backend); Ok(()) @@ -1166,33 +1238,6 @@ impl AudioEngine { &self.transmit_gate } - /// Privacy-safe descriptor of the engine's PTT view. The - /// platform backend lives in `chanora_core::ptt::PttController` - /// (SDD-088); the engine itself no longer owns it. This getter - /// always returns the universal Focused fallback descriptor - /// and is retained only for legacy callers that constructed - /// engines directly without a controller (tests, headless - /// diagnostics). - pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor { - crate::ptt::PttBackendDescriptor::focused() - } - - /// Legacy alias for [`Self::set_transmit_active`]. Retained so - /// the existing bridge `set_ptt` command and the existing - /// Flutter UI continue to compile during the v0.9.3 PTT - /// migration (SRS-201 splits the conceptual `ptt` flag into - /// `transmit_active` / `capture_active`). - #[doc(hidden)] - pub fn set_ptt(&self, active: bool) { - self.set_transmit_active(active); - } - - /// Legacy alias for [`Self::transmit_active`]. - #[doc(hidden)] - pub fn ptt(&self) -> bool { - self.transmit_active() - } - /// True if the capture stream opened. When false, the engine /// runs in playback-only mode and the transmit gate is a /// no-op (no frames will ever be encoded). @@ -1268,6 +1313,29 @@ impl AudioEngine { pub fn output_gain(&self) -> f32 { f32::from_bits(self.output_gain.load(Ordering::Relaxed)) } + + /// Set per-client output volume (SRS-075). `1.0` is unity, `0.0` + /// mutes. Values above `1.0` amplify and may clip. Clamped to + /// `0.0..4.0`. + pub fn set_client_volume(&self, client_id: u64, volume: f32) { + let clamped = volume.clamp(0.0, 4.0); + match self.audio_handler.lock() { + Ok(mut h) => { + if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) { + q.volume = clamped; + } + } + Err(e) => { + tracing::warn!( + target: "chanora_audio", + client_id, + volume = clamped, + error = %e, + "set_client_volume: audio_handler lock poisoned — volume not applied" + ); + } + } + } } impl Drop for AudioEngine { diff --git a/crates/chanora_audio/src/ios_raw_unit.rs b/crates/chanora_audio/src/ios_raw_unit.rs index bf57a3b..f084544 100644 --- a/crates/chanora_audio/src/ios_raw_unit.rs +++ b/crates/chanora_audio/src/ios_raw_unit.rs @@ -44,9 +44,8 @@ mod inner { use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat}; use tokio::sync::mpsc; use tracing::{info, warn}; - use tsclientlib::audio::AudioHandler; - use crate::engine::SessionAudioId; + use crate::mobile_voice_backend::VoiceAudioParams; use crate::processor::AudioProcessor; use crate::AudioError; use chanora_protocol::OutPacket; @@ -121,6 +120,7 @@ mod inner { ten_vad_worker: Option, current_vad_backend: crate::VadBackend, silero_model_epoch: u64, + ten_model_epoch: u64, capture_frame_seq: u64, vad_state: crate::voice_activity::VoiceActivityStateMachine, /// Processing config — retained for route-change reloads. @@ -136,18 +136,12 @@ mod inner { impl RawCaptureState { fn new( - voice_out_tx: mpsc::Sender, - transmit_active: Arc, - output_muted: Arc, - frames_sent: Arc, - mic_gain: f32, - voice_activity_selector: Option>, - audio_processing_config: Arc>, - audio_processing_stats: Arc, + params: &VoiceAudioParams, render_reference: Arc, ) -> Result { let encoder = crate::opus_voice::new_voip_encoder("ios raw")?; - let webrtc_apm_config = audio_processing_config + let webrtc_apm_config = params + .audio_processing_config .lock() .map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg)) .unwrap_or_default(); @@ -155,24 +149,25 @@ mod inner { encoder, pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME], - voice_out_tx, - transmit_active, - output_muted, - frames_sent, - mic_gain, - voice_activity_selector, + voice_out_tx: params.voice_out_tx.clone(), + transmit_active: params.transmit_active.clone(), + output_muted: params.output_muted.clone(), + frames_sent: params.frames_sent.clone(), + mic_gain: params.mic_gain, + voice_activity_selector: params.voice_activity_selector.clone(), vad_detector: crate::vad::WebRtcFallbackVad::default(), silero_vad_worker: None, ten_vad_worker: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), + ten_model_epoch: crate::vad::ten_model_epoch(), capture_frame_seq: 0, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), - audio_processing_config, + audio_processing_config: params.audio_processing_config.clone(), webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config( webrtc_apm_config, )?, - audio_processing_stats, + audio_processing_stats: params.audio_processing_stats.clone(), render_reference, pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms_len: 0, @@ -184,11 +179,20 @@ mod inner { fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) { if self.fallback_warned_backend != Some(failed_backend) { self.fallback_warned_backend = Some(failed_backend); - tracing::warn!( - target: "chanora_audio", - backend = failed_backend.as_str(), - "VAD backend unavailable; using WebRTC fallback for runtime detection" - ); + if self.capture_frame_seq < 128 { + tracing::info!( + target: "chanora_audio", + backend = failed_backend.as_str(), + seq = self.capture_frame_seq, + "VAD backend warming up; using WebRTC fallback" + ); + } else { + tracing::warn!( + target: "chanora_audio", + backend = failed_backend.as_str(), + "VAD backend unavailable; using WebRTC fallback for runtime detection" + ); + } } } @@ -286,11 +290,15 @@ mod inner { // Switch VAD backend when config changes. let silero_epoch = crate::vad::silero_model_epoch(); + let ten_epoch = crate::vad::ten_model_epoch(); let silero_changed = vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch; - if vad_backend != self.current_vad_backend || silero_changed { + let ten_changed = + vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch; + if vad_backend != self.current_vad_backend || silero_changed || ten_changed { self.current_vad_backend = vad_backend; self.silero_model_epoch = silero_epoch; + self.ten_model_epoch = ten_epoch; self.fallback_warned_backend = None; match vad_backend { crate::VadBackend::SileroOnnx => { @@ -335,12 +343,17 @@ mod inner { } else if vad_backend == crate::VadBackend::SileroOnnx { if let Some(worker) = self.silero_vad_worker.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); - if enqueued && !worker.is_stale(capture_seq) { + if !worker.is_stale(capture_seq) { let p = worker.latest_probability(); crate::vad::VadOutput { probability: p, speech: p >= 0.5, } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); @@ -357,12 +370,17 @@ mod inner { } else if vad_backend == crate::VadBackend::TenVad { if let Some(worker) = self.ten_vad_worker.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); - if enqueued && !worker.is_stale(capture_seq) { + if !worker.is_stale(capture_seq) { let p = worker.latest_probability(); crate::vad::VadOutput { probability: p, speech: p >= 0.5, } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); @@ -422,22 +440,10 @@ mod inner { impl IosRawUnit { /// Open a RemoteIO AudioUnit, install render + input callbacks, start. - #[allow(clippy::too_many_arguments)] - pub fn start( - handler: Arc>>, - output_gain: Arc, - output_muted: Arc, - voice_out_tx: mpsc::Sender, - transmit_active: Arc, - frames_sent: Arc, - mic_gain: f32, - voice_activity_selector: Option>, - audio_processing_config: Arc>, - audio_processing_stats: Arc, - ) -> Result { + pub(crate) fn start(params: VoiceAudioParams) -> Result { // INV_010: reject if config requests VPIO (that's IosVoiceUnit's job). { - let cfg = audio_processing_config.lock().unwrap(); + let cfg = params.audio_processing_config.lock().unwrap(); if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing { return Err(AudioError::InvalidAudioProcessingConfig( "IosRawUnit requires raw WebRTC APM mode".to_string(), @@ -470,17 +476,7 @@ mod inner { let render_ref_buf = RenderReferenceBuffer::new(); let render_ref_for_capture = render_ref_buf.clone(); - let mut capture_state = RawCaptureState::new( - voice_out_tx, - transmit_active, - output_muted.clone(), - frames_sent, - mic_gain, - voice_activity_selector, - audio_processing_config, - audio_processing_stats.clone(), - render_ref_for_capture, - )?; + let mut capture_state = RawCaptureState::new(¶ms, render_ref_for_capture)?; unit.set_input_callback(move |args: render_callback::Args>| { capture_state.ingest_i16(args.data.buffer); @@ -489,7 +485,10 @@ mod inner { .map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?; let mut scratch: Vec = Vec::with_capacity(2048); - let stats_render = audio_processing_stats.clone(); + let handler = params.handler.clone(); + let output_gain = params.output_gain.clone(); + let output_muted = params.output_muted.clone(); + let stats_render = params.audio_processing_stats.clone(); unit.set_render_callback(move |args: render_callback::Args>| { let out = args.data.buffer; diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index 105f86c..d1dc79d 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -81,9 +81,8 @@ use coreaudio::audio_unit::IOType; use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat}; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; -use tsclientlib::audio::AudioHandler; -use crate::engine::SessionAudioId; +use crate::mobile_voice_backend::VoiceAudioParams; use crate::AudioError; use chanora_protocol::OutPacket; @@ -152,6 +151,8 @@ struct IosCaptureState { current_vad_backend: crate::VadBackend, /// Last observed configured Silero model epoch. silero_model_epoch: u64, + /// Last observed configured TEN model epoch. + ten_model_epoch: u64, fallback_warned_backend: Option, vad_state: crate::voice_activity::VoiceActivityStateMachine, audio_processing_config: Arc>, @@ -172,16 +173,8 @@ impl IosCaptureState { /// Encoder configuration is the same as cpal-side /// `try_open_capture` (engine.rs) so audio quality is platform- /// neutral. - #[allow(clippy::too_many_arguments)] fn new( - voice_out_tx: mpsc::Sender, - transmit_active: Arc, - output_muted: Arc, - frames_sent: Arc, - mic_gain: f32, - voice_activity_selector: Option>, - audio_processing_config: Arc>, - audio_processing_stats: Arc, + params: &VoiceAudioParams, wav_recorder: Arc>>>, ) -> Result { let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?; @@ -190,22 +183,23 @@ impl IosCaptureState { encoder, pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME], - voice_out_tx, - transmit_active, - output_muted, - frames_sent, - mic_gain, - voice_activity_selector, + voice_out_tx: params.voice_out_tx.clone(), + transmit_active: params.transmit_active.clone(), + output_muted: params.output_muted.clone(), + frames_sent: params.frames_sent.clone(), + mic_gain: params.mic_gain, + voice_activity_selector: params.voice_activity_selector.clone(), vad_detector: crate::vad::WebRtcFallbackVad::default(), silero_vad_worker: None, ten_vad: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), + ten_model_epoch: crate::vad::ten_model_epoch(), fallback_warned_backend: None, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), - audio_processing_config, + audio_processing_config: params.audio_processing_config.clone(), sonora_processor: crate::processor::SonoraProcessor::new(), - audio_processing_stats, + audio_processing_stats: params.audio_processing_stats.clone(), pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms_len: 0, pre_roll_buf: [[0_i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES], @@ -222,11 +216,20 @@ impl IosCaptureState { return; } self.fallback_warned_backend = Some(failed_backend); - tracing::warn!( - target: "chanora_audio", - backend = failed_backend.as_str(), - "VAD backend unavailable; using WebRTC fallback for runtime detection" - ); + if self.capture_frame_seq < 128 { + tracing::info!( + target: "chanora_audio", + backend = failed_backend.as_str(), + seq = self.capture_frame_seq, + "VAD backend warming up; using WebRTC fallback" + ); + } else { + tracing::warn!( + target: "chanora_audio", + backend = failed_backend.as_str(), + "VAD backend unavailable; using WebRTC fallback for runtime detection" + ); + } } /// Consume the i16 mono buffer delivered by VPIO, accumulate @@ -360,8 +363,11 @@ impl IosCaptureState { // Switch VAD backend when the config changes. let silero_model_epoch = crate::vad::silero_model_epoch(); + let ten_model_epoch = crate::vad::ten_model_epoch(); let silero_model_changed = vad_backend == crate::VadBackend::SileroOnnx && silero_model_epoch != self.silero_model_epoch; + let ten_model_changed = + vad_backend == crate::VadBackend::TenVad && ten_model_epoch != self.ten_model_epoch; if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() { if debug_wav_dump_enabled { @@ -376,10 +382,11 @@ impl IosCaptureState { } } - if vad_backend != self.current_vad_backend || silero_model_changed { + if vad_backend != self.current_vad_backend || silero_model_changed || ten_model_changed { self.current_vad_backend = vad_backend; self.fallback_warned_backend = None; self.silero_model_epoch = silero_model_epoch; + self.ten_model_epoch = ten_model_epoch; match vad_backend { crate::VadBackend::SileroOnnx => { // Attempt to load Silero model from the well-known @@ -467,12 +474,17 @@ impl IosCaptureState { } else if vad_backend == crate::VadBackend::SileroOnnx { if let Some(worker) = self.silero_vad_worker.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); - if enqueued && !worker.is_stale(capture_seq) { + if !worker.is_stale(capture_seq) { let probability = worker.latest_probability(); crate::vad::VadOutput { probability, speech: probability >= 0.5, } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } } else { used_fallback_vad = true; self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx); @@ -486,12 +498,17 @@ impl IosCaptureState { } else if vad_backend == crate::VadBackend::TenVad { if let Some(worker) = self.ten_vad.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); - if enqueued && !worker.is_stale(capture_seq) { + if !worker.is_stale(capture_seq) { let probability = worker.latest_probability(); crate::vad::VadOutput { probability, speech: probability >= 0.5, } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } } else { used_fallback_vad = true; self.mark_vad_fallback_active(crate::VadBackend::TenVad); @@ -617,19 +634,7 @@ impl IosVoiceUnit { /// /// Capture wiring landed in commit 3; playback wiring landed /// in commit 4. Route-change observation is commit 5. - #[allow(clippy::too_many_arguments)] - pub fn start( - handler: Arc>>, - output_gain: Arc, - output_muted: Arc, - voice_out_tx: mpsc::Sender, - transmit_active: Arc, - frames_sent: Arc, - mic_gain: f32, - voice_activity_selector: Option>, - audio_processing_config: Arc>, - audio_processing_stats: Arc, - ) -> Result { + pub(crate) fn start(params: VoiceAudioParams) -> Result { // Construct the VoiceProcessingIO AudioUnit. cpal exposes // `Default::default()` which on iOS picks the inferior // RemoteIO unit; we explicitly pick VPIO. `coreaudio-rs` @@ -721,7 +726,7 @@ impl IosVoiceUnit { // because the input callback is the sole writer/reader on // the audio thread. let wav_recorder = Arc::new(Mutex::new({ - let cfg = audio_processing_config.lock().unwrap().clone(); + let cfg = params.audio_processing_config.lock().unwrap().clone(); if cfg.debug_wav_dump_enabled { Some(crate::debug_wav::WavDebugRecorder::start( cfg.route, @@ -731,17 +736,7 @@ impl IosVoiceUnit { None } })); - let mut capture_state = IosCaptureState::new( - voice_out_tx, - transmit_active, - output_muted.clone(), - frames_sent, - mic_gain, - voice_activity_selector, - audio_processing_config, - audio_processing_stats.clone(), - wav_recorder.clone(), - )?; + let mut capture_state = IosCaptureState::new(¶ms, wav_recorder.clone())?; unit.set_input_callback(move |args: render_callback::Args>| { // VPIO with our pinned stream format delivers @@ -806,9 +801,10 @@ impl IosVoiceUnit { // buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16 // converted at the boundary. let mut scratch_stereo: Vec = Vec::with_capacity(2048); - let handler_for_render = handler.clone(); - let output_gain_for_render = output_gain.clone(); - let output_muted_for_render = output_muted.clone(); + let handler_for_render = params.handler.clone(); + let output_gain_for_render = params.output_gain.clone(); + let output_muted_for_render = params.output_muted.clone(); + let audio_processing_stats_for_render = params.audio_processing_stats.clone(); let wav_recorder_for_render = wav_recorder.clone(); // Diagnostic counters (sampled every 100 callbacks ~= 2 s). let mut cb_count: u64 = 0; @@ -841,7 +837,7 @@ impl IosVoiceUnit { let _removed = h.fill_buffer(&mut scratch_stereo[..needed]); } Err(std::sync::TryLockError::WouldBlock) => { - audio_processing_stats.increment_callback_xrun(); + audio_processing_stats_for_render.increment_callback_xrun(); // scratch_stereo is already zeroed above. } Err(std::sync::TryLockError::Poisoned(e)) => { @@ -859,9 +855,9 @@ impl IosVoiceUnit { muted, ); if mix_stats.clipped_samples > 0 { - audio_processing_stats.add_clipped_samples(mix_stats.clipped_samples); + audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples); } - audio_processing_stats.update_render( + audio_processing_stats_for_render.update_render( crate::frame::dbfs(&scratch_stereo[..needed]), num_frames as u32, ); @@ -895,7 +891,7 @@ impl IosVoiceUnit { if mix_stats.peak_i16 > 0 { callbacks_with_audio = callbacks_with_audio.wrapping_add(1); } else { - audio_processing_stats.increment_output_underrun(); + audio_processing_stats_for_render.increment_output_underrun(); callbacks_with_silence = callbacks_with_silence.wrapping_add(1); } diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index 929f183..f49c4e4 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -62,7 +62,8 @@ pub use audio_processing::{ AudioBackend, AudioProcessingConfig, AudioProcessingStats, AudioRoute, EffectOwner, IosVoiceProcessingMode, SharedAudioProcessingStats, VadBackend, }; -pub use engine::{AudioEngine, AudioEngineConfig}; +pub use engine::list_audio_devices; +pub use engine::{AudioDeviceInfo, AudioDeviceList, AudioEngine, AudioEngineConfig}; // SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion // bench harness under `crates/chanora_audio/benches/` can construct a diff --git a/crates/chanora_audio/src/mobile_voice_backend.rs b/crates/chanora_audio/src/mobile_voice_backend.rs index a132795..303c80f 100644 --- a/crates/chanora_audio/src/mobile_voice_backend.rs +++ b/crates/chanora_audio/src/mobile_voice_backend.rs @@ -17,13 +17,16 @@ // so the engine can hold a single `Box` // across iOS and Android. -#![allow(dead_code)] - use std::fmt; +use std::sync::atomic::{AtomicBool, AtomicU32}; +use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; +use tsclientlib::audio::AudioHandler; +use crate::engine::SessionAudioId; use crate::AudioEffects; +use chanora_protocol::OutPacket; /// Events the audio-callback thread or platform JNI listener can post /// to the tokio side of the engine (SDD-115). Audio callbacks MUST @@ -63,6 +66,36 @@ pub type BackendEventTx = mpsc::UnboundedSender; /// against the active capture session. pub type AudioSessionId = i32; +/// Engine-owned state shared with mobile voice audio callbacks. +#[derive(Clone)] +pub(crate) struct VoiceAudioParams { + /// Opus-encoded voice packets sent on this channel toward the + /// protocol layer. + pub voice_out_tx: mpsc::Sender, + /// PTT transmission gate — true when the user holds the PTT key. + pub transmit_active: Arc, + /// Counter incremented per encoded frame sent. + pub frames_sent: Arc, + /// Pre-encode amplitude scale (1.0 = unity). + pub mic_gain: f32, + /// AudioHandler that inbound decode+mix feeds into; the output + /// callback pulls mixed stereo f32 from it. + pub handler: Arc>>, + /// Master output gain (f32 bits stored in AtomicU32 for lock-free + /// cross-thread read from the realtime audio callback). + pub output_gain: Arc, + /// True = output silence regardless of incoming voice frames. + pub output_muted: Arc, + /// Optional TransmitModeSelector for VoiceActivity transmit mode. + /// The capture callback calls set_voice_activity_open on this when + /// VAD detects speech. None means VoiceActivity mode is disabled. + pub voice_activity_selector: Option>, + /// Shared audio-processing config (WebRTC APM flags, VAD backend). + pub audio_processing_config: Arc>, + /// Shared audio-processing statistics for diagnostics. + pub audio_processing_stats: Arc, +} + /// Achieved performance-mode reported by the platform after stream /// open (SDD-112 item 4). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -734,12 +767,18 @@ mod tests { /// open()/start()/stop()/close()). #[test] fn swe4_uv_047_backend_error_display() { - assert!(format!("{}", BackendError::OpenFailed("x".into())).contains("open failed")); - assert!( - format!("{}", BackendError::LifecycleFailed("y".into())).contains("lifecycle failed") - ); - assert!(format!("{}", BackendError::ErrorDisconnected).contains("disconnected")); - assert!(format!("{}", BackendError::Platform("z".into())).contains("platform")); + assert!(BackendError::OpenFailed("x".into()) + .to_string() + .contains("open failed")); + assert!(BackendError::LifecycleFailed("y".into()) + .to_string() + .contains("lifecycle failed")); + assert!(BackendError::ErrorDisconnected + .to_string() + .contains("disconnected")); + assert!(BackendError::Platform("z".into()) + .to_string() + .contains("platform")); } /// SWE4-UV-047: achieved enums Display stably (used in @@ -748,15 +787,15 @@ mod tests { #[test] fn swe4_uv_047_achieved_enum_display_is_stable() { assert_eq!( - format!("{}", AchievedPerformanceMode::LowLatency), + AchievedPerformanceMode::LowLatency.to_string(), "LowLatency" ); assert_eq!( - format!("{}", AchievedPerformanceMode::PowerSaving), + AchievedPerformanceMode::PowerSaving.to_string(), "PowerSaving" ); - assert_eq!(format!("{}", AchievedPerformanceMode::None), "None"); - assert_eq!(format!("{}", AchievedSharingMode::Exclusive), "Exclusive"); - assert_eq!(format!("{}", AchievedSharingMode::Shared), "Shared"); + assert_eq!(AchievedPerformanceMode::None.to_string(), "None"); + assert_eq!(AchievedSharingMode::Exclusive.to_string(), "Exclusive"); + assert_eq!(AchievedSharingMode::Shared.to_string(), "Shared"); } } diff --git a/crates/chanora_audio/src/sdl_output.rs b/crates/chanora_audio/src/sdl_output.rs index 00652bc..13fa98e 100644 --- a/crates/chanora_audio/src/sdl_output.rs +++ b/crates/chanora_audio/src/sdl_output.rs @@ -136,15 +136,6 @@ impl SdlOutput { _subsystem: subsystem, }) } - - /// Pause the SDL device. Used by the engine on hard mute / - /// shutdown if we ever want to stop the callback firing while - /// keeping the device handle alive. Not currently invoked — - /// the engine drops `SdlOutput` entirely on stop. - #[allow(dead_code)] - pub fn pause(&self) { - self.device.pause(); - } } impl Drop for SdlOutput { diff --git a/crates/chanora_audio/src/vad/silero_onnx.rs b/crates/chanora_audio/src/vad/silero_onnx.rs index b8a6a41..7fe550b 100644 --- a/crates/chanora_audio/src/vad/silero_onnx.rs +++ b/crates/chanora_audio/src/vad/silero_onnx.rs @@ -76,6 +76,8 @@ pub struct SileroOnnxVad { enum SileroInner { Onnx(OnnxSession), + #[cfg(test)] + Stub, } struct OnnxSession { @@ -179,9 +181,13 @@ impl SileroOnnxVad { use ort::value::Value; use tracing::error; - let SileroInner::Onnx(ref mut inner) = self.inner else { - return self.last_probability; + #[cfg(test)] + let inner = match self.inner { + SileroInner::Onnx(ref mut inner) => inner, + SileroInner::Stub => return self.last_probability, }; + #[cfg(not(test))] + let SileroInner::Onnx(ref mut inner) = self.inner; debug_assert_eq!(audio_frame.len(), SILERO_FRAME_16K); @@ -259,17 +265,6 @@ impl SileroOnnxVad { } } -#[cfg(target_os = "macos")] -pub(crate) fn bundled_onnxruntime_path_for_vad() -> Option { - let exe = std::env::current_exe().ok()?; - let app_dir = exe.parent()?; - let framework = app_dir - .join("Frameworks") - .join("onnxruntime.framework") - .join("onnxruntime"); - framework.exists().then_some(framework) -} - impl VoiceActivityDetector for SileroOnnxVad { /// Accept one 10 ms **16 kHz** f32 mono frame (160 samples). /// @@ -333,7 +328,7 @@ impl SileroOnnxVadWorker { let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits())); let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX)); let alive = Arc::new(AtomicBool::new(true)); - let (tx, rx) = std::sync::mpsc::sync_channel::(32); + let (tx, rx) = std::sync::mpsc::sync_channel::(64); let latest_probability_for_thread = latest_probability.clone(); let latest_processed_seq_for_thread = latest_processed_seq.clone(); let alive_for_thread = alive.clone(); diff --git a/crates/chanora_audio/src/vad/ten_onnx.rs b/crates/chanora_audio/src/vad/ten_onnx.rs index 59c39ca..b63a778 100644 --- a/crates/chanora_audio/src/vad/ten_onnx.rs +++ b/crates/chanora_audio/src/vad/ten_onnx.rs @@ -8,6 +8,8 @@ use crate::frame::f32_to_i16; +use rustfft::{num_complex::Complex32, FftPlanner}; + use super::resampler::{Downsampler48to16, INPUT_FRAME_10MS}; use super::{VadOutput, VoiceActivityDetector}; @@ -49,6 +51,8 @@ pub struct TenOnnxVad { feature_stack: [[f32; FEATURE_LEN]; CONTEXT], states: [[f32; HIDDEN]; 4], mel_filters: Vec<[f32; N_BINS]>, + fft: std::sync::Arc>, + fft_buffer: Vec, last_probability: f32, last_speech: bool, } @@ -75,6 +79,8 @@ impl TenOnnxVad { return None; } }; + let mut fft_planner = FftPlanner::::new(); + let fft = fft_planner.plan_fft_forward(FFT_SIZE); tracing::info!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model loaded"); Some(Self { session, @@ -84,6 +90,8 @@ impl TenOnnxVad { feature_stack: [[0.0; FEATURE_LEN]; CONTEXT], states: [[0.0; HIDDEN]; 4], mel_filters: build_mel_filters(), + fft, + fft_buffer: vec![Complex32::ZERO; FFT_SIZE], last_probability: 0.0, last_speech: false, }) @@ -104,7 +112,12 @@ impl TenOnnxVad { self.sample_fifo.drain(..excess); } - let feature = compute_feature(&self.mel_filters, &frame); + let feature = compute_feature( + &self.mel_filters, + self.fft.as_ref(), + &mut self.fft_buffer, + &frame, + ); self.feature_stack.copy_within(1..CONTEXT, 0); self.feature_stack[CONTEXT - 1] = feature; self.run_onnx(); @@ -169,19 +182,13 @@ impl TenOnnxVad { } } -#[cfg(target_os = "macos")] -fn bundled_onnxruntime_path() -> Option { - let exe = std::env::current_exe().ok()?; - let app_dir = exe.parent()?; - let framework = app_dir - .join("Frameworks") - .join("onnxruntime.framework") - .join("onnxruntime"); - framework.exists().then_some(framework) -} - -fn compute_feature(mel_filters: &[[f32; N_BINS]], frame: &[f32]) -> [f32; FEATURE_LEN] { - let power = power_spectrum(frame); +fn compute_feature( + mel_filters: &[[f32; N_BINS]], + fft: &dyn rustfft::Fft, + fft_buffer: &mut [Complex32], + frame: &[f32], +) -> [f32; FEATURE_LEN] { + let power = power_spectrum(fft, fft_buffer, frame); let mut feature = [0.0; FEATURE_LEN]; for band in 0..MEL_BANDS { let energy = mel_filters[band] @@ -245,33 +252,43 @@ fn build_mel_filters() -> Vec<[f32; N_BINS]> { let left = bins[band]; let center = bins[band + 1].max(left + 1); let right = bins[band + 2].max(center + 1).min(N_BINS - 1); - for i in left..center.min(N_BINS) { - filters[band][i] = (i - left) as f32 / (center - left) as f32; + for (i, weight) in filters[band] + .iter_mut() + .enumerate() + .take(center.min(N_BINS)) + .skip(left) + { + *weight = (i - left) as f32 / (center - left) as f32; } - for i in center..=right { - filters[band][i] = (right - i) as f32 / (right - center).max(1) as f32; + for (i, weight) in filters[band] + .iter_mut() + .enumerate() + .take(right + 1) + .skip(center) + { + *weight = (right - i) as f32 / (right - center).max(1) as f32; } } filters } -fn power_spectrum(frame: &[f32]) -> [f32; N_BINS] { - let mut windowed = [0.0_f32; FFT_SIZE]; +fn power_spectrum( + fft: &dyn rustfft::Fft, + fft_buffer: &mut [Complex32], + frame: &[f32], +) -> [f32; N_BINS] { + debug_assert_eq!(fft_buffer.len(), FFT_SIZE); + fft_buffer.fill(Complex32::ZERO); for (idx, sample) in frame.iter().take(WINDOW_16K).enumerate() { let hann = 0.5 - 0.5 * (2.0 * std::f32::consts::PI * idx as f32 / WINDOW_16K as f32).cos(); - windowed[idx] = f32_to_i16(*sample) as f32 * hann; + fft_buffer[idx].re = f32_to_i16(*sample) as f32 * hann; } + fft.process(fft_buffer); + let mut out = [0.0_f32; N_BINS]; - for (k, dst) in out.iter_mut().enumerate() { - let mut re = 0.0_f32; - let mut im = 0.0_f32; - for (n, &x) in windowed.iter().enumerate() { - let phase = -2.0 * std::f32::consts::PI * k as f32 * n as f32 / FFT_SIZE as f32; - re += x * phase.cos(); - im += x * phase.sin(); - } - *dst = re * re + im * im; + for (dst, bin) in out.iter_mut().zip(fft_buffer.iter()) { + *dst = bin.norm_sqr(); } out } @@ -340,7 +357,7 @@ impl TenOnnxVadWorker { let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits())); let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX)); let alive = Arc::new(AtomicBool::new(true)); - let (tx, rx) = std::sync::mpsc::sync_channel::(32); + let (tx, rx) = std::sync::mpsc::sync_channel::(128); let prob_arc = latest_probability.clone(); let seq_arc = latest_processed_seq.clone(); let alive_arc = alive.clone(); @@ -425,8 +442,11 @@ mod tests { #[test] fn preprocessing_produces_finite_features() { let filters = build_mel_filters(); + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(FFT_SIZE); + let mut fft_buffer = vec![Complex32::ZERO; FFT_SIZE]; let frame = vec![0.0_f32; WINDOW_16K]; - let feature = compute_feature(&filters, &frame); + let feature = compute_feature(&filters, fft.as_ref(), &mut fft_buffer, &frame); assert!(feature.iter().all(|v| v.is_finite())); } } diff --git a/crates/chanora_bridge/Cargo.toml b/crates/chanora_bridge/Cargo.toml index 4de253c..1ee3b6b 100644 --- a/crates/chanora_bridge/Cargo.toml +++ b/crates/chanora_bridge/Cargo.toml @@ -17,7 +17,6 @@ crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] chanora_core = { path = "../../core/chanora_core" } -chanora_protocol = { path = "../chanora_protocol" } chanora_audio = { path = "../chanora_audio" } flutter_rust_bridge = "=2.12.0" thiserror.workspace = true diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 575e11a..5f25c6a 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -80,7 +80,10 @@ fn session() -> &'static chanora_core::ChanoraSession { fn log_sink() -> &'static chanora_core::InMemoryLogSink { static SINK: OnceLock = OnceLock::new(); SINK.get_or_init(|| { - chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy()) + chanora_core::InMemoryLogSink::new( + chanora_core::DEFAULT_LOG_CAPACITY, + chanora_core::Redactor::with_default_policy(), + ) }) } @@ -342,6 +345,9 @@ pub struct BridgeChannel { pub order: i64, /// True when the server marks the channel as password-protected. pub has_password: bool, + /// Talk power threshold required to speak in this channel. + /// None means no talk-power restriction. + pub needed_talk_power: Option, } /// Client as seen by Dart. @@ -361,6 +367,10 @@ pub struct BridgeClient { pub is_speaking: bool, /// True for TeamSpeak ServerQuery clients. pub is_server_query: bool, + /// Current talk power value assigned by the server. + pub talk_power: i32, + /// True when the server has granted talk power regardless of numeric value. + pub talk_power_granted: bool, } /// Server snapshot as seen by Dart. @@ -384,8 +394,8 @@ pub struct BridgeSnapshot { pub own_client_id: u64, } -impl From for BridgeSnapshot { - fn from(s: chanora_protocol::ServerSnapshot) -> Self { +impl From for BridgeSnapshot { + fn from(s: chanora_core::ServerSnapshot) -> Self { Self { server_name: s.server_name, welcome_message: s.welcome_message, @@ -400,6 +410,7 @@ impl From for BridgeSnapshot { name: c.name, order: c.order, has_password: c.has_password, + needed_talk_power: c.needed_talk_power, }) .collect(), clients: s @@ -413,6 +424,8 @@ impl From for BridgeSnapshot { output_muted: c.output_muted, is_speaking: c.is_speaking, is_server_query: c.is_server_query, + talk_power: c.talk_power, + talk_power_granted: c.talk_power_granted, }) .collect(), own_client_id: s.own_client_id, @@ -501,24 +514,6 @@ pub fn handle_route_change(route: BridgeAudioRoute) { } } -/// Handle iOS AVAudioSession media-services reset (legacy, no route arg). -/// -/// Called by the existing FRB-generated Dart binding. Uses -/// `AudioRoute::Unknown` which triggers a route-change recompute. -/// The AppDelegate now also calls `handle_media_services_reset_with_route` -/// directly after rebuilding the session. -#[frb(sync)] -pub fn handle_media_services_reset() { - let result = runtime().block_on(async { - session() - .ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown) - .await - }); - if let Err(e) = result { - warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed"); - } -} - /// Handle iOS AVAudioSession media-services reset with the current /// route class. Called by AppDelegate after rebuilding the session. /// @@ -552,11 +547,11 @@ pub fn handle_interruption_ended(should_resume: bool) { } } -/// Set the push-to-talk state. +/// Set the focused/on-screen push-to-talk hold state. /// -/// Superseded in v1 by [`set_transmit_mode`] + the binding capture -/// dialog. Retained so legacy callers and integration tests keep -/// working; the new VoiceBar UI no longer invokes this. +/// Binding capture chooses which physical key drives PTT, while this +/// command carries the actual press/release edge for fallback focused +/// keyboard handling and touch controls. pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_ptt(active).await }) @@ -691,6 +686,46 @@ pub enum BridgePttInputClass { MouseSideButton, } +/// Privacy-safe PTT capability descriptor for the UI. +#[derive(Debug, Clone)] +pub struct BridgePttDescriptor { + /// Stable capability level name. + pub level: String, + /// Stable backend identifier. + pub backend_id: String, + /// Coarse bound input class; empty when no binding is active. + pub bound_input_class: String, +} + +impl From for BridgePttDescriptor { + fn from(desc: chanora_core::PttDescriptorSnapshot) -> Self { + Self { + level: desc.level, + backend_id: desc.backend_id, + bound_input_class: desc.bound_input_class, + } + } +} + +/// Persisted PTT binding display state for the UI. +#[derive(Debug, Clone)] +pub struct BridgePttBinding { + /// Stable input category string (`""`, `"keyboard"`, or + /// `"mouse-side-button"`). + pub input_class: String, + /// Display-only key label; empty when no binding is active. + pub key_label: String, +} + +impl From for BridgePttBinding { + fn from(binding: chanora_core::PersistedPttBinding) -> Self { + Self { + input_class: binding.input_class, + key_label: binding.key_label, + } + } +} + impl From for chanora_core::PttInputClass { fn from(c: BridgePttInputClass) -> Self { match c { @@ -721,27 +756,34 @@ pub async fn set_ptt_binding( Ok(()) } -/// Read the current PTT capability descriptor. Returns a -/// `(level, backend_id, bound_input_class)` triple matching the -/// privacy-safe `BridgeEvent::PttCapability` event shape; useful -/// for the initial UI render before the first event arrives. -pub async fn ptt_descriptor() -> (String, String, String) { +/// Read the current PTT capability descriptor. Matches the privacy-safe +/// `BridgeEvent::PttCapability` event shape; useful for the initial UI +/// render before the first event arrives. +pub async fn ptt_descriptor() -> BridgePttDescriptor { runtime() .spawn(async { session().ptt_descriptor().await }) .await - .unwrap_or_else(|_| (String::new(), String::new(), String::new())) + .map(Into::into) + .unwrap_or_else(|_| BridgePttDescriptor { + level: String::new(), + backend_id: String::new(), + bound_input_class: 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) { +/// Return the persisted PTT binding 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() -> BridgePttBinding { runtime() .spawn(async { session().get_ptt_binding().await }) .await - .unwrap_or_else(|_| (String::new(), String::new())) + .map(Into::into) + .unwrap_or_else(|_| BridgePttBinding { + input_class: String::new(), + key_label: String::new(), + }) } /// Move our own client to `channel_id`. Optional channel password @@ -794,6 +836,36 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> { Ok(()) } +/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0` +/// mutes. No-op when client has no active voice queue. Volume is +/// applied directly to the tsclientlib AudioQueue and takes effect +/// immediately on the next render callback. +pub async fn set_client_volume(client_id: u64, volume: f32) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_client_volume(client_id, volume).await }) + .await + .map_err(|e| task_join_error("set_client_volume", e))??; + Ok(()) +} + +/// Send a text message to the specified target. +pub async fn send_chat_message( + message: String, + target: BridgeMessageTarget, +) -> Result<(), BridgeError> { + let target_core: chanora_core::MessageTarget = match target { + BridgeMessageTarget::Server => chanora_core::MessageTarget::Server, + BridgeMessageTarget::Channel => chanora_core::MessageTarget::Channel, + BridgeMessageTarget::Client(id) => chanora_core::MessageTarget::Client(id), + BridgeMessageTarget::Poke(id) => chanora_core::MessageTarget::Poke(id), + }; + runtime() + .spawn(async move { session().send_text_message(message, target_core).await }) + .await + .map_err(|e| task_join_error("send_chat_message", e))??; + Ok(()) +} + /// Statistics from the audio engine. #[derive(Debug, Clone)] pub struct BridgeAudioStats { @@ -1164,8 +1236,14 @@ pub fn export_diagnostics() -> String { let android_audio_yaml = chanora_audio::mobile_voice_backend::current_android_audio_diagnostics() .map(|d| d.to_yaml_fragment()); + let network_info = runtime().block_on(async { session().network_diagnostics_summary().await }); + let protocol_events = runtime().block_on(async { session().drain_protocol_events().await }); match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { - Ok(exp) => exp.with_android_audio(android_audio_yaml).to_text(), + Ok(exp) => exp + .with_android_audio(android_audio_yaml) + .with_network_info(Some(network_info)) + .with_protocol_events(protocol_events) + .to_text(), Err(e) => format!("(diagnostic export failed: {e})"), } } @@ -1417,6 +1495,46 @@ pub enum BridgeEvent { /// Resolved permission state. state: PermissionStateKind, }, + /// A text message received from the server. + ChatMessage { + /// Client id of the sender. + sender_id: u64, + /// Nickname of the sender. + sender_name: String, + /// Message content. + message: String, + /// Target scope (server/channel/private/poke). + target: BridgeMessageTarget, + }, + /// Audio route changed (speaker/earpiece/BT/wired). + AudioRouteChanged { + /// The new audio route. + route: BridgeAudioRoute, + }, +} + +/// Bridge message target scope. +#[derive(Debug, Clone, Copy)] +pub enum BridgeMessageTarget { + /// Broadcast to entire server. + Server, + /// Broadcast to current channel. + Channel, + /// Private message to a specific client. + Client(u64), + /// Poke a specific client. + Poke(u64), +} + +impl From for BridgeMessageTarget { + fn from(t: chanora_core::MessageTarget) -> Self { + match t { + chanora_core::MessageTarget::Server => Self::Server, + chanora_core::MessageTarget::Channel => Self::Channel, + chanora_core::MessageTarget::Client(id) => Self::Client(id), + chanora_core::MessageTarget::Poke(id) => Self::Poke(id), + } + } } /// Bridge mirror of core join projection sync state. @@ -1563,6 +1681,22 @@ impl From for BridgeEvent { began, should_resume, }, + chanora_core::SessionEvent::ChatMessage { + sender_id, + sender_name, + message, + target, + } => BridgeEvent::ChatMessage { + sender_id, + sender_name, + message, + target: target.into(), + }, + chanora_core::SessionEvent::AudioRouteChanged { route } => { + BridgeEvent::AudioRouteChanged { + route: route.into(), + } + } } } } @@ -1711,6 +1845,66 @@ pub async fn audio_processing_stats() -> Result, + /// Available output devices. + pub output_devices: Vec, +} + +/// List available audio input and output devices from the platform. +pub fn list_audio_devices() -> BridgeAudioDeviceList { + let list = chanora_audio::list_audio_devices(); + BridgeAudioDeviceList { + input_devices: list + .input_devices + .into_iter() + .map(|d| BridgeAudioDevice { + name: d.name, + is_default: d.is_default, + }) + .collect(), + output_devices: list + .output_devices + .into_iter() + .map(|d| BridgeAudioDevice { + name: d.name, + is_default: d.is_default, + }) + .collect(), + } +} + +/// Set the preferred input device by name. Takes effect on next +/// `start_audio`. +pub async fn set_input_device(name: Option) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_input_device(name).await }) + .await + .map_err(|e| task_join_error("set_input_device", e))??; + Ok(()) +} + +/// Set the preferred output device by name. +pub async fn set_output_device(name: Option) -> Result<(), BridgeError> { + runtime() + .spawn(async move { session().set_output_device(name).await }) + .await + .map_err(|e| task_join_error("set_output_device", e))??; + Ok(()) +} + /// Configure the VAD model path. pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> { if path.trim().is_empty() { @@ -1733,7 +1927,7 @@ pub async fn set_ten_vad_model_path(path: String) -> Result<(), BridgeError> { )); } runtime() - .spawn(async move { chanora_audio::vad::set_ten_model_path(&path).map_err(|e| e) }) + .spawn(async move { chanora_audio::vad::set_ten_model_path(&path) }) .await .map_err(|e| task_join_error("set_ten_vad_model_path", e))? .map_err(|e| BridgeError::Unmapped(format!("set_ten_vad_model_path: {e}")))?; @@ -1785,3 +1979,18 @@ pub async fn set_ios_voice_processing_mode( }; set_audio_processing_config(config).await } + +/// Set the preferred audio output route (Android/iOS). +#[frb(sync)] +pub fn set_audio_output_route(route: BridgeAudioRoute) { + runtime().block_on(async { + let _ = session().ios_handle_route_change(route.into()).await; + }); +} +/// Called from Flutter when the app enters background/foreground. +#[frb(sync)] +pub fn record_lifecycle_event(state: String) { + runtime().block_on(async { + session().record_lifecycle_event(&state).await; + }); +} diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 8e7c8ed..66e5e75 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -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 = -436507436; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1433826599; // Section: executor @@ -602,37 +602,6 @@ fn wire__crate__api__handle_interruption_ended_impl( }, ) } -fn wire__crate__api__handle_media_services_reset_impl( - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "handle_media_services_reset", - port: None, - mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, - }, - 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(); - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok({ - crate::api::handle_media_services_reset(); - })?; - Ok(output_ok) - })()) - }, - ) -} fn wire__crate__api__handle_media_services_reset_with_route_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -768,6 +737,38 @@ fn wire__crate__api__is_connected_impl( }, ) } +fn wire__crate__api__list_audio_devices_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_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "list_audio_devices", + 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| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::list_audio_devices())?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__list_bookmarks_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -905,6 +906,108 @@ fn wire__crate__api__ptt_descriptor_impl( }, ) } +fn wire__crate__api__record_lifecycle_event_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "record_lifecycle_event", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + 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); + let api_state = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::record_lifecycle_event(api_state); + })?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__send_chat_message_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::TaskInfo { + debug_name: "send_chat_message", + 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); + let api_message = ::sse_decode(&mut deserializer); + let api_target = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = + crate::api::send_chat_message(api_message, api_target).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__set_audio_output_route_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_audio_output_route", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + 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); + let api_route = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::set_audio_output_route(api_route); + })?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__set_audio_processing_config_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -942,6 +1045,44 @@ fn wire__crate__api__set_audio_processing_config_impl( }, ) } +fn wire__crate__api__set_client_volume_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::TaskInfo { + debug_name: "set_client_volume", + 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); + let api_client_id = ::sse_decode(&mut deserializer); + let api_volume = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = + crate::api::set_client_volume(api_client_id, api_volume).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__set_hard_mute_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -978,6 +1119,42 @@ fn wire__crate__api__set_hard_mute_impl( }, ) } +fn wire__crate__api__set_input_device_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::TaskInfo { + debug_name: "set_input_device", + 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); + let api_name = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_input_device(api_name).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__set_input_muted_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1083,6 +1260,42 @@ fn wire__crate__api__set_network_state_impl( }, ) } +fn wire__crate__api__set_output_device_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::TaskInfo { + debug_name: "set_output_device", + 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); + let api_name = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::set_output_device(api_name).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__set_output_gain_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1567,6 +1780,30 @@ impl SseDecode for crate::api::BridgeAudioBackend { } } +impl SseDecode for crate::api::BridgeAudioDevice { + // 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_name = ::sse_decode(deserializer); + let mut var_isDefault = ::sse_decode(deserializer); + return crate::api::BridgeAudioDevice { + name: var_name, + is_default: var_isDefault, + }; + } +} + +impl SseDecode for crate::api::BridgeAudioDeviceList { + // 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_inputDevices = >::sse_decode(deserializer); + let mut var_outputDevices = >::sse_decode(deserializer); + return crate::api::BridgeAudioDeviceList { + input_devices: var_inputDevices, + output_devices: var_outputDevices, + }; + } +} + impl SseDecode for crate::api::BridgeAudioProcessingConfig { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1704,12 +1941,14 @@ impl SseDecode for crate::api::BridgeChannel { let mut var_name = ::sse_decode(deserializer); let mut var_order = ::sse_decode(deserializer); let mut var_hasPassword = ::sse_decode(deserializer); + let mut var_neededTalkPower = >::sse_decode(deserializer); return crate::api::BridgeChannel { id: var_id, parent: var_parent, name: var_name, order: var_order, has_password: var_hasPassword, + needed_talk_power: var_neededTalkPower, }; } } @@ -1724,6 +1963,8 @@ impl SseDecode for crate::api::BridgeClient { let mut var_outputMuted = ::sse_decode(deserializer); let mut var_isSpeaking = ::sse_decode(deserializer); let mut var_isServerQuery = ::sse_decode(deserializer); + let mut var_talkPower = ::sse_decode(deserializer); + let mut var_talkPowerGranted = ::sse_decode(deserializer); return crate::api::BridgeClient { id: var_id, channel: var_channel, @@ -1732,6 +1973,8 @@ impl SseDecode for crate::api::BridgeClient { output_muted: var_outputMuted, is_speaking: var_isSpeaking, is_server_query: var_isServerQuery, + talk_power: var_talkPower, + talk_power_granted: var_talkPowerGranted, }; } } @@ -1891,6 +2134,22 @@ impl SseDecode for crate::api::BridgeEvent { state: var_state, }; } + 11 => { + let mut var_senderId = ::sse_decode(deserializer); + let mut var_senderName = ::sse_decode(deserializer); + let mut var_message = ::sse_decode(deserializer); + let mut var_target = ::sse_decode(deserializer); + return crate::api::BridgeEvent::ChatMessage { + sender_id: var_senderId, + sender_name: var_senderName, + message: var_message, + target: var_target, + }; + } + 12 => { + let mut var_route = ::sse_decode(deserializer); + return crate::api::BridgeEvent::AudioRouteChanged { route: var_route }; + } _ => { unimplemented!(""); } @@ -1913,6 +2172,32 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode { } } +impl SseDecode for crate::api::BridgeMessageTarget { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + return crate::api::BridgeMessageTarget::Server; + } + 1 => { + return crate::api::BridgeMessageTarget::Channel; + } + 2 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::api::BridgeMessageTarget::Client(var_field0); + } + 3 => { + let mut var_field0 = ::sse_decode(deserializer); + return crate::api::BridgeMessageTarget::Poke(var_field0); + } + _ => { + unimplemented!(""); + } + } + } +} + impl SseDecode for crate::api::BridgeNetworkState { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1926,6 +2211,32 @@ impl SseDecode for crate::api::BridgeNetworkState { } } +impl SseDecode for crate::api::BridgePttBinding { + // 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_inputClass = ::sse_decode(deserializer); + let mut var_keyLabel = ::sse_decode(deserializer); + return crate::api::BridgePttBinding { + input_class: var_inputClass, + key_label: var_keyLabel, + }; + } +} + +impl SseDecode for crate::api::BridgePttDescriptor { + // 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_level = ::sse_decode(deserializer); + let mut var_backendId = ::sse_decode(deserializer); + let mut var_boundInputClass = ::sse_decode(deserializer); + return crate::api::BridgePttDescriptor { + level: var_level, + backend_id: var_backendId, + bound_input_class: var_boundInputClass, + }; + } +} + impl SseDecode for crate::api::BridgePttInputClass { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2044,6 +2355,18 @@ impl SseDecode for i64 { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2092,6 +2415,17 @@ impl SseDecode for Vec { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2105,6 +2439,17 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2130,25 +2475,6 @@ impl SseDecode for crate::api::PermissionStateKind { } } -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 = ::sse_decode(deserializer); - let mut var_field1 = ::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 { - let mut var_field0 = ::sse_decode(deserializer); - let mut var_field1 = ::sse_decode(deserializer); - let mut var_field2 = ::sse_decode(deserializer); - return (var_field0, var_field1, var_field2); - } -} - impl SseDecode for u32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2197,29 +2523,34 @@ fn pde_ffi_dispatcher_primary_impl( 12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len), 13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 14 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len), 22 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), 24 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), 25 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), - 28 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), - 29 => { + 27 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len), + 29 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len), + 30 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len), + 31 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), + 32 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), + 34 => { wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len) } - 31 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), - 32 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), - 33 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 34 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), - 35 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), - 36 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len), - 37 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), + 36 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), + 38 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), + 41 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -2235,15 +2566,16 @@ fn pde_ffi_dispatcher_sync_impl( 10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), 15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len), 16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len), - 17 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len), - 18 => wire__crate__api__handle_media_services_reset_with_route_impl( + 17 => wire__crate__api__handle_media_services_reset_with_route_impl( ptr, rust_vec_len, data_len, ), - 19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len), + 18 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len), 23 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), - 30 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 26 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len), + 28 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len), + 35 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -2274,6 +2606,45 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDevice { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.name.into_into_dart().into_dart(), + self.is_default.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeAudioDevice {} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeAudioDevice +{ + fn into_into_dart(self) -> crate::api::BridgeAudioDevice { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDeviceList { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.input_devices.into_into_dart().into_dart(), + self.output_devices.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgeAudioDeviceList +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeAudioDeviceList +{ + fn into_into_dart(self) -> crate::api::BridgeAudioDeviceList { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingConfig { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -2414,6 +2785,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeChannel { self.name.into_into_dart().into_dart(), self.order.into_into_dart().into_dart(), self.has_password.into_into_dart().into_dart(), + self.needed_talk_power.into_into_dart().into_dart(), ] .into_dart() } @@ -2435,6 +2807,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient { self.output_muted.into_into_dart().into_dart(), self.is_speaking.into_into_dart().into_dart(), self.is_server_query.into_into_dart().into_dart(), + self.talk_power.into_into_dart().into_dart(), + self.talk_power_granted.into_into_dart().into_dart(), ] .into_dart() } @@ -2586,6 +2960,22 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { state.into_into_dart().into_dart(), ] .into_dart(), + crate::api::BridgeEvent::ChatMessage { + sender_id, + sender_name, + message, + target, + } => [ + 11.into_dart(), + sender_id.into_into_dart().into_dart(), + sender_name.into_into_dart().into_dart(), + message.into_into_dart().into_dart(), + target.into_into_dart().into_dart(), + ] + .into_dart(), + crate::api::BridgeEvent::AudioRouteChanged { route } => { + [12.into_dart(), route.into_into_dart().into_dart()].into_dart() + } _ => { unimplemented!(""); } @@ -2620,6 +3010,35 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeMessageTarget { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::api::BridgeMessageTarget::Server => [0.into_dart()].into_dart(), + crate::api::BridgeMessageTarget::Channel => [1.into_dart()].into_dart(), + crate::api::BridgeMessageTarget::Client(field0) => { + [2.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } + crate::api::BridgeMessageTarget::Poke(field0) => { + [3.into_dart(), field0.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgeMessageTarget +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeMessageTarget +{ + fn into_into_dart(self) -> crate::api::BridgeMessageTarget { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -2642,6 +3061,46 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.input_class.into_into_dart().into_dart(), + self.key_label.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgePttBinding {} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgePttBinding +{ + fn into_into_dart(self) -> crate::api::BridgePttBinding { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgePttDescriptor { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.level.into_into_dart().into_dart(), + self.backend_id.into_into_dart().into_dart(), + self.bound_input_class.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgePttDescriptor +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgePttDescriptor +{ + fn into_into_dart(self) -> crate::api::BridgePttDescriptor { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::BridgePttInputClass { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -2851,6 +3310,22 @@ impl SseEncode for crate::api::BridgeAudioBackend { } } +impl SseEncode for crate::api::BridgeAudioDevice { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.name, serializer); + ::sse_encode(self.is_default, serializer); + } +} + +impl SseEncode for crate::api::BridgeAudioDeviceList { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.input_devices, serializer); + >::sse_encode(self.output_devices, serializer); + } +} + impl SseEncode for crate::api::BridgeAudioProcessingConfig { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2946,6 +3421,7 @@ impl SseEncode for crate::api::BridgeChannel { ::sse_encode(self.name, serializer); ::sse_encode(self.order, serializer); ::sse_encode(self.has_password, serializer); + >::sse_encode(self.needed_talk_power, serializer); } } @@ -2959,6 +3435,8 @@ impl SseEncode for crate::api::BridgeClient { ::sse_encode(self.output_muted, serializer); ::sse_encode(self.is_speaking, serializer); ::sse_encode(self.is_server_query, serializer); + ::sse_encode(self.talk_power, serializer); + ::sse_encode(self.talk_power_granted, serializer); } } @@ -3105,6 +3583,22 @@ impl SseEncode for crate::api::BridgeEvent { ::sse_encode(permission, serializer); ::sse_encode(state, serializer); } + crate::api::BridgeEvent::ChatMessage { + sender_id, + sender_name, + message, + target, + } => { + ::sse_encode(11, serializer); + ::sse_encode(sender_id, serializer); + ::sse_encode(sender_name, serializer); + ::sse_encode(message, serializer); + ::sse_encode(target, serializer); + } + crate::api::BridgeEvent::AudioRouteChanged { route } => { + ::sse_encode(12, serializer); + ::sse_encode(route, serializer); + } _ => { unimplemented!(""); } @@ -3128,6 +3622,31 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode { } } +impl SseEncode for crate::api::BridgeMessageTarget { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::api::BridgeMessageTarget::Server => { + ::sse_encode(0, serializer); + } + crate::api::BridgeMessageTarget::Channel => { + ::sse_encode(1, serializer); + } + crate::api::BridgeMessageTarget::Client(field0) => { + ::sse_encode(2, serializer); + ::sse_encode(field0, serializer); + } + crate::api::BridgeMessageTarget::Poke(field0) => { + ::sse_encode(3, serializer); + ::sse_encode(field0, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + impl SseEncode for crate::api::BridgeNetworkState { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -3145,6 +3664,23 @@ impl SseEncode for crate::api::BridgeNetworkState { } } +impl SseEncode for crate::api::BridgePttBinding { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.input_class, serializer); + ::sse_encode(self.key_label, serializer); + } +} + +impl SseEncode for crate::api::BridgePttDescriptor { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.level, serializer); + ::sse_encode(self.backend_id, serializer); + ::sse_encode(self.bound_input_class, serializer); + } +} + impl SseEncode for crate::api::BridgePttInputClass { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -3274,6 +3810,16 @@ impl SseEncode for i64 { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -3314,6 +3860,16 @@ impl SseEncode for Vec { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -3324,6 +3880,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -3352,23 +3918,6 @@ impl SseEncode for crate::api::PermissionStateKind { } } -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) { - ::sse_encode(self.0, serializer); - ::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) { - ::sse_encode(self.0, serializer); - ::sse_encode(self.1, serializer); - ::sse_encode(self.2, serializer); - } -} - impl SseEncode for u32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index cc60fd2..3d85857 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -102,13 +102,14 @@ impl From for BridgeError { chanora_core::CoreError::AudioNotStarted => { BridgeError::InvalidCommand("audio not started".to_string()) } - chanora_core::CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed { + chanora_core::CoreError::Protocol(chanora_core::ProtocolError::DnsFailed { host, reason, }) => BridgeError::DnsFailed { host, reason }, - chanora_core::CoreError::Protocol( - chanora_protocol::ProtocolError::ServerRejected { code, message }, - ) => BridgeError::ServerRejected { code, message }, + chanora_core::CoreError::Protocol(chanora_core::ProtocolError::ServerRejected { + code, + message, + }) => BridgeError::ServerRejected { code, message }, chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")), chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")), chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")), diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index d9ea31c..a4d87f9 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -61,6 +61,14 @@ pub enum DiagnosticsError { /// to the PoC value so audit grep patterns survive the promotion. pub const REDACTION_MARKER: &str = "[REDACTED]"; +/// SRS-122: In-memory log capacity for release builds. +/// Release builds use a smaller buffer to limit memory footprint and residual data in exports. +#[cfg(not(debug_assertions))] +pub const DEFAULT_LOG_CAPACITY: usize = 256; +/// SRS-122: In-memory log capacity for debug builds. +#[cfg(debug_assertions)] +pub const DEFAULT_LOG_CAPACITY: usize = 4096; + /// Registry of known-secret values that must never appear in logs /// or exports. Cross-spike contract per SS-AUD-003: the secure /// storage adapter calls [`Self::register`] every time a secret @@ -612,6 +620,10 @@ pub struct DiagnosticExport { /// fragment contains only device-side technical scalars per /// SDD-090 (no PII, no permission state, no server identity). pub android_audio: Option, + /// SRS-100: Network connectivity diagnostics summary. + pub network_info: Option, + /// SRS-097: Protocol event recording trace. + pub protocol_events: Vec, } impl DiagnosticExport { @@ -625,6 +637,8 @@ impl DiagnosticExport { recent_logs: sink.snapshot(), known_secret_count: sink.redactor().secrets().len(), android_audio: None, + network_info: None, + protocol_events: Vec::new(), }) } @@ -638,6 +652,18 @@ impl DiagnosticExport { self } + /// Attach a network connectivity diagnostics fragment (SRS-100). + pub fn with_network_info(mut self, info: Option) -> Self { + self.network_info = info; + self + } + + /// Attach protocol event trace (SRS-097). + pub fn with_protocol_events(mut self, events: Vec) -> Self { + self.protocol_events = events; + self + } + /// Render as a plaintext blob suitable for `Share` / `Copy`. /// The output is multi-line UTF-8, redacted. pub fn to_text(&self) -> String { @@ -656,6 +682,17 @@ impl DiagnosticExport { out.push_str("\n[audio.android]\n"); out.push_str(yaml); } + if let Some(info) = &self.network_info { + out.push_str("\n[network]\n"); + out.push_str(info); + } + if !self.protocol_events.is_empty() { + out.push_str("\n[protocol events]\n"); + for ev in &self.protocol_events { + out.push_str(ev); + out.push('\n'); + } + } out.push_str("\n[recent logs]\n"); for line in &self.recent_logs { out.push_str(line); @@ -665,6 +702,86 @@ impl DiagnosticExport { } } +/// SRS-097/098: Ring-buffer recorder of protocol-level events +/// (connect, disconnect, snapshot changes, channel joins) for +/// diagnostic export and state-sync replay verification. +#[derive(Debug, Clone)] +pub struct ProtocolEventRecorder { + events: Vec, + capacity: usize, +} + +impl ProtocolEventRecorder { + /// Create a recorder with the given ring-buffer capacity. + pub fn new(capacity: usize) -> Self { + Self { + events: Vec::with_capacity(capacity), + capacity, + } + } + + fn push(&mut self, ts: &str, kind: &str, detail: &str) { + let s = format!("[{ts}] {kind}: {detail}"); + if self.events.len() >= self.capacity { + self.events.remove(0); + } + self.events.push(s); + } + + /// Record a successful connection. + pub fn record_connected(&mut self, server_name: &str) { + self.push("connect", "connected", server_name); + } + + /// Record a graceful or forced disconnect. + pub fn record_disconnected(&mut self, reason: &str) { + self.push("disconnect", "disconnected", reason); + } + + /// Record a reconnect attempt. + pub fn record_reconnecting(&mut self, attempt: u64, delay_secs: u64) { + self.push( + "reconnect", + "reconnecting", + &format!("attempt={attempt} delay={delay_secs}s"), + ); + } + + /// Record a snapshot tree change. + pub fn record_snapshot_changed(&mut self, channels: usize, clients: usize) { + self.push( + "snapshot", + "changed", + &format!("channels={channels} clients={clients}"), + ); + } + + /// Record a channel join event. + pub fn record_channel_join(&mut self, channel_id: u64, channel_name: &str) { + self.push( + "join", + "channel_joined", + &format!("id={channel_id} name={channel_name}"), + ); + } + + /// Record a platform lifecycle transition (SRS-138). + pub fn record_lifecycle(&mut self, state: &str) { + self.push("lifecycle", state, ""); + } + + /// Drain all recorded events and reset the buffer. + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } +} + +impl Default for ProtocolEventRecorder { + fn default() -> Self { + Self::new(256) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/chanora_protocol/Cargo.toml b/crates/chanora_protocol/Cargo.toml index 2c32d0e..f30ec1a 100644 --- a/crates/chanora_protocol/Cargo.toml +++ b/crates/chanora_protocol/Cargo.toml @@ -24,6 +24,7 @@ tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2 # avoids any version-skew confusion. tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" } tsproto-types = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" } +ts-bookkeeping = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" } # Async runtime utilities used by the connection task. tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 29544a3..55bca5a 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -33,7 +33,9 @@ use tsclientlib::{ use tsproto_packets::packets::{InAudioBuf, OutPacket}; use tsproto_types::ClientType; -use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot}; +use crate::dto::{ + ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot, +}; use crate::ProtocolError; const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750); @@ -104,8 +106,8 @@ pub struct ConnectConfig { pub password: Option, /// Optional pre-existing identity (base64 string accepted by /// `tsclientlib::Identity::new_from_str`). If `None`, a fresh - /// identity is generated and **not persisted** — production - /// callers must wire this to `chanora_storage::SecretStorageRepository`. + /// identity is generated and **not persisted** — production callers + /// should provide one from secure identity storage. pub identity: Option, /// How long to wait for the initial state snapshot before /// returning `ProtocolError::Timeout`. @@ -139,6 +141,15 @@ enum Request { output: Option, reply: oneshot::Sender>, }, + /// Send a text message to a target. + SendTextMessage { + /// Message content. + message: String, + /// Target scope. + target: MessageTarget, + /// Reply channel for outcome. + reply: oneshot::Sender>, + }, } /// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven @@ -170,6 +181,9 @@ pub struct ProtocolClient { /// auto-reconnect. Wrapped in a Mutex> so it can be /// taken once by the supervisor and never resurfaced. lost_rx: std::sync::Mutex>>, + /// Inbound chat message stream from the connection task. The + /// receiver is taken by the supervisor and forwarded to UI. + chat_rx: std::sync::Mutex>>, } /// One inbound voice packet from a remote client. @@ -228,6 +242,7 @@ impl ProtocolClient { let (tx, rx) = mpsc::channel::(8); let (voice_out_tx, voice_out_rx) = mpsc::channel::(64); let (voice_in_tx, voice_in_rx) = mpsc::channel::(64); + let (chat_tx, chat_rx) = mpsc::channel::(64); let (ready_tx, ready_rx) = oneshot::channel::>(); let (lost_tx, lost_rx) = oneshot::channel::(); @@ -236,6 +251,7 @@ impl ProtocolClient { rx, voice_out_rx, voice_in_tx, + chat_tx, ready_tx, lost_tx, )); @@ -246,6 +262,7 @@ impl ProtocolClient { voice_out_tx, voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)), lost_rx: std::sync::Mutex::new(Some(lost_rx)), + chat_rx: std::sync::Mutex::new(Some(chat_rx)), }), Ok(Ok(Err(e))) => Err(e), Ok(Err(_)) => Err(ProtocolError::Backend( @@ -360,6 +377,40 @@ impl ProtocolClient { pub fn take_loss_notifier(&self) -> Option> { self.lost_rx.lock().ok().and_then(|mut g| g.take()) } + + /// Take the inbound-chat receiver. Returns `None` if it has + /// already been taken; only one consumer is allowed. + pub fn take_chat_rx(&self) -> Option> { + self.chat_rx.lock().ok().and_then(|mut g| g.take()) + } + + /// Put a previously-taken chat_rx receiver back. + pub fn put_chat_rx(&self, rx: mpsc::Receiver) { + if let Ok(mut g) = self.chat_rx.lock() { + if g.is_none() { + *g = Some(rx); + } + } + } + + /// Send a text message to the specified target. + pub async fn send_text_message( + &self, + message: String, + target: MessageTarget, + ) -> Result<(), ProtocolError> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Request::SendTextMessage { + message, + target, + reply: tx, + }) + .await + .map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?; + rx.await + .map_err(|_| ProtocolError::Lost("send_text_message reply dropped".to_string()))? + } } async fn connection_task( @@ -367,6 +418,7 @@ async fn connection_task( mut rx: mpsc::Receiver, mut voice_out_rx: mpsc::Receiver, voice_in_tx: mpsc::Sender, + chat_tx: mpsc::Sender, ready_tx: oneshot::Sender>, lost_tx: oneshot::Sender, ) { @@ -545,6 +597,33 @@ async fn connection_task( } } } + StreamItem::BookEvents(events) => { + for ev in events { + if let tsclientlib::events::Event::Message { + target, + invoker, + message, + } = ev + { + let mapped = match target { + tsclientlib::MessageTarget::Server => MessageTarget::Server, + tsclientlib::MessageTarget::Channel => MessageTarget::Channel, + tsclientlib::MessageTarget::Client(id) => { + MessageTarget::Client(id.0 as u64) + } + tsclientlib::MessageTarget::Poke(id) => { + MessageTarget::Poke(id.0 as u64) + } + }; + let _ = chat_tx.try_send(ChatMessage { + sender_id: ClientId(invoker.id.0 as u64), + sender_name: sanitize(&invoker.name), + message: sanitize(&message), + target: mapped, + }); + } + } + } StreamItem::MessageResult(handle, result) => { if let Some((reply, _deadline)) = pending_moves.remove(&handle) { let mapped = match result { @@ -644,6 +723,14 @@ async fn connection_task( let r = set_self_muted(&mut con, input, output); let _ = reply.send(r); } + Ok(Request::SendTextMessage { + message, + target, + reply, + }) => { + let r = send_text_message(&mut con, &message, target); + let _ = reply.send(r); + } Ok(Request::Disconnect(reply)) => { let _ = con.disconnect(DisconnectOptions::new()); con.events().for_each(|_| future::ready(())).await; @@ -723,6 +810,74 @@ fn set_self_muted( Ok(()) } +fn send_text_message( + con: &mut Connection, + message: &str, + target: MessageTarget, +) -> Result<(), ProtocolError> { + use ts_bookkeeping::messages::c2s; + use tsproto_types::TextMessageTargetMode; + match target { + MessageTarget::Server => { + c2s::OutSendTextMessageMessage::new(&mut std::iter::once( + c2s::OutSendTextMessagePart { + target: TextMessageTargetMode::Server, + target_client_id: None, + message: message.into(), + }, + )) + .send(con) + .map_err(|e| ProtocolError::Backend(format!("send_textmessage(server): {e}")))?; + } + MessageTarget::Channel => { + // Fix: previously channel messages were sent via + // state.server.send_textmessage() which always uses + // TextMessageTargetMode::Server. Now correctly uses + // TextMessageTargetMode::Channel so the message is + // scoped to the current channel, not server-wide. + c2s::OutSendTextMessageMessage::new(&mut std::iter::once( + c2s::OutSendTextMessagePart { + target: TextMessageTargetMode::Channel, + target_client_id: None, + message: message.into(), + }, + )) + .send(con) + .map_err(|e| ProtocolError::Backend(format!("send_textmessage(channel): {e}")))?; + } + MessageTarget::Client(client_id) => { + let state = con + .get_state() + .map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?; + let client = state + .clients + .values() + .find(|c| c.id.0 as u64 == client_id) + .ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?; + client + .send_textmessage(message) + .send(con) + .map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?; + } + MessageTarget::Poke(client_id) => { + let state = con + .get_state() + .map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?; + let client = state + .clients + .values() + .find(|c| c.id.0 as u64 == client_id) + .ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?; + client + .poke(message) + .send(con) + .map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?; + } + } + info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent"); + Ok(()) +} + /// Extract the originating `client_id` from an inbound voice packet. fn packet_sender_id(buf: &InAudioBuf) -> Option { use tsproto_packets::packets::AudioData; @@ -872,6 +1027,7 @@ fn build_snapshot( name: sanitize(&c.name), order: c.order.0 as i64, has_password: c.has_password.unwrap_or(false), + needed_talk_power: c.needed_talk_power, }) .collect(); @@ -887,6 +1043,8 @@ fn build_snapshot( .get(&(c.id.0 as u64)) .is_some_and(|last| last.elapsed() <= SPEAKING_ACTIVITY_WINDOW), is_server_query: is_server_query_client_type(&c.client_type), + talk_power: c.talk_power, + talk_power_granted: c.talk_power_granted, }) .collect(); @@ -915,8 +1073,7 @@ fn sanitize(s: &str) -> String { .collect() } -#[allow(dead_code)] -const _ROOT_MATCHES_UPSTREAM: () = { +const _: () = { // Compile-time assertion that ChannelId(0) maps to what tsclientlib // also considers the root. let _ = TsChannelId(0); diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index b5c7c03..9cac153 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -25,6 +25,35 @@ pub struct ChannelInfo { pub order: i64, /// True when the server marks the channel as password-protected. pub has_password: bool, + /// Talk power threshold required to speak in this channel. + /// `None` means no talk-power restriction. + pub needed_talk_power: Option, +} + +/// The target scope of a text message (mirrors TS3 `TextMessageTargetMode`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageTarget { + /// Broadcast to entire server. + Server, + /// Broadcast to current channel. + Channel, + /// Private message to a specific client. + Client(u64), + /// Poke a specific client. + Poke(u64), +} + +/// An in-channel text message from a specific client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + /// The client id of the sender. + pub sender_id: ClientId, + /// Nickname of the sender, preserved verbatim. + pub sender_name: String, + /// Message content, preserved verbatim. + pub message: String, + /// Target scope of this message. + pub target: MessageTarget, } /// One connected client on the server. @@ -44,6 +73,10 @@ pub struct ClientInfo { pub is_speaking: bool, /// True for TeamSpeak ServerQuery clients. pub is_server_query: bool, + /// Current talk power value assigned by the server. + pub talk_power: i32, + /// True when the server has granted talk power regardless of numeric value. + pub talk_power_granted: bool, } /// Snapshot of the server's published state at a moment in time. diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index 3305afd..659b4cd 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -41,7 +41,9 @@ mod dto; mod resolver; pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe}; -pub use dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot}; +pub use dto::{ + ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot, +}; // Re-export the upstream voice types so chanora_audio can build outbound // voice packets without taking a direct dependency on tsclientlib / diff --git a/crates/chanora_state/Cargo.toml b/crates/chanora_state/Cargo.toml index 2ec1dbd..e8e78f3 100644 --- a/crates/chanora_state/Cargo.toml +++ b/crates/chanora_state/Cargo.toml @@ -11,4 +11,3 @@ publish.workspace = true [dependencies] thiserror.workspace = true -tracing.workspace = true diff --git a/crates/chanora_state/src/channel_join.rs b/crates/chanora_state/src/channel_join.rs index 4834d42..8b39e46 100644 --- a/crates/chanora_state/src/channel_join.rs +++ b/crates/chanora_state/src/channel_join.rs @@ -706,9 +706,7 @@ fn pending_key(pending: &JoinPending) -> JoinOutcomeKey { } fn key_matches(pending: Option, key: JoinOutcomeKey) -> bool { - pending - .map(|pending| pending_key(&pending) == key) - .unwrap_or(false) + pending.is_some_and(|pending| pending_key(&pending) == key) } fn stale(state: &mut ChannelJoinState, actions: &mut Vec) -> JoinReduceStatus { diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 56eb887..1a19ad4 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -1,15 +1,15 @@ //! # `chanora_storage` //! -//! Two strictly separated repositories per SAD-067: +//! Two strictly separated storage concerns per SAD-067: //! -//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks, -//! settings, identity *references*) via SQLite. Crate choice: -//! `rusqlite` bundled (DEC-013.1). **Not yet implemented** — -//! `poc/sqlite-storage-spike` lands in v0.4. -//! * [`SecretStorageRepository`] — secret material (identity private -//! keys, server passwords) via platform secure storage. Linux -//! policy: Secret Service preferred, kernel keyutils fallback -//! (DEC-013.2). Other platforms TBD per SS-TC-001/002/004/005. +//! * [`BookmarkRepository`] — non-secret bookmark state via SQLite +//! with optional encrypted password fields. Crate choice: +//! `rusqlite` bundled (DEC-013.1). +//! * [`IdentityFileStore`] — Beta fallback storage for identity +//! material while the platform secure-storage backends mature. +//! Linux policy remains Secret Service preferred, kernel keyutils +//! fallback (DEC-013.2). Other platforms TBD per +//! SS-TC-001/002/004/005. //! //! Secret values **never** appear in the local DB (SS-AUD-001/002); //! bookmarks store only an `identity_ref` lookup name into the @@ -48,7 +48,7 @@ use std::sync::Mutex; use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use rand::RngCore; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{info, warn}; @@ -79,16 +79,6 @@ pub enum StorageError { Crypto(String), } -/// Marker trait for the non-secret database side. Concrete impl will -/// land alongside the `BookmarkRepository` / `SettingsRepository` -/// traits promoted from the SQLite PoC. -pub trait LocalDatabaseRepository: Send + Sync {} - -/// Marker trait for the platform secure-storage side. Concrete impl -/// will land alongside the `Secret` newtype + per-platform adapters -/// promoted from the secure-storage PoC. -pub trait SecretStorageRepository: Send + Sync {} - /// Audio-related per-identity settings persisted alongside the /// identity file as a small JSON blob (SDD-095 / SDD-096). These /// are *not* secrets; they sit beside the encrypted identity in @@ -118,6 +108,22 @@ struct AudioMeta { ptt_key_label: String, } +/// Persisted PTT binding metadata. +/// +/// `input_class` is a stable privacy-safe category string, `platform_key` +/// is the opaque identifier consumed by the platform backend, and +/// `key_label` is the display-only label shown in the UI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PttBindingMeta { + /// Stable input category string (`""`, `"keyboard"`, or + /// `"mouse-side-button"`). + pub input_class: String, + /// Opaque platform key identifier. + pub platform_key: String, + /// Display-only key label. + pub key_label: String, +} + fn default_release_tail_ms() -> u32 { 200 } @@ -214,59 +220,57 @@ impl IdentityFileStore { /// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless /// environments where a real Secret Service call would block on /// a missing D-Bus session. - #[allow(unused_variables)] + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + ))] fn keyring_load(&self) -> Result, StorageError> { if keyring_disabled() { return Ok(None); } - #[cfg(any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "ios" - ))] - { - use base64::Engine; - let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { - Ok(e) => e, - Err(e) => { - warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file"); - return Ok(None); - } - }; - match entry.get_password() { - Ok(b64) => { - let bytes = base64::engine::general_purpose::STANDARD - .decode(b64.as_bytes()) - .map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?; - if bytes.len() != 32 { - return Err(StorageError::Crypto(format!( - "keyring dek length {} (expected 32)", - bytes.len() - ))); - } - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - Ok(Some(key)) - } - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => { - // Bus unreachable, no session, locked keychain - // — best-effort: fall through to file. - warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file"); - Ok(None) + use base64::Engine; + let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { + Ok(e) => e, + Err(e) => { + warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file"); + return Ok(None); + } + }; + match entry.get_password() { + Ok(b64) => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?; + if bytes.len() != 32 { + return Err(StorageError::Crypto(format!( + "keyring dek length {} (expected 32)", + bytes.len() + ))); } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(Some(key)) + } + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => { + // Bus unreachable, no session, locked keychain + // — best-effort: fall through to file. + warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file"); + Ok(None) } } - #[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "ios" - )))] - { - Ok(None) - } + } + + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + )))] + fn keyring_load(&self) -> Result, StorageError> { + Ok(None) } /// Persist the DEK in the platform keyring. Returns true on @@ -274,44 +278,42 @@ impl IdentityFileStore { /// should then fall back to the file path). /// /// Honours `CHANORA_DISABLE_KEYRING=1`. - #[allow(unused_variables)] + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + ))] fn keyring_save(&self, key: &[u8; 32]) -> bool { if keyring_disabled() { return false; } - #[cfg(any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "ios" - ))] - { - use base64::Engine; - let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { - Ok(e) => e, - Err(_) => return false, - }; - let b64 = base64::engine::general_purpose::STANDARD.encode(key); - match entry.set_password(&b64) { - Ok(()) => { - info!(target: "chanora_storage", "DEK stored in platform keyring"); - true - } - Err(e) => { - warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file"); - false - } + use base64::Engine; + let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { + Ok(e) => e, + Err(_) => return false, + }; + let b64 = base64::engine::general_purpose::STANDARD.encode(key); + match entry.set_password(&b64) { + Ok(()) => { + info!(target: "chanora_storage", "DEK stored in platform keyring"); + true + } + Err(e) => { + warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file"); + false } } - #[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "ios" - )))] - { - false - } + } + + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + )))] + fn keyring_save(&self, _key: &[u8; 32]) -> bool { + false } fn ensure_dek(&self) -> Result<(), StorageError> { @@ -562,12 +564,14 @@ impl IdentityFileStore { 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) { + /// Read the persisted PTT binding. Empty strings mean "no binding". + pub fn get_ptt_binding(&self) -> PttBindingMeta { let m = self.read_meta(); - (m.ptt_input_class, m.ptt_platform_key, m.ptt_key_label) + PttBindingMeta { + input_class: m.ptt_input_class, + platform_key: m.ptt_platform_key, + key_label: m.ptt_key_label, + } } /// Remove any persisted identity. No-op if none exists. Leaves @@ -874,6 +878,49 @@ impl BookmarkRepository { Ok(conn.last_insert_rowid()) } + /// Insert or update a bookmark identified by host. If a row + /// with the same host exists, update connection fields while + /// preserving the user-facing display name; otherwise insert. + /// Returns the row id. + pub fn upsert_or_add(&self, b: &Bookmark) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?; + let blob = match (&self.crypto, &b.password) { + (Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?), + _ => None, + }; + let plain: Option<&str> = if self.crypto.is_some() { + None + } else { + b.password.as_deref() + }; + let existing: Option = conn + .query_row( + "SELECT id FROM bookmarks WHERE host = ?1", + params![b.host], + |row| row.get(0), + ) + .optional() + .map_err(|e| StorageError::Sqlite(format!("select: {e}")))?; + if let Some(id) = existing { + conn.execute( + "UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4", + params![b.nickname, plain, blob, id], + ) + .map_err(|e| StorageError::Sqlite(format!("update: {e}")))?; + Ok(id) + } else { + conn.execute( + "INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)", + params![b.display_name, b.host, b.nickname, plain, blob], + ) + .map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?; + Ok(conn.last_insert_rowid()) + } + } + /// Replace an existing bookmark identified by `id`. Errors with /// [`StorageError::NotFound`] if no such row exists. Honours the /// password-column encryption setting and clears the legacy @@ -1067,6 +1114,38 @@ mod tests { assert!(repo.list().unwrap().is_empty()); } + #[test] + fn bookmark_upsert_preserves_existing_display_name() { + force_keyring_off(); + let tmp = tempdir(); + let repo = BookmarkRepository::new(&tmp).unwrap(); + let id = repo + .add(&Bookmark { + id: 0, + display_name: "my custom title".to_string(), + host: "cn.teamspeak.app".to_string(), + nickname: "old nick".to_string(), + password: None, + }) + .unwrap(); + let upserted = repo + .upsert_or_add(&Bookmark { + id: 0, + display_name: "live server name".to_string(), + host: "cn.teamspeak.app".to_string(), + nickname: "new nick".to_string(), + password: Some("pw".to_string()), + }) + .unwrap(); + + assert_eq!(upserted, id); + let rows = repo.list().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].display_name, "my custom title"); + assert_eq!(rows[0].nickname, "new nick"); + assert_eq!(rows[0].password.as_deref(), Some("pw")); + } + #[test] fn bookmark_update_missing_is_notfound() { force_keyring_off(); @@ -1228,14 +1307,14 @@ mod tests { fn tempdir() -> PathBuf { let p = std::env::temp_dir() .join("chanora_storage_test") - .join(format!("{}", std::process::id())) - .join(format!( - "{}", + .join(std::process::id().to_string()) + .join( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() - )); + .to_string(), + ); fs::create_dir_all(&p).unwrap(); p }