Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T

983 lines
32 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.
///
/// 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, 500),
min: 0,
max: 500,
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. On iOS this must remain
// visible even though the resolved level is always
// `L0Focused`, because the P0 acceptance flow requires
// honest capability advertising with an explanation of
// the sandbox limitation.
if (isPtt) ...[
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 {
// Apple-documented quirk: in .voiceChat mode, calling
// setPreferredInput(builtInMic) AFTER overrideOutputAudioPort(.speaker)
// causes iOS to recalculate the route. Built-in mic naturally
// pairs with the receiver (not the speaker), so the system
// SILENTLY REVERTS the speaker override and routes audio
// back through the earpiece. Net: the await chain returns
// successfully ('no exception'), but the user hears no
// change.
//
// Fix: do NOT call setPreferredInput when forcing speaker.
// The speaker override is sufficient on its own \u2014 input
// remains on whatever the system was already using (built-in
// mic by default, or BT/wired if connected and selected
// elsewhere). For consistency, only switch input when the
// user explicitly picks a non-speaker input row.
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.speaker,
);
debugPrint('chanora: audio output -> speakerphone (override applied)');
} catch (e, st) {
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectReceiver() async {
try {
// Same quirk in reverse: removing the speaker override
// (.none) is enough to restore the .voiceChat default route,
// which is the built-in receiver. Calling setPreferredInput
// explicitly here is redundant and risks the same recalc
// race that broke _selectSpeaker before.
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
debugPrint('chanora: audio output -> receiver (override cleared)');
} catch (e, st) {
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectInput(AVAudioSessionPortDescription port) async {
try {
// Drop any speakerphone override so the route follows the
// selected input. BT / wired headset / USB inputs pair their
// OWN output (the user hears audio through the same device
// they speak into), so .none + setPreferredInput is the
// correct combo here.
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
await AVAudioSession().setPreferredInput(port);
debugPrint(
'chanora: audio output -> ${port.portName} (${port.portType})',
);
} catch (e, st) {
debugPrint('chanora: _selectInput FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
if (_loading) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator()),
),
);
}
final currentOutputType = _route?.outputs.isNotEmpty == true
? _route!.outputs.first.portType
: null;
final currentInputUid = _route?.inputs.isNotEmpty == true
? _route!.inputs.first.uid
: null;
// Whether the active route is the speakerphone override (built-in
// speaker is the output but the actual session category isn't
// playback \u2014 it's playAndRecord + override).
final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker;
final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver;
// Non-built-in inputs (BT / wired / USB / car audio) for the
// device-specific rows. Built-in mic is rendered as 'iPhone
// (receiver)' above; we filter it out here.
final externalInputs = _availableInputs
.where((p) => p.portType != AVAudioSessionPort.builtInMic)
.toList();
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.volume_up,
label: l10n.audioRouteSpeaker,
selected: isSpeaker,
onTap: _selectSpeaker,
),
_PickerRow(
icon: Icons.phone_in_talk,
label: l10n.audioRouteReceiver,
selected: isReceiver,
onTap: _selectReceiver,
),
for (final port in externalInputs)
_PickerRow(
icon: _AudioOutputTileState._portIcon(port.portType),
label: _AudioOutputTileState._portLabel(
port.portType,
port.portName,
l10n,
),
selected: !isSpeaker && port.uid == currentInputUid,
onTap: () => _selectInput(port),
),
],
),
),
);
}
}
class _PickerRow extends StatelessWidget {
const _PickerRow({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final 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),
),
),
),
);
}
}