diff --git a/apps/chanora_flutter/ios/Podfile.lock b/apps/chanora_flutter/ios/Podfile.lock index 4c59049..374e187 100644 --- a/apps/chanora_flutter/ios/Podfile.lock +++ b/apps/chanora_flutter/ios/Podfile.lock @@ -1,15 +1,20 @@ PODS: + - audio_router (1.1.1): + - Flutter - chanora_bridge (1.0.0) - connectivity_plus (0.0.1): - Flutter - Flutter (1.0.0) DEPENDENCIES: + - audio_router (from `.symlinks/plugins/audio_router/ios`) - chanora_bridge (from `.`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) EXTERNAL SOURCES: + audio_router: + :path: ".symlinks/plugins/audio_router/ios" chanora_bridge: :path: "." connectivity_plus: @@ -18,7 +23,8 @@ EXTERNAL SOURCES: :path: Flutter SPEC CHECKSUMS: - chanora_bridge: 3346a0a77a8d5707cdcd9e5d9f490e281e1f428b + audio_router: ab44b34ec1da33105ff9dc3ee1ba029f7733a4cc + chanora_bridge: af821d2c0507cb3199c91be12996bf0eb6b8bf5d connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 1632d70..56aac22 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -130,6 +130,16 @@ "voicePttHoldHint": "Hold the button", "voiceMicOn": "on", "voiceMicOff": "off", + "voiceSheetTitle": "Voice", + "voiceAdjustSettings": "Adjust mode & release tail", + "audioOutputLabel": "Audio output", + "audioRouteSpeaker": "Speaker", + "audioRouteReceiver": "iPhone receiver", + "audioRouteBluetooth": "Bluetooth", + "audioRouteWiredHeadset": "Wired headset", + "audioRouteCarAudio": "Car audio", + "audioRouteAirplay": "AirPlay", + "audioRouteUnknown": "Unknown", "channelJoinFailedPermission": "Insufficient permission to join this channel.", "channelJoinFailedPassword": "Wrong channel password.", "channelJoinFailedFull": "Channel is full.", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 141735d..87888d2 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -87,6 +87,16 @@ "voicePttHoldHint": "按住按钮", "voiceMicOn": "开启", "voiceMicOff": "关闭", + "voiceSheetTitle": "语音", + "voiceAdjustSettings": "调整模式与释放尾延时", + "audioOutputLabel": "音频输出", + "audioRouteSpeaker": "扬声器", + "audioRouteReceiver": "听筒", + "audioRouteBluetooth": "蓝牙", + "audioRouteWiredHeadset": "有线耳机", + "audioRouteCarAudio": "车载音频", + "audioRouteAirplay": "AirPlay", + "audioRouteUnknown": "未知", "channelJoinFailedPermission": "权限不足,无法加入此频道。", "channelJoinFailedPassword": "频道密码错误。", "channelJoinFailedFull": "频道已满。", diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 4735606..21b3c74 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -502,12 +502,13 @@ class _BetaHomeState extends State<_BetaHome> { } } - /// Narrow-mode voice details modal sheet (Plan E status chip - /// trigger). Surfaces level meter + TX/RX counts + capability - /// badge in a Material 3 bottom sheet. Mode + bind-key + release- - /// tail configuration still live in [VoiceSettingsDialog] reached - /// via the AppBar gear icon — there is exactly one configuration - /// surface. + /// Narrow-mode voice controls modal sheet (Plan E status chip + /// trigger). On mobile this is the **single** voice-controls + /// surface: route picker + mode/tail recap + "Adjust" button to + /// [VoiceSettingsDialog] + level meter + stats + (desktop-only) + /// capability badge. The AppBar gear icon was removed in this + /// rc.8 follow-up so there is exactly one entry point to voice + /// controls on mobile. Future _onOpenVoiceDetailsSheet() async { await showVoiceDetailsSheet( context, @@ -519,6 +520,7 @@ class _BetaHomeState extends State<_BetaHome> { pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, isTouchOnly: _isTouchOnlyPttHost, + onAdjustVoiceSettings: _onOpenVoiceSettings, ); } @@ -862,13 +864,19 @@ class _BetaHomeState extends State<_BetaHome> { channelName: _currentVoiceChannelName(), ), actions: [ - // Narrow-mode AppBar gains mic-mute + headset-mute + voice - // settings icons when the user is in a voice channel, so - // the on-screen body can dedicate ~85% of its height to - // the channel tree + the bottom-anchored PTT button. Wide - // mode keeps these controls inside the VoiceBar widget - // (left column) so the signed-off rc.8 wide-layout - // doesn't shift. + // Narrow-mode AppBar gains mic-mute + headset-mute icons + // when the user is in a voice channel, so the on-screen + // body can dedicate ~85% of its height to the channel tree + // + the bottom-anchored PTT button. Wide mode keeps these + // controls inside the VoiceBar widget (left column) so the + // signed-off rc.8 wide-layout doesn't shift. + // + // The gear icon (Icons.tune) that used to live here was + // **removed** in the rc.8 follow-up: it duplicated the + // "Adjust mode & release tail" button now inside the modal + // sheet, and the user reported the duplication. The modal + // is reached by tapping the status chip above the PTT + // button — there is now exactly one entry point. if (_phase == _Phase.connected && _inChannel && MediaQuery.of(context).size.width < 840.0) ...[ @@ -886,11 +894,6 @@ class _BetaHomeState extends State<_BetaHome> { selectedIcon: const Icon(Icons.headset_off), onPressed: _toggleOutputMute, ), - IconButton( - tooltip: l10n.voiceSettingsTitle, - icon: const Icon(Icons.tune), - onPressed: _onOpenVoiceSettings, - ), ], IconButton( tooltip: l10n.aboutAction, diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 5b5bdb7..9c02264 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -2,24 +2,19 @@ // AppBar mutes + status chip with 2-line live readout + wide bottom- // anchored PTT button + modal sheet for non-essential controls). // -// Wide-mode (>= 840 dp) keeps the existing [VoiceBar] widget; this -// file is only invoked from `main.dart` when the body is narrow. -// -// Layout in narrow mode (in voice channel, PTT mode): -// -// [ AppBar with #channel chip + 🎤 mic 🎧 headset ⚙ settings ... ] -// [ ============= channel tree (Expanded) ============== ] -// [ chip: 'PTT · Hold the button' ↑ ] -// [ '200 ms tail · Mic on' ] -// [ ┌──────────────────────────────────────────────────┐] -// [ │ 🎤 PUSH TO TALK │] PTT button -// [ └──────────────────────────────────────────────────┘] -// -// Tapping the chip opens a [showModalBottomSheet] that surfaces the -// mode radio, release-tail slider, level meter, stats, and the -// (currently rare) capability badge. The mute buttons live in the -// AppBar so they remain visible without expanding the sheet. +// rc.8 follow-up (post-iPhone-test feedback): the AppBar gear icon +// was removed; the modal sheet is now the **single** voice-controls +// surface on mobile. Mode + release-tail are reached via an +// "Adjust mode & release tail" button inside the modal that opens +// the existing [VoiceSettingsDialog]. Audio output route picker is +// new — driven by the `audio_router` plugin, which renders the +// native AVRoutePickerView on iOS and a Material 3 device list on +// Android. +import 'dart:io' show Platform; + +import 'package:audio_router/audio_router.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; @@ -27,10 +22,7 @@ import '../main.dart' show PttCapabilityBadge; import '../src/rust/api.dart' as rust; /// Two-line status chip that summarises the current voice state. -/// Tap to open the [_VoiceDetailsSheet] modal. -/// -/// Line 1: mode + bound key (or "Hold the button" on mobile) -/// Line 2: release tail + mic state +/// Tap to open the voice details modal. class VoiceStatusChip extends StatelessWidget { /// Construct a status chip. const VoiceStatusChip({ @@ -43,24 +35,22 @@ class VoiceStatusChip extends StatelessWidget { required this.onTap, }); - /// Current transmit mode (PTT / Continuous / VoiceActivity). + /// Current transmit mode. final rust.BridgeTransmitMode transmitMode; /// Release-tail in milliseconds. final int releaseTailMs; - /// Bound key label (empty on touch-only hosts where no hardware - /// key is bound — the chip's line 1 then says "Hold the button"). + /// Bound key label (empty on touch-only hosts). final String pttBoundKeyLabel; /// Current audio stats; null while audio engine not running. final rust.BridgeAudioStats? audioStats; - /// True on iOS / iPadOS / Android. Used so the chip's line 1 - /// can say "Hold the button" rather than naming a hardware key. + /// True on iOS / iPadOS / Android. final bool isTouchOnly; - /// Open the [_VoiceDetailsSheet] modal. + /// Open the voice details modal. final VoidCallback onTap; @override @@ -81,12 +71,10 @@ class VoiceStatusChip extends StatelessWidget { String line1; if (transmitMode == rust.BridgeTransmitMode.ptt) { if (isTouchOnly) { - // Touch-only hosts have no bound key; describe the - // on-screen button instead. - line1 = '$modeLabel · ${l10n.voicePttHoldHint}'; + line1 = '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'; } else { - // Desktop: name the bound key. - line1 = '$modeLabel · ${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'; + line1 = + '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'; } } else { line1 = modeLabel; @@ -96,7 +84,7 @@ class VoiceStatusChip extends StatelessWidget { ? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}' : null; final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff; - final line2 = tailText == null ? micText : '$tailText · $micText'; + final line2 = tailText == null ? micText : '$tailText \u00b7 $micText'; return Material( type: MaterialType.transparency, @@ -115,10 +103,10 @@ class VoiceStatusChip extends StatelessWidget { ), child: Row( children: [ - // Mic state dot — solid + primary while transmitting, - // outlined while idle. Icon( - micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, + micOn + ? Icons.fiber_manual_record + : Icons.fiber_manual_record_outlined, size: 12, color: micOn ? theme.colorScheme.primary @@ -163,15 +151,7 @@ class VoiceStatusChip extends StatelessWidget { } } -/// Wide, bottom-anchored push-to-talk button. Touch-and-hold drives -/// the engine via `onHeldChanged`; the bridge's release-tail timer -/// handles the trailing tail (SDD-096) so the user sees the same -/// behaviour as a hardware-key host. -/// -/// Visually: filled primary-container chip at rest, filled primary -/// (with a soft outer glow) while held. 56 dp tall by spec, matching -/// the Material 3 extended-FAB height; horizontal margin is the -/// caller's responsibility so the button matches sibling content. +/// Wide, bottom-anchored push-to-talk button. class VoicePttButton extends StatefulWidget { /// Construct a PTT button. const VoicePttButton({ @@ -180,13 +160,10 @@ class VoicePttButton extends StatefulWidget { required this.onHeldChanged, }); - /// True while the engine reports the gate open (mirrors the level - /// meter's active flag). Drives the held-style visuals so the - /// user gets feedback that holding actually engaged the mic. + /// True while the engine reports the gate open. final bool active; - /// Called with `true` on finger-down, `false` on finger-up or - /// gesture cancel. Map to `setPtt(active: held)` on the caller. + /// Called with `true` on finger-down, `false` on finger-up or cancel. final ValueChanged onHeldChanged; @override @@ -265,22 +242,15 @@ class _VoicePttButtonState extends State { } } -/// Show the voice details modal sheet. Returns when the user -/// dismisses (taps outside / swipes down / hits the close button). -/// -/// Surfaces the non-essential controls + live readouts that don't -/// fit in the AppBar or the status chip: -/// * Mic level meter -/// * TX / RX frame counts + mic state line -/// * PTT capability badge (only on desktop; touch-only hosts hide -/// this because the capability story is always "L0 Focused via -/// on-screen button" and the on-screen button is itself the -/// evidence) -/// -/// Mode + release-tail + bind-key are intentionally NOT duplicated -/// here — those still live in `VoiceSettingsDialog` reachable from -/// the AppBar's settings icon, so there's exactly one configuration -/// surface. +/// Show the voice controls modal sheet — the single voice-controls +/// surface on mobile. Tiles: +/// 1. Audio output route picker (iOS native AVRoutePickerView / +/// Android Material 3 list). Mobile only. +/// 2. Mode + bind / tail recap (display). +/// 3. "Adjust mode & release tail" button → [VoiceSettingsDialog]. +/// 4. Mic level meter. +/// 5. TX / RX frame counts. +/// 6. PTT capability badge (desktop only). Future showVoiceDetailsSheet( BuildContext context, { required rust.BridgeAudioStats? audioStats, @@ -291,10 +261,12 @@ Future showVoiceDetailsSheet( required String pttBackendId, required String pttBoundInputClass, required bool isTouchOnly, + required VoidCallback onAdjustVoiceSettings, }) async { await showModalBottomSheet( context: context, showDragHandle: true, + isScrollControlled: true, builder: (ctx) { final theme = Theme.of(ctx); final l10n = AppL10n.of(ctx); @@ -309,19 +281,36 @@ Future showVoiceDetailsSheet( '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})', }; + // Route picker only meaningful on iOS + Android where the OS + // owns audio routing. Desktop hosts skip the tile entirely. + final showRoutePicker = + !kIsWeb && (Platform.isIOS || Platform.isAndroid); + return SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - l10n.voiceSettingsTitle, + l10n.voiceSheetTitle, style: theme.textTheme.titleLarge, ), const SizedBox(height: 16), - // Mode + (PTT-only) bind / release-tail recap line. + + // 1) Audio output route picker tile (mobile only). + if (showRoutePicker) ...[ + const _AudioOutputTile(), + const SizedBox(height: 12), + Divider( + height: 1, + color: theme.colorScheme.outlineVariant, + ), + const SizedBox(height: 12), + ], + + // 2) Mode + bind / tail recap (display only). Row( children: [ Icon( @@ -346,18 +335,32 @@ Future showVoiceDetailsSheet( padding: const EdgeInsets.only(left: 26), child: Text( isTouchOnly - ? '${l10n.voicePttHoldHint} · ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}' + ? '${l10n.voicePttHoldHint} \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}' : '${l10n.voiceModePtt}: ' - '${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}' - ' · ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', + '${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}' + ' \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], + const SizedBox(height: 12), + + // 3) Adjust button — opens VoiceSettingsDialog. One + // config form for mode + tail (+ bind on desktop), + // not two. + OutlinedButton.icon( + onPressed: () { + Navigator.of(ctx).pop(); + onAdjustVoiceSettings(); + }, + icon: const Icon(Icons.tune), + label: Text(l10n.voiceAdjustSettings), + ), const SizedBox(height: 16), - // Level meter. + + // 4) Level meter. _LevelMeter(active: levelActive), const SizedBox(height: 6), if (stats != null) @@ -369,9 +372,8 @@ Future showVoiceDetailsSheet( ), style: theme.textTheme.bodySmall, ), - // PTT capability badge — desktop-only (the touch-only - // story is "L0 Focused via on-screen button" which is - // already visually obvious from the PTT button). + + // 5) PTT capability badge — desktop-only. if (isPtt && !isTouchOnly) ...[ const SizedBox(height: 12), PttCapabilityBadge( @@ -388,6 +390,116 @@ Future showVoiceDetailsSheet( ); } +/// 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. +class _AudioOutputTile extends StatefulWidget { + const _AudioOutputTile(); + + @override + State<_AudioOutputTile> createState() => _AudioOutputTileState(); +} + +class _AudioOutputTileState extends State<_AudioOutputTile> { + final AudioRouter _router = AudioRouter(); + AudioDevice? _device; + + @override + void initState() { + super.initState(); + _router.currentDeviceStream.listen((dev) { + if (!mounted) return; + setState(() => _device = dev); + }); + } + + String _deviceLabel(AudioSourceType? type, AppL10n l10n) { + switch (type) { + case AudioSourceType.builtinSpeaker: + return l10n.audioRouteSpeaker; + case AudioSourceType.builtinReceiver: + return l10n.audioRouteReceiver; + case AudioSourceType.bluetooth: + return l10n.audioRouteBluetooth; + case AudioSourceType.wiredHeadset: + return l10n.audioRouteWiredHeadset; + case AudioSourceType.carAudio: + return l10n.audioRouteCarAudio; + case AudioSourceType.airplay: + return l10n.audioRouteAirplay; + case AudioSourceType.unknown: + case null: + return l10n.audioRouteUnknown; + } + } + + IconData _deviceIcon(AudioSourceType? type) { + switch (type) { + case AudioSourceType.builtinSpeaker: + return Icons.volume_up; + case AudioSourceType.builtinReceiver: + return Icons.phone_in_talk; + case AudioSourceType.bluetooth: + return Icons.bluetooth_audio; + case AudioSourceType.wiredHeadset: + return Icons.headset; + case AudioSourceType.carAudio: + return Icons.directions_car; + case AudioSourceType.airplay: + return Icons.airplay; + case AudioSourceType.unknown: + case null: + return Icons.speaker; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + return InkWell( + onTap: () => _router.showAudioRoutePicker(context), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), + child: Row( + children: [ + Icon( + _deviceIcon(_device?.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( + _deviceLabel(_device?.type, l10n), + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + class _LevelMeter extends StatelessWidget { const _LevelMeter({required this.active}); diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock index 7739c6d..6681d64 100644 --- a/apps/chanora_flutter/pubspec.lock +++ b/apps/chanora_flutter/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_router: + dependency: "direct main" + description: + name: audio_router + sha256: "4dd8b65870f915ef1e7a08ca97898b9ac12c44d3e236ac272bb8f1f68065f48f" + url: "https://pub.dev" + source: hosted + version: "1.1.1" boolean_selector: dependency: transitive description: diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 6192b1c..b8e4564 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -41,6 +41,20 @@ dependencies: freezed_annotation: ^3.1.0 connectivity_plus: ^6.1.0 path_provider: ^2.1.4 + # audio_router 1.1.1 (MIT, supports Android + iOS) drives the + # "Audio output" device picker on mobile. iOS displays the native + # AVRoutePickerView (AirPlay/Bluetooth/Speaker/Receiver). Android + # gets a Material 3 dialog backed by AudioManager.setCommunicationDevice. + # No-op on desktop \u2014 we only import the symbols and gate use behind + # Platform.isIOS / Platform.isAndroid at the call site. + # + # Prerequisite the plugin documents: the audio session must be + # configured *before* the picker is shown. We already set + # AVAudioSession to .playAndRecord/.voiceChat in AppDelegate.swift + # (iOS 0466000), so the plugin's iOS path is satisfied. Android + # has no audio session yet \u2014 will be addressed when we wire up + # the Android target post-rc.8. + audio_router: ^1.1.1 dev_dependencies: flutter_test: