User reported: the 'output device' picker only listed AirPlay
destinations (other iPhones / AirPlay speakers / AppleTV) and not
the speaker / iPhone receiver / AirPods / wired headset choices.
Root cause: audio_router 1.1.1's iOS path uses AVRoutePickerView,
which is Apple's **AirPlay** picker UI \u2014 by design it only lists
AirPlay-eligible output destinations, NOT the input/output route
choices we need (speaker vs receiver vs Bluetooth HFP vs wired).
AVRoutePickerView is the right UI for 'cast audio elsewhere'; for
'pick how I hear / talk' (VoIP) the right primitive is direct
AVAudioSession calls.
Fix: replace audio_router with audio_session 0.2.3 (Ryan Heise,
verified publisher, 865k downloads, MIT). audio_session exposes:
* AVAudioSession.availableInputs \u2014 enumerate every real input
port: builtInMic, bluetoothHfp, bluetoothA2dp, headsetMic
(wired), usbAudio, carAudio, airPlay.
* AVAudioSession.currentRoute \u2014 .inputs + .outputs of the
active route.
* AVAudioSession.setPreferredInput(port) \u2014 switch the input
(HFP / wired / USB / car audio also move output to themselves).
* AVAudioSession.overrideOutputAudioPort(.speaker | .none) \u2014
toggle built-in speakerphone vs receiver/earpiece.
* AVAudioSession.routeChangeStream \u2014 live notifications when
the user plugs / unplugs / connects a device while the picker
is open.
This is exactly the same primitive Discord, WhatsApp, FaceTime
use for their VoIP audio chooser. No native UI plugin needed.
New widgets in voice_compact.dart:
* _AudioOutputTile: shows the active output port name (Speaker /
iPhone / AirPods / 'Phil's Wired Headset' / etc.) with the
matching icon. Subscribes to routeChangeStream for live
updates. Tap opens _AudioOutputPickerSheet.
* _AudioOutputPickerSheet: bottom sheet with 'Choose audio' title
and a Discord-style list:
- Speaker (volume_up)
- iPhone (phone_in_talk; the receiver/earpiece)
- <BT name> (bluetooth_audio)
- <Wired headset> (headset)
- <USB / Car> (usb / directions_car)
Selected row is highlighted + has a check mark. Tap routes:
- Speaker -> overrideOutputAudioPort(.speaker)
- iPhone -> overrideOutputAudioPort(.none) + setPreferredInput(builtInMic)
- External -> overrideOutputAudioPort(.none) + setPreferredInput(port)
* _PickerRow: shared row widget with selected/check styling.
AppDelegate.swift is unchanged: the manual AVAudioSession
.setCategory(playAndRecord / .voiceChat) we already do at launch
(0466000 / 4ee2b38) is fully compatible with audio_session \u2014 the
plugin only adds Dart-side accessors over the same underlying
AVAudioSession singleton.
Removed l10n keys not used anymore (audioRouteUsb was already gone).
Kept audioRouteSpeaker / Receiver / Bluetooth / WiredHeadset /
CarAudio / Airplay / Unknown \u2014 all still used by the new picker.
flutter analyze: 6 pre-existing Radio deprecation infos (unchanged).
flutter build ios --release --no-codesign: 54.9 s, Runner.app
30.4 MB (+200 KB vs audio_router build).
949 lines
30 KiB
Dart
949 lines
30 KiB
Dart
// Compact voice UI for narrow / mobile layouts (Plan E hybrid:
|
|
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
|
// anchored PTT button + modal sheet for non-essential controls).
|
|
//
|
|
// 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.
|
|
|
|
import 'dart:async' show StreamSubscription;
|
|
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 '../l10n/generated/app_localizations.dart';
|
|
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 voice details modal.
|
|
class VoiceStatusChip extends StatelessWidget {
|
|
/// Construct a status chip.
|
|
const VoiceStatusChip({
|
|
super.key,
|
|
required this.transmitMode,
|
|
required this.releaseTailMs,
|
|
required this.pttBoundKeyLabel,
|
|
required this.audioStats,
|
|
required this.isTouchOnly,
|
|
required this.onTap,
|
|
});
|
|
|
|
/// Current transmit mode.
|
|
final rust.BridgeTransmitMode transmitMode;
|
|
|
|
/// Release-tail in milliseconds.
|
|
final int releaseTailMs;
|
|
|
|
/// 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.
|
|
final bool isTouchOnly;
|
|
|
|
/// Open the voice details modal.
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l10n = AppL10n.of(context);
|
|
|
|
final stats = audioStats;
|
|
final micOn = stats?.pttActive ?? false;
|
|
|
|
final modeLabel = switch (transmitMode) {
|
|
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
|
|
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
|
|
rust.BridgeTransmitMode.voiceActivity =>
|
|
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
|
|
};
|
|
|
|
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 = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
|
|
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
|
|
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: theme.colorScheme.outlineVariant,
|
|
width: 0.5,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
micOn
|
|
? Icons.fiber_manual_record
|
|
: Icons.fiber_manual_record_outlined,
|
|
size: 12,
|
|
color: micOn
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.outline,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
line1,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
Text(
|
|
line2,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.expand_less,
|
|
size: 18,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Wide, bottom-anchored push-to-talk button.
|
|
class VoicePttButton extends StatefulWidget {
|
|
/// Construct a PTT button.
|
|
const VoicePttButton({
|
|
super.key,
|
|
required this.active,
|
|
required this.onHeldChanged,
|
|
});
|
|
|
|
/// True while the engine reports the gate open.
|
|
final bool active;
|
|
|
|
/// Called with `true` on finger-down, `false` on finger-up or cancel.
|
|
final ValueChanged<bool> onHeldChanged;
|
|
|
|
@override
|
|
State<VoicePttButton> createState() => _VoicePttButtonState();
|
|
}
|
|
|
|
class _VoicePttButtonState extends State<VoicePttButton> {
|
|
bool _pressed = false;
|
|
|
|
void _setHeld(bool held) {
|
|
if (_pressed == held) return;
|
|
setState(() => _pressed = held);
|
|
widget.onHeldChanged(held);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l10n = AppL10n.of(context);
|
|
final activeNow = _pressed || widget.active;
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (_) => _setHeld(true),
|
|
onTapUp: (_) => _setHeld(false),
|
|
onTapCancel: () => _setHeld(false),
|
|
onPanDown: (_) => _setHeld(true),
|
|
onPanEnd: (_) => _setHeld(false),
|
|
onPanCancel: () => _setHeld(false),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 80),
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: activeNow
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: activeNow
|
|
? [
|
|
BoxShadow(
|
|
color: theme.colorScheme.primary.withAlpha(100),
|
|
blurRadius: 16,
|
|
spreadRadius: 2,
|
|
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: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.4,
|
|
color: activeNow
|
|
? theme.colorScheme.onPrimary
|
|
: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 radio buttons (PTT / Continuous; VoiceActivity disabled
|
|
/// coming-soon).
|
|
/// 3. Release-tail slider (PTT-only).
|
|
/// 4. Mic level meter.
|
|
/// 5. TX / RX frame counts.
|
|
/// 6. PTT capability badge (desktop only).
|
|
///
|
|
/// Mode + release-tail are inlined directly here instead of being
|
|
/// hidden behind an "Adjust" button → nested dialog. Single-screen
|
|
/// control panel, zero navigation depth. `onModeChanged` and
|
|
/// `onReleaseTailChanged` are debounced by the caller so users can
|
|
/// drag the slider freely.
|
|
Future<void> showVoiceDetailsSheet(
|
|
BuildContext context, {
|
|
required rust.BridgeAudioStats? audioStats,
|
|
required rust.BridgeTransmitMode transmitMode,
|
|
required int releaseTailMs,
|
|
required String pttBoundKeyLabel,
|
|
required String pttLevel,
|
|
required String pttBackendId,
|
|
required String pttBoundInputClass,
|
|
required bool isTouchOnly,
|
|
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
|
required ValueChanged<int> onReleaseTailChanged,
|
|
}) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
isScrollControlled: true,
|
|
builder: (ctx) {
|
|
return _VoiceSheetBody(
|
|
audioStats: audioStats,
|
|
initialMode: transmitMode,
|
|
initialReleaseTailMs: releaseTailMs,
|
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
|
pttLevel: pttLevel,
|
|
pttBackendId: pttBackendId,
|
|
pttBoundInputClass: pttBoundInputClass,
|
|
isTouchOnly: isTouchOnly,
|
|
onModeChanged: onModeChanged,
|
|
onReleaseTailChanged: onReleaseTailChanged,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _VoiceSheetBody extends StatefulWidget {
|
|
const _VoiceSheetBody({
|
|
required this.audioStats,
|
|
required this.initialMode,
|
|
required this.initialReleaseTailMs,
|
|
required this.pttBoundKeyLabel,
|
|
required this.pttLevel,
|
|
required this.pttBackendId,
|
|
required this.pttBoundInputClass,
|
|
required this.isTouchOnly,
|
|
required this.onModeChanged,
|
|
required this.onReleaseTailChanged,
|
|
});
|
|
|
|
final rust.BridgeAudioStats? audioStats;
|
|
final rust.BridgeTransmitMode initialMode;
|
|
final int initialReleaseTailMs;
|
|
final String pttBoundKeyLabel;
|
|
final String pttLevel;
|
|
final String pttBackendId;
|
|
final String pttBoundInputClass;
|
|
final bool isTouchOnly;
|
|
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
|
final ValueChanged<int> onReleaseTailChanged;
|
|
|
|
@override
|
|
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
|
|
}
|
|
|
|
class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|
late rust.BridgeTransmitMode _mode = widget.initialMode;
|
|
late int _tail = widget.initialReleaseTailMs;
|
|
|
|
void _setMode(rust.BridgeTransmitMode m) {
|
|
if (m == rust.BridgeTransmitMode.voiceActivity) {
|
|
// Coming-soon \u2014 disabled in UI; defensive guard.
|
|
return;
|
|
}
|
|
setState(() => _mode = m);
|
|
widget.onModeChanged(m);
|
|
}
|
|
|
|
void _setTail(double v) {
|
|
final ms = v.round();
|
|
setState(() => _tail = ms);
|
|
widget.onReleaseTailChanged(ms);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l10n = AppL10n.of(context);
|
|
final stats = widget.audioStats;
|
|
final levelActive = stats?.pttActive ?? false;
|
|
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
|
|
|
|
// 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: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
l10n.voiceSheetTitle,
|
|
style: theme.textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// 1) Audio output route picker tile (mobile only).
|
|
if (showRoutePicker) ...[
|
|
const _AudioOutputTile(),
|
|
const SizedBox(height: 8),
|
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
const SizedBox(height: 8),
|
|
],
|
|
|
|
// 2) Mode \u2014 inline radio rows.
|
|
Text(
|
|
l10n.voiceModeLabel,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
_ModeRow(
|
|
label: l10n.voiceModePtt,
|
|
icon: Icons.radio_button_checked,
|
|
selected: _mode == rust.BridgeTransmitMode.ptt,
|
|
onTap: () => _setMode(rust.BridgeTransmitMode.ptt),
|
|
),
|
|
_ModeRow(
|
|
label: l10n.voiceModeContinuous,
|
|
icon: Icons.podcasts,
|
|
selected: _mode == rust.BridgeTransmitMode.continuous,
|
|
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
|
),
|
|
_ModeRow(
|
|
label:
|
|
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
|
|
icon: Icons.graphic_eq,
|
|
selected: false,
|
|
onTap: null,
|
|
),
|
|
|
|
// 3) Release-tail slider (PTT only).
|
|
if (isPtt) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l10n.voiceReleaseTailLabel,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'$_tail${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Slider(
|
|
value: _tail.toDouble().clamp(0, 1000),
|
|
min: 0,
|
|
max: 1000,
|
|
divisions: 20,
|
|
label: '$_tail ms',
|
|
onChanged: _setTail,
|
|
),
|
|
// Desktop-only: surface the bound key so the user
|
|
// sees what hardware key is wired. On mobile this
|
|
// row is suppressed (there is no hardware key; the
|
|
// PTT button is the input).
|
|
if (!widget.isTouchOnly &&
|
|
widget.pttBoundKeyLabel.isNotEmpty) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4),
|
|
child: Text(
|
|
'${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
|
|
const SizedBox(height: 16),
|
|
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
|
const SizedBox(height: 12),
|
|
|
|
// 4) Level meter.
|
|
_LevelMeter(active: levelActive),
|
|
const SizedBox(height: 6),
|
|
if (stats != null)
|
|
Text(
|
|
l10n.audioStatsLine(
|
|
stats.framesSent,
|
|
stats.framesReceived,
|
|
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
|
),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
|
|
// 5) PTT capability badge \u2014 desktop-only.
|
|
if (isPtt && !widget.isTouchOnly) ...[
|
|
const SizedBox(height: 12),
|
|
PttCapabilityBadge(
|
|
level: widget.pttLevel,
|
|
backendId: widget.pttBackendId,
|
|
boundInputClass: widget.pttBoundInputClass,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ModeRow extends StatelessWidget {
|
|
const _ModeRow({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final bool selected;
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final disabled = onTap == null;
|
|
final color = disabled
|
|
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
|
|
: selected
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.onSurface;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
selected
|
|
? Icons.radio_button_checked
|
|
: Icons.radio_button_unchecked,
|
|
size: 20,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Icon(icon, size: 18, color: color),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.bodyLarge?.copyWith(color: color),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
AVAudioSessionPortDescription? _activeOutput;
|
|
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_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;
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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<void> _openPicker() async {
|
|
await showModalBottomSheet<void>(
|
|
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);
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// '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<AVAudioSessionPortDescription> _availableInputs = const {};
|
|
AVAudioSessionRouteDescription? _route;
|
|
StreamSubscription<AVAudioSessionRouteChange>? _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<void> _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<void> _selectSpeaker() async {
|
|
try {
|
|
await AVAudioSession()
|
|
.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker);
|
|
} catch (_) {/* ignore */}
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
Future<void> _selectReceiver() async {
|
|
try {
|
|
await AVAudioSession()
|
|
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
|
|
// Also clear any preferred input so it falls back to built-in.
|
|
// We do that by selecting the built-in mic if available.
|
|
final builtIn = _availableInputs
|
|
.where((p) => p.portType == AVAudioSessionPort.builtInMic)
|
|
.toList();
|
|
if (builtIn.isNotEmpty) {
|
|
await AVAudioSession().setPreferredInput(builtIn.first);
|
|
}
|
|
} catch (_) {/* ignore */}
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
Future<void> _selectInput(AVAudioSessionPortDescription port) async {
|
|
try {
|
|
// Drop any speakerphone override so the route follows the
|
|
// selected input (BT / wired headset move output to themselves).
|
|
await AVAudioSession()
|
|
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
|
|
await AVAudioSession().setPreferredInput(port);
|
|
} catch (_) {/* ignore */}
|
|
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 color =
|
|
selected ? theme.colorScheme.primary : theme.colorScheme.onSurface;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|