From 5f1423c349d4d7d455a1a165cd71adeb9d72a657 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Fri, 5 Jun 2026 20:58:16 +0900 Subject: [PATCH] feat(voice): unified mobile voice bar with gesture-isolated PTT row (#22) * feat(voice): unified mobile voice bar with gesture-isolated PTT row Replace separate VoiceStatusChip + VoicePttButton with a single CompactVoiceBar widget that combines both into a two-row layout: - Control row (tap): status text, mute, deafen, settings chevron - PTT row (hold): full-width hold-to-talk, shown only in PTT mode Gesture isolation prevents mis-touch between rows: the control row uses tap-only InkWell/IconButton while the PTT row uses a raw Listener for pointer-down/up events. Key changes: - Add CompactVoiceBar widget with state-colored container (normal, muted, talk-power-blocked) - Remove mute/deafen IconButtons from AppBar headerActions - Restructure voice details sheet into primary section + collapsible ExpansionTiles (audio processing, PTT capability, debug) - Optimistic state updates for mute/deafen to eliminate tap delay - Instant PTT visual feedback (no AnimatedContainer fade) - Constant geometry across all states (no layout shift on toggle) * fix(voice): preserve current PTT button format * feat(voice): move mute/deafen controls into VoiceStatusChip * fix(voice): ensure consistent chip height across mute states Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip. Material 3 toggle IconButtons (_SelectableIconButton) can vary in height when the selected state changes due to tap target sizing. Use simple conditional icons instead and set shrinkWrap tap target size with tight constraints for stable 40x40 buttons regardless of state. * fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip * fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in the oboe output callback with `bytemuck::cast_slice_mut`, eliminating the unsafe block and relying on bytemuck compile-time NoUninit verification instead. Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only remaining hard-coded English tooltip in VoiceStatusChip with it. --- Cargo.lock | 1 + apps/chanora_flutter/lib/l10n/app_en.arb | 3 +- apps/chanora_flutter/lib/l10n/app_zh.arb | 3 +- .../lib/l10n/generated/app_localizations.dart | 6 + .../l10n/generated/app_localizations_en.dart | 4 + .../l10n/generated/app_localizations_zh.dart | 3 + apps/chanora_flutter/lib/main.dart | 157 +++++++++++------- .../lib/widgets/voice_compact.dart | 150 +++++++++++------ crates/chanora_audio/Cargo.toml | 5 + .../chanora_audio/src/android_voice_unit.rs | 15 +- 10 files changed, 225 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38190da..eff8065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,6 +419,7 @@ name = "chanora_audio" version = "0.2.0-beta.1" dependencies = [ "audiopus", + "bytemuck", "chanora_protocol", "coreaudio-rs", "cpal", diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index daaad93..946ba54 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -298,5 +298,6 @@ "clientVolumeMuteAction": "Mute user", "clientVolumeUnmuteAction": "Unmute user", "clientVolumeResetAction": "Reset to default", - "permissionDenied": "Permission Denied" + "permissionDenied": "Permission Denied", + "voiceTalkPowerBlocked": "Insufficient talk power to speak in this channel" } diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index d5bc8e7..40cfe96 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -241,5 +241,6 @@ "clientVolumeMuteAction": "静音该用户", "clientVolumeUnmuteAction": "取消静音", "clientVolumeResetAction": "恢复默认", - "permissionDenied": "权限被拒绝" + "permissionDenied": "权限被拒绝", + "voiceTalkPowerBlocked": "发言权限不足,无法在此频道发言" } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 5b27ffa..8618835 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -1246,6 +1246,12 @@ abstract class AppL10n { /// In en, this message translates to: /// **'Permission Denied'** String get permissionDenied; + + /// No description provided for @voiceTalkPowerBlocked. + /// + /// In en, this message translates to: + /// **'Insufficient talk power to speak in this channel'** + String get voiceTalkPowerBlocked; } class _AppL10nDelegate extends LocalizationsDelegate { 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 cdf4d65..10de84c 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -653,4 +653,8 @@ class AppL10nEn extends AppL10n { @override String get permissionDenied => 'Permission Denied'; + + @override + String get voiceTalkPowerBlocked => + 'Insufficient talk power to speak in this channel'; } 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 d90a2dd..204b418 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -641,4 +641,7 @@ class AppL10nZh extends AppL10n { @override String get permissionDenied => '权限被拒绝'; + + @override + String get voiceTalkPowerBlocked => '发言权限不足,无法在此频道发言'; } diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 52e159b..d895b24 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -385,8 +385,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { // and Notifications permission service. On non-macOS hosts the service // short-circuits to "granted" / "unsupported" and never wires the // MethodChannel. - final MacOSPermissionsService _macOSPermissions = - MacOSPermissionsService(); + final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService(); final UiPreferencesService _uiPreferences = const UiPreferencesService(); @override @@ -677,7 +676,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _reconnectDelay = null; }); case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): - _recordUiDiagnostic('connection', 'reconnecting attempt=$attempt delay=${delaySecs}s'); + _recordUiDiagnostic( + 'connection', + 'reconnecting attempt=$attempt delay=${delaySecs}s', + ); setState(() { _phase = ConnectionPhase.reconnecting; _reconnectAttempt = attempt; @@ -852,23 +854,41 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); return updated; }); - case rust.BridgeEvent_ClientJoined(:final clientId, :final channelId, :final name, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted): + case rust.BridgeEvent_ClientJoined( + :final clientId, + :final channelId, + :final name, + :final inputMuted, + :final outputMuted, + :final isServerQuery, + :final talkPower, + :final talkPowerGranted, + ): if (!isServerQuery) { - _applyClientAdd(rust.BridgeClient( - id: clientId, - channel: channelId, - name: name, - inputMuted: inputMuted, - outputMuted: outputMuted, - isSpeaking: false, - isServerQuery: isServerQuery, - talkPower: talkPower, - talkPowerGranted: talkPowerGranted, - )); + _applyClientAdd( + rust.BridgeClient( + id: clientId, + channel: channelId, + name: name, + inputMuted: inputMuted, + outputMuted: outputMuted, + isSpeaking: false, + isServerQuery: isServerQuery, + talkPower: talkPower, + talkPowerGranted: talkPowerGranted, + ), + ); } case rust.BridgeEvent_ClientLeft(:final clientId): _applyClientRemove(clientId); - case rust.BridgeEvent_ClientUpdated(:final clientId, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted): + case rust.BridgeEvent_ClientUpdated( + :final clientId, + :final inputMuted, + :final outputMuted, + :final isServerQuery, + :final talkPower, + :final talkPowerGranted, + ): _applyClientDelta((c) => c.id == clientId, (c) { final updated = rust.BridgeClient( id: c.id, @@ -883,18 +903,32 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); return updated; }); - case rust.BridgeEvent_ChannelAdded(:final id, :final parent, :final name, :final order, :final hasPassword, :final neededTalkPower): - _applyChannelAdd(rust.BridgeChannel( - id: id, - parent: parent, - name: name, - order: order, - hasPassword: hasPassword, - neededTalkPower: neededTalkPower, - )); + case rust.BridgeEvent_ChannelAdded( + :final id, + :final parent, + :final name, + :final order, + :final hasPassword, + :final neededTalkPower, + ): + _applyChannelAdd( + rust.BridgeChannel( + id: id, + parent: parent, + name: name, + order: order, + hasPassword: hasPassword, + neededTalkPower: neededTalkPower, + ), + ); case rust.BridgeEvent_ChannelRemoved(:final id): _applyChannelRemove(id); - case rust.BridgeEvent_ChannelUpdated(:final id, :final name, :final hasPassword, :final neededTalkPower): + case rust.BridgeEvent_ChannelUpdated( + :final id, + :final name, + :final hasPassword, + :final neededTalkPower, + ): _applyChannelDelta((ch) => ch.id == id, (ch) { return rust.BridgeChannel( id: ch.id, @@ -932,7 +966,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final now = DateTime.now(); if (_inChannel && (_lastSpeakingRefresh == null || - now.difference(_lastSpeakingRefresh!) >= _speakingRefreshInterval)) { + now.difference(_lastSpeakingRefresh!) >= + _speakingRefreshInterval)) { _lastSpeakingRefresh = now; unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false)); } @@ -1134,14 +1169,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _toggleOutputMute() async { final next = !_outputMuted; + setState(() { + _outputMuted = next; + }); try { await rust.setOutputMuted(muted: next); - if (!mounted) return; - setState(() { - _outputMuted = next; - }); } catch (e) { if (!mounted) return; + setState(() { + _outputMuted = !next; + }); _showUiError('output mute', e); } } @@ -1268,6 +1305,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { return; } final next = !_hardMute; + final previousInputMuted = _inputMuted; + final previousHardMute = _hardMute; + final previousPermissionMute = _hardMuteByPermission; + setState(() { + _inputMuted = next; + _hardMute = next; + if (next) { + _hardMuteByPermission = false; + } + }); try { // Hard-mute is two coordinated effects: // * setHardMute — local TransmitGate clamp; we stop sending @@ -1280,14 +1327,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { // through. Drive them together. await rust.setHardMute(muted: next); await rust.setInputMuted(muted: next); - if (!mounted) return; - setState(() { - _inputMuted = next; - _hardMute = next; - _hardMuteByPermission = false; - }); } catch (e) { if (!mounted) return; + setState(() { + _inputMuted = previousInputMuted; + _hardMute = previousHardMute; + _hardMuteByPermission = previousPermissionMute; + }); _showUiError('hard mute', e); } } @@ -1745,7 +1791,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }; } - void _applyClientDelta(bool Function(rust.BridgeClient) test, rust.BridgeClient Function(rust.BridgeClient) update) { + void _applyClientDelta( + bool Function(rust.BridgeClient) test, + rust.BridgeClient Function(rust.BridgeClient) update, + ) { final snap = _snapshot; if (snap == null) return; final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList(); @@ -1831,10 +1880,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } - void _applyChannelDelta(bool Function(rust.BridgeChannel) test, rust.BridgeChannel Function(rust.BridgeChannel) update) { + void _applyChannelDelta( + bool Function(rust.BridgeChannel) test, + rust.BridgeChannel Function(rust.BridgeChannel) update, + ) { final snap = _snapshot; if (snap == null) return; - final channels = snap.channels.map((ch) => test(ch) ? update(ch) : ch).toList(); + final channels = snap.channels + .map((ch) => test(ch) ? update(ch) : ch) + .toList(); setState(() { _snapshot = rust.BridgeSnapshot( serverName: snap.serverName, @@ -2153,26 +2207,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final theme = Theme.of(context); final headerActions = [ - if (_serverReachable && _inChannel) ...[ - IconButton( - tooltip: _hardMuteByTalkPower - ? 'Insufficient talk power to speak in this channel' - : l10n.voiceHardMuteLabel, - icon: Icon(_hardMute ? Icons.mic_off : Icons.mic), - isSelected: _hardMute, - selectedIcon: const Icon(Icons.mic_off), - color: _hardMute ? theme.colorScheme.error : null, - onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute, - ), - IconButton( - tooltip: l10n.voiceOutputMuteLabel, - icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset), - isSelected: _outputMuted, - selectedIcon: const Icon(Icons.headset_off), - color: _outputMuted ? theme.colorScheme.error : null, - onPressed: _toggleOutputMute, - ), - ], IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), @@ -2466,10 +2500,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { isTouchOnly: isTouchOnlyPttHost, inputMuted: _hardMute, outputMuted: _outputMuted, + hardMuteByTalkPower: _hardMuteByTalkPower, talkPower: ownClientState?.talkPower, neededTalkPower: ownClientState?.neededTalkPower, talkPowerGranted: ownClientState?.talkPowerGranted, onTap: () => _onOpenVoiceDetailsSheet(), + onToggleInputMute: _onToggleHardMute, + onToggleOutputMute: _toggleOutputMute, ), if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) ...[ diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index e52c599..b0f25c6 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget { required this.audioStats, required this.isTouchOnly, required this.onTap, + required this.onToggleInputMute, + required this.onToggleOutputMute, this.inputMuted = false, this.outputMuted = false, + this.hardMuteByTalkPower = false, this.talkPower, this.neededTalkPower, this.talkPowerGranted, @@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget { /// True when local speaker is muted. final bool outputMuted; + /// True when the server talk-power gate forces local hard mute. + final bool hardMuteByTalkPower; + /// Own client's talk power. final int? talkPower; @@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget { /// Open the voice details modal. final VoidCallback onTap; + /// Toggle local input hard mute. + final VoidCallback onToggleInputMute; + + /// Toggle local output mute/deafen. + final VoidCallback onToggleOutputMute; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -113,49 +125,48 @@ class VoiceStatusChip extends StatelessWidget { ); return Semantics( - button: true, label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', - hint: l10n.voiceSettingsTitle, child: Material( type: MaterialType.transparency, - child: InkWell( - onTap: () { - HapticFeedback.lightImpact(); - onTap(); - }, - borderRadius: BorderRadius.circular(12), - child: ExcludeSemantics( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - 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: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + 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: Row( + children: [ + Icon( + summary.micOn + ? Icons.fiber_manual_record + : Icons.fiber_manual_record_outlined, + size: 12, + color: summary.micOn + ? theme.colorScheme.primary + : theme.colorScheme.outline, ), - child: Row( - children: [ - Icon( - summary.micOn - ? Icons.fiber_manual_record - : Icons.fiber_manual_record_outlined, - size: 12, - color: summary.micOn - ? theme.colorScheme.primary - : theme.colorScheme.outline, - ), - const SizedBox(width: 8), - Expanded( + const SizedBox(width: 8), + Expanded( + child: InkWell( + onTap: () { + HapticFeedback.lightImpact(); + onTap(); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -179,15 +190,60 @@ class VoiceStatusChip extends StatelessWidget { ], ), ), - const SizedBox(width: 8), - Icon( - Icons.expand_less, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - ], + ), ), - ), + const SizedBox(width: 4), + IconButton( + tooltip: hardMuteByTalkPower + ? l10n.voiceTalkPowerBlocked + : l10n.voiceHardMuteLabel, + icon: Icon(inputMuted ? Icons.mic_off : Icons.mic), + color: inputMuted ? theme.colorScheme.error : null, + onPressed: hardMuteByTalkPower ? null : onToggleInputMute, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + IconButton( + tooltip: l10n.voiceOutputMuteLabel, + icon: Icon(outputMuted ? Icons.headset_off : Icons.headset), + color: outputMuted ? theme.colorScheme.error : null, + onPressed: onToggleOutputMute, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + IconButton( + tooltip: l10n.voiceSettingsTitle, + icon: Icon( + Icons.expand_less, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + onPressed: onTap, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints.tightFor( + width: 40, + height: 40, + ), + padding: EdgeInsets.zero, + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], ), ), ), diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 6dc5d8b..174666d 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -73,6 +73,11 @@ ndk-context = "0.1" # - deduplicated macro impls # - PowerSavingOffloaded PerformanceMode variant oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" } +# Safe slice reinterpret for the oboe stereo output callback. +# bytemuck::cast_slice_mut replaces the raw-pointer cast from +# `&mut [(f32, f32)]` to `&mut [f32]` with a provenance-correct +# and UB-free transmute backed by `NoUninit`. +bytemuck = { version = "1", features = ["derive"] } [target.'cfg(target_os = "windows")'.dependencies] # Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 5d069e6..43bd976 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -542,19 +542,8 @@ impl AudioOutputCallback for OutputCallback { frames: &mut [(f32, f32)], ) -> DataCallbackResult { let _ = catch_unwind(AssertUnwindSafe(|| { - // SAFETY: `frames: &mut [(f32, f32)]` is an interleaved stereo - // buffer. `(f32, f32)` has the same size (8 bytes) and alignment - // (4 bytes) as `[f32; 2]`, so reinterpreting the slice as a flat - // `&mut [f32]` of length `frames.len() * 2` is sound. The Rust - // reference does not *guarantee* `#[repr(Rust)]` tuple layout, - // but (a) both fields are identical F32 primitives with no - // padding possible, and (b) the oboe crate uses - // `#[repr(transparent)]` on its frame type alias so the ABI - // contract is upheld at the FFI boundary. `frames` is not - // accessed again after `buf` is created, so no aliasing UB. - let buf: &mut [f32] = unsafe { - std::slice::from_raw_parts_mut(frames.as_mut_ptr() as *mut f32, frames.len() * 2) - }; + let buf: &mut [f32] = + bytemuck::cast_slice_mut::<(f32, f32), f32>(frames); for s in buf.iter_mut() { *s = 0.0; }