feat(ui,ios,android): audio output route picker + collapse voice controls into single modal
Two user-reported issues addressed:
1. 'on supported devices such as iphone or android user should be
able to select audio device such as speaker or airpods or phone'
2. 'the bottom folder is duplicated with the app bar settings'
Issue 1 \u2014 audio output route picker:
Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:
* iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
Control Center's audio chooser. Lists Speaker / iPhone receiver /
AirPods / connected Bluetooth devices / AirPlay / CarPlay.
System manages the device list; we don't have to track route
changes manually.
* Android (post-rc.8 when we wire the platform): Material Design 3
dialog backed by AudioManager.setCommunicationDevice() with
SCO Bluetooth + USB headsets filtered for VoIP.
The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit 0466000.
The new _AudioOutputTile widget in voice_compact.dart subscribes to
AudioRouter.currentDeviceStream so the row label + icon auto-update
when the user plugs in headphones, connects AirPods, etc. \u2014 no
manual KVO observation needed.
Tile is mobile-only (Platform.isIOS || Platform.isAndroid). Desktop
hosts continue to use the system mixer; the tile is hidden.
Plugin caveat: the published enum AudioSourceType has no .usb
variant (despite README mentioning USB support). We map only the
seven real enum cases: builtinSpeaker / builtinReceiver / bluetooth /
wiredHeadset / carAudio / airplay / unknown.
Issue 2 \u2014 collapse voice controls into the single modal sheet:
The AppBar gear icon (Icons.tune) that opened VoiceSettingsDialog
was removed. It duplicated the configuration entry point that the
status chip \u2192 modal-sheet path already provides, and the user found
that duplication confusing on a phone-narrow screen where AppBar
real estate is precious.
The voice modal sheet (showVoiceDetailsSheet) is now the **single**
voice-controls surface on mobile, with layout (top to bottom):
1. Audio output route picker tile (iOS / Android only) \u2014 new.
2. Mode + bind / release-tail recap (display only).
3. 'Adjust mode & release tail' OutlinedButton that closes the
sheet and opens the same VoiceSettingsDialog the gear icon
used to open. One config form, not two.
4. Mic level meter.
5. TX / RX frame counts + mic state.
6. PTT capability badge (desktop only).
Sheet title renamed from 'Voice settings' (which collided with the
gear-icon tooltip) to 'Voice'. New l10n keys: voiceSheetTitle,
voiceAdjustSettings, audioOutputLabel, audioRoute{Speaker,Receiver,
Bluetooth,WiredHeadset,CarAudio,Airplay,Unknown}. en + zh translated.
Build: flutter build ios --release --no-codesign clean, 50.5 s,
Runner.app 30.2 MB (+200 KB from audio_router). flutter analyze
clean (6 pre-existing Radio.groupValue deprecation infos in
voice_settings.dart, unchanged).
This commit is contained in:
@@ -1,15 +1,20 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- audio_router (1.1.1):
|
||||||
|
- Flutter
|
||||||
- chanora_bridge (1.0.0)
|
- chanora_bridge (1.0.0)
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- audio_router (from `.symlinks/plugins/audio_router/ios`)
|
||||||
- chanora_bridge (from `.`)
|
- chanora_bridge (from `.`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
audio_router:
|
||||||
|
:path: ".symlinks/plugins/audio_router/ios"
|
||||||
chanora_bridge:
|
chanora_bridge:
|
||||||
:path: "."
|
:path: "."
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
@@ -18,7 +23,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 3346a0a77a8d5707cdcd9e5d9f490e281e1f428b
|
audio_router: ab44b34ec1da33105ff9dc3ee1ba029f7733a4cc
|
||||||
|
chanora_bridge: af821d2c0507cb3199c91be12996bf0eb6b8bf5d
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,16 @@
|
|||||||
"voicePttHoldHint": "Hold the button",
|
"voicePttHoldHint": "Hold the button",
|
||||||
"voiceMicOn": "on",
|
"voiceMicOn": "on",
|
||||||
"voiceMicOff": "off",
|
"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.",
|
"channelJoinFailedPermission": "Insufficient permission to join this channel.",
|
||||||
"channelJoinFailedPassword": "Wrong channel password.",
|
"channelJoinFailedPassword": "Wrong channel password.",
|
||||||
"channelJoinFailedFull": "Channel is full.",
|
"channelJoinFailedFull": "Channel is full.",
|
||||||
|
|||||||
@@ -87,6 +87,16 @@
|
|||||||
"voicePttHoldHint": "按住按钮",
|
"voicePttHoldHint": "按住按钮",
|
||||||
"voiceMicOn": "开启",
|
"voiceMicOn": "开启",
|
||||||
"voiceMicOff": "关闭",
|
"voiceMicOff": "关闭",
|
||||||
|
"voiceSheetTitle": "语音",
|
||||||
|
"voiceAdjustSettings": "调整模式与释放尾延时",
|
||||||
|
"audioOutputLabel": "音频输出",
|
||||||
|
"audioRouteSpeaker": "扬声器",
|
||||||
|
"audioRouteReceiver": "听筒",
|
||||||
|
"audioRouteBluetooth": "蓝牙",
|
||||||
|
"audioRouteWiredHeadset": "有线耳机",
|
||||||
|
"audioRouteCarAudio": "车载音频",
|
||||||
|
"audioRouteAirplay": "AirPlay",
|
||||||
|
"audioRouteUnknown": "未知",
|
||||||
"channelJoinFailedPermission": "权限不足,无法加入此频道。",
|
"channelJoinFailedPermission": "权限不足,无法加入此频道。",
|
||||||
"channelJoinFailedPassword": "频道密码错误。",
|
"channelJoinFailedPassword": "频道密码错误。",
|
||||||
"channelJoinFailedFull": "频道已满。",
|
"channelJoinFailedFull": "频道已满。",
|
||||||
|
|||||||
@@ -502,12 +502,13 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Narrow-mode voice details modal sheet (Plan E status chip
|
/// Narrow-mode voice controls modal sheet (Plan E status chip
|
||||||
/// trigger). Surfaces level meter + TX/RX counts + capability
|
/// trigger). On mobile this is the **single** voice-controls
|
||||||
/// badge in a Material 3 bottom sheet. Mode + bind-key + release-
|
/// surface: route picker + mode/tail recap + "Adjust" button to
|
||||||
/// tail configuration still live in [VoiceSettingsDialog] reached
|
/// [VoiceSettingsDialog] + level meter + stats + (desktop-only)
|
||||||
/// via the AppBar gear icon — there is exactly one configuration
|
/// capability badge. The AppBar gear icon was removed in this
|
||||||
/// surface.
|
/// rc.8 follow-up so there is exactly one entry point to voice
|
||||||
|
/// controls on mobile.
|
||||||
Future<void> _onOpenVoiceDetailsSheet() async {
|
Future<void> _onOpenVoiceDetailsSheet() async {
|
||||||
await showVoiceDetailsSheet(
|
await showVoiceDetailsSheet(
|
||||||
context,
|
context,
|
||||||
@@ -519,6 +520,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
pttBackendId: _pttBackendId,
|
pttBackendId: _pttBackendId,
|
||||||
pttBoundInputClass: _pttBoundInputClass,
|
pttBoundInputClass: _pttBoundInputClass,
|
||||||
isTouchOnly: _isTouchOnlyPttHost,
|
isTouchOnly: _isTouchOnlyPttHost,
|
||||||
|
onAdjustVoiceSettings: _onOpenVoiceSettings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,13 +864,19 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
channelName: _currentVoiceChannelName(),
|
channelName: _currentVoiceChannelName(),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
// Narrow-mode AppBar gains mic-mute + headset-mute + voice
|
// Narrow-mode AppBar gains mic-mute + headset-mute icons
|
||||||
// settings icons when the user is in a voice channel, so
|
// when the user is in a voice channel, so the on-screen
|
||||||
// the on-screen body can dedicate ~85% of its height to
|
// body can dedicate ~85% of its height to the channel tree
|
||||||
// the channel tree + the bottom-anchored PTT button. Wide
|
// + the bottom-anchored PTT button. Wide mode keeps these
|
||||||
// mode keeps these controls inside the VoiceBar widget
|
// controls inside the VoiceBar widget (left column) so the
|
||||||
// (left column) so the signed-off rc.8 wide-layout
|
// signed-off rc.8 wide-layout doesn't shift.
|
||||||
// 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 &&
|
if (_phase == _Phase.connected &&
|
||||||
_inChannel &&
|
_inChannel &&
|
||||||
MediaQuery.of(context).size.width < 840.0) ...[
|
MediaQuery.of(context).size.width < 840.0) ...[
|
||||||
@@ -886,11 +894,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
selectedIcon: const Icon(Icons.headset_off),
|
selectedIcon: const Icon(Icons.headset_off),
|
||||||
onPressed: _toggleOutputMute,
|
onPressed: _toggleOutputMute,
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
tooltip: l10n.voiceSettingsTitle,
|
|
||||||
icon: const Icon(Icons.tune),
|
|
||||||
onPressed: _onOpenVoiceSettings,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.aboutAction,
|
tooltip: l10n.aboutAction,
|
||||||
|
|||||||
@@ -2,24 +2,19 @@
|
|||||||
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
||||||
// anchored PTT button + modal sheet for non-essential controls).
|
// anchored PTT button + modal sheet for non-essential controls).
|
||||||
//
|
//
|
||||||
// Wide-mode (>= 840 dp) keeps the existing [VoiceBar] widget; this
|
// rc.8 follow-up (post-iPhone-test feedback): the AppBar gear icon
|
||||||
// file is only invoked from `main.dart` when the body is narrow.
|
// was removed; the modal sheet is now the **single** voice-controls
|
||||||
//
|
// surface on mobile. Mode + release-tail are reached via an
|
||||||
// Layout in narrow mode (in voice channel, PTT mode):
|
// "Adjust mode & release tail" button inside the modal that opens
|
||||||
//
|
// the existing [VoiceSettingsDialog]. Audio output route picker is
|
||||||
// [ AppBar with #channel chip + 🎤 mic 🎧 headset ⚙ settings ... ]
|
// new — driven by the `audio_router` plugin, which renders the
|
||||||
// [ ============= channel tree (Expanded) ============== ]
|
// native AVRoutePickerView on iOS and a Material 3 device list on
|
||||||
// [ chip: 'PTT · Hold the button' ↑ ]
|
// Android.
|
||||||
// [ '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.
|
|
||||||
|
|
||||||
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
@@ -27,10 +22,7 @@ import '../main.dart' show PttCapabilityBadge;
|
|||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
/// Two-line status chip that summarises the current voice state.
|
/// Two-line status chip that summarises the current voice state.
|
||||||
/// Tap to open the [_VoiceDetailsSheet] modal.
|
/// Tap to open the voice details modal.
|
||||||
///
|
|
||||||
/// Line 1: mode + bound key (or "Hold the button" on mobile)
|
|
||||||
/// Line 2: release tail + mic state
|
|
||||||
class VoiceStatusChip extends StatelessWidget {
|
class VoiceStatusChip extends StatelessWidget {
|
||||||
/// Construct a status chip.
|
/// Construct a status chip.
|
||||||
const VoiceStatusChip({
|
const VoiceStatusChip({
|
||||||
@@ -43,24 +35,22 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Current transmit mode (PTT / Continuous / VoiceActivity).
|
/// Current transmit mode.
|
||||||
final rust.BridgeTransmitMode transmitMode;
|
final rust.BridgeTransmitMode transmitMode;
|
||||||
|
|
||||||
/// Release-tail in milliseconds.
|
/// Release-tail in milliseconds.
|
||||||
final int releaseTailMs;
|
final int releaseTailMs;
|
||||||
|
|
||||||
/// Bound key label (empty on touch-only hosts where no hardware
|
/// Bound key label (empty on touch-only hosts).
|
||||||
/// key is bound — the chip's line 1 then says "Hold the button").
|
|
||||||
final String pttBoundKeyLabel;
|
final String pttBoundKeyLabel;
|
||||||
|
|
||||||
/// Current audio stats; null while audio engine not running.
|
/// Current audio stats; null while audio engine not running.
|
||||||
final rust.BridgeAudioStats? audioStats;
|
final rust.BridgeAudioStats? audioStats;
|
||||||
|
|
||||||
/// True on iOS / iPadOS / Android. Used so the chip's line 1
|
/// True on iOS / iPadOS / Android.
|
||||||
/// can say "Hold the button" rather than naming a hardware key.
|
|
||||||
final bool isTouchOnly;
|
final bool isTouchOnly;
|
||||||
|
|
||||||
/// Open the [_VoiceDetailsSheet] modal.
|
/// Open the voice details modal.
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -81,12 +71,10 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
String line1;
|
String line1;
|
||||||
if (transmitMode == rust.BridgeTransmitMode.ptt) {
|
if (transmitMode == rust.BridgeTransmitMode.ptt) {
|
||||||
if (isTouchOnly) {
|
if (isTouchOnly) {
|
||||||
// Touch-only hosts have no bound key; describe the
|
line1 = '$modeLabel \u00b7 ${l10n.voicePttHoldHint}';
|
||||||
// on-screen button instead.
|
|
||||||
line1 = '$modeLabel · ${l10n.voicePttHoldHint}';
|
|
||||||
} else {
|
} else {
|
||||||
// Desktop: name the bound key.
|
line1 =
|
||||||
line1 = '$modeLabel · ${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}';
|
'$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
line1 = modeLabel;
|
line1 = modeLabel;
|
||||||
@@ -96,7 +84,7 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
|
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
|
||||||
: null;
|
: null;
|
||||||
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
|
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
|
||||||
final line2 = tailText == null ? micText : '$tailText · $micText';
|
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
type: MaterialType.transparency,
|
type: MaterialType.transparency,
|
||||||
@@ -115,10 +103,10 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Mic state dot — solid + primary while transmitting,
|
|
||||||
// outlined while idle.
|
|
||||||
Icon(
|
Icon(
|
||||||
micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined,
|
micOn
|
||||||
|
? Icons.fiber_manual_record
|
||||||
|
: Icons.fiber_manual_record_outlined,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: micOn
|
color: micOn
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
@@ -163,15 +151,7 @@ class VoiceStatusChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wide, bottom-anchored push-to-talk button. Touch-and-hold drives
|
/// Wide, bottom-anchored push-to-talk button.
|
||||||
/// 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.
|
|
||||||
class VoicePttButton extends StatefulWidget {
|
class VoicePttButton extends StatefulWidget {
|
||||||
/// Construct a PTT button.
|
/// Construct a PTT button.
|
||||||
const VoicePttButton({
|
const VoicePttButton({
|
||||||
@@ -180,13 +160,10 @@ class VoicePttButton extends StatefulWidget {
|
|||||||
required this.onHeldChanged,
|
required this.onHeldChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// True while the engine reports the gate open (mirrors the level
|
/// True while the engine reports the gate open.
|
||||||
/// meter's active flag). Drives the held-style visuals so the
|
|
||||||
/// user gets feedback that holding actually engaged the mic.
|
|
||||||
final bool active;
|
final bool active;
|
||||||
|
|
||||||
/// Called with `true` on finger-down, `false` on finger-up or
|
/// Called with `true` on finger-down, `false` on finger-up or cancel.
|
||||||
/// gesture cancel. Map to `setPtt(active: held)` on the caller.
|
|
||||||
final ValueChanged<bool> onHeldChanged;
|
final ValueChanged<bool> onHeldChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -265,22 +242,15 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the voice details modal sheet. Returns when the user
|
/// Show the voice controls modal sheet — the single voice-controls
|
||||||
/// dismisses (taps outside / swipes down / hits the close button).
|
/// surface on mobile. Tiles:
|
||||||
///
|
/// 1. Audio output route picker (iOS native AVRoutePickerView /
|
||||||
/// Surfaces the non-essential controls + live readouts that don't
|
/// Android Material 3 list). Mobile only.
|
||||||
/// fit in the AppBar or the status chip:
|
/// 2. Mode + bind / tail recap (display).
|
||||||
/// * Mic level meter
|
/// 3. "Adjust mode & release tail" button → [VoiceSettingsDialog].
|
||||||
/// * TX / RX frame counts + mic state line
|
/// 4. Mic level meter.
|
||||||
/// * PTT capability badge (only on desktop; touch-only hosts hide
|
/// 5. TX / RX frame counts.
|
||||||
/// this because the capability story is always "L0 Focused via
|
/// 6. PTT capability badge (desktop only).
|
||||||
/// 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.
|
|
||||||
Future<void> showVoiceDetailsSheet(
|
Future<void> showVoiceDetailsSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required rust.BridgeAudioStats? audioStats,
|
required rust.BridgeAudioStats? audioStats,
|
||||||
@@ -291,10 +261,12 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
required String pttBackendId,
|
required String pttBackendId,
|
||||||
required String pttBoundInputClass,
|
required String pttBoundInputClass,
|
||||||
required bool isTouchOnly,
|
required bool isTouchOnly,
|
||||||
|
required VoidCallback onAdjustVoiceSettings,
|
||||||
}) async {
|
}) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
|
isScrollControlled: true,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
final theme = Theme.of(ctx);
|
final theme = Theme.of(ctx);
|
||||||
final l10n = AppL10n.of(ctx);
|
final l10n = AppL10n.of(ctx);
|
||||||
@@ -309,19 +281,36 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
|
'${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(
|
return SafeArea(
|
||||||
child: Padding(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
l10n.voiceSettingsTitle,
|
l10n.voiceSheetTitle,
|
||||||
style: theme.textTheme.titleLarge,
|
style: theme.textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -346,18 +335,32 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
padding: const EdgeInsets.only(left: 26),
|
padding: const EdgeInsets.only(left: 26),
|
||||||
child: Text(
|
child: Text(
|
||||||
isTouchOnly
|
isTouchOnly
|
||||||
? '${l10n.voicePttHoldHint} · ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}'
|
? '${l10n.voicePttHoldHint} \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}'
|
||||||
: '${l10n.voiceModePtt}: '
|
: '${l10n.voiceModePtt}: '
|
||||||
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
'${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'
|
||||||
' · ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
' \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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),
|
const SizedBox(height: 16),
|
||||||
// Level meter.
|
|
||||||
|
// 4) Level meter.
|
||||||
_LevelMeter(active: levelActive),
|
_LevelMeter(active: levelActive),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (stats != null)
|
if (stats != null)
|
||||||
@@ -369,9 +372,8 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
),
|
),
|
||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
// PTT capability badge — desktop-only (the touch-only
|
|
||||||
// story is "L0 Focused via on-screen button" which is
|
// 5) PTT capability badge — desktop-only.
|
||||||
// already visually obvious from the PTT button).
|
|
||||||
if (isPtt && !isTouchOnly) ...[
|
if (isPtt && !isTouchOnly) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
PttCapabilityBadge(
|
PttCapabilityBadge(
|
||||||
@@ -388,6 +390,116 @@ Future<void> 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 {
|
class _LevelMeter extends StatelessWidget {
|
||||||
const _LevelMeter({required this.active});
|
const _LevelMeter({required this.active});
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ dependencies:
|
|||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
connectivity_plus: ^6.1.0
|
connectivity_plus: ^6.1.0
|
||||||
path_provider: ^2.1.4
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user