User report from sideloaded iPhone build, in order of priority: #4 'Could not join channel: audio: audio backend: build_output_stream: The requested stream configuration is not supported by the device.' Cause: we forced cpal::BufferSize::Fixed(2048) on the output and input streams unconditionally on non-Linux. iOS CoreAudio RemoteIO units reject arbitrary buffer-size requests with that exact error. Windows WASAPI needs the pinning for shared-mode jitter, but macOS / iOS do not. Fix: cfg-gate Fixed(2048) to target_os = 'windows'; everywhere else use BufferSize::Default and let the platform HAL pick. crates/chanora_audio/src/engine.rs. #5 'Could not join channel: invariant violated: voice_in already taken' Cause: start_audio tore down the old engine BEFORE attempting to construct the new one, and consumed voice_in (an mpsc Receiver that can only be taken once) early. When the new engine failed mid-construction (e.g. because of #4 above) the session was left with: no audio engine, voice_in consumed, no way to retry without reconnect. The second voice_join attempt surfaced the invariant message. Fix: build the new engine BEFORE tearing down the old. Only swap state.audio if construction succeeded. crates/chanora_ core/src/lib.rs::ChanoraSession::start_audio. Additionally added a put_voice_in helper to the protocol adapter ( crates/chanora_protocol/src/adapter.rs) for a future broadcast-channel migration; the helper is unused on the immediate fix path but documents the intent. #3 'permission request would better on first open' Cause: AVAudioSession only triggers the mic-permission prompt the first time it tries to record. We never recorded until voice_join, so the prompt fired then. Fix iOS: AVAudioSession.sharedInstance().requestRecordPermission in AppDelegate.swift::application(_:didFinishLaunchingWithOptions:). Fix macOS: AVCaptureDevice.requestAccess(for: .audio) in macos/Runner/AppDelegate.swift::applicationDidFinishLaunching. Both run non-blocking; user can deny without crashing app launch, and voice_join then surfaces a clearer downstream error when the engine fails to open the input device. #1 + #2 'one-column upper takes too much space; Push to Talk button at bottom would be better' Layout rework for narrow-mode (single column, mobile shape): - Flipped the stacking order in main.dart so Voice Bar moves to the BOTTOM of the body and the channel tree (Expanded) fills above. Wide-mode (Row, >= 840 dp) layout unchanged. - Inside the Voice Bar on touch-only hosts, moved the on-screen Push to Talk button to be the LAST element of the Voice Bar (was Row 3). Order now: pill + mutes, mode badge + settings, level meter, stats line, release-tail caption, PTT button. The button is closest to the user's thumb when the Voice Bar is pinned to the bottom of a narrow-layout screen. #6 'remove right top debug badge' debugShowCheckedModeBanner: false on the MaterialApp. Release builds never showed it anyway; this only affects local dev / debug builds. #7 'what does the refresh button use for? nothing happened' Removed. The snapshot updates via BridgeEvent::SnapshotChanged are pushed from the bridge — a manual rust.snapshot() call was redundant. Now only the Diagnostics + Disconnect actions remain in the AppBar trailing row when connected. #8 'Bind Key related function should not be added to a mobile platform' widgets/voice_settings.dart: bind-key OutlinedButton is now #cfg'd out when Platform.isIOS || Platform.isAndroid. The release-tail slider stays because it still applies to the on-screen PTT button. Capability badge in voice_bar.dart also hidden on mobile (it would always show L0Focused which is redundant with the visible on-screen button). Tests + analyze: chanora_audio 34/0/0 on macOS, workspace 78/0/1 on Linux; flutter analyze clean (6 pre-existing Radio.groupValue infos). flutter build ios --release --no-codesign: 28.8 s clean (Runner.app 29.9 MB).
453 lines
16 KiB
Dart
453 lines
16 KiB
Dart
// Voice bar widget (SDD-097). Replaces the legacy `_AudioControls`
|
|
// block that used to live in `main.dart`. Driven by the
|
|
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
|
// core's transmit-mode selector + release-tail timer.
|
|
|
|
import 'dart:io' show Platform;
|
|
|
|
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;
|
|
|
|
/// True when the host is a mobile platform without a hardware
|
|
/// keyboard the user would bind a PTT key on. iOS / iPadOS /
|
|
/// Android fall here. macOS / Linux / Windows / Web fall on the
|
|
/// hardware-key path.
|
|
bool get _isTouchOnlyPttHost {
|
|
if (kIsWeb) return false;
|
|
return Platform.isIOS || Platform.isAndroid;
|
|
}
|
|
|
|
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
|
/// toggle, level meter, and a leave-channel affordance.
|
|
class VoiceBar extends StatelessWidget {
|
|
/// Construct a voice bar.
|
|
const VoiceBar({
|
|
super.key,
|
|
required this.inChannel,
|
|
required this.transmitMode,
|
|
required this.hardMute,
|
|
required this.outputMuted,
|
|
required this.releaseTailMs,
|
|
required this.channelName,
|
|
required this.audioStats,
|
|
required this.pttLevel,
|
|
required this.pttBackendId,
|
|
required this.pttBoundInputClass,
|
|
required this.pttBoundKeyLabel,
|
|
required this.onToggleMute,
|
|
required this.onToggleOutputMute,
|
|
required this.onConfigure,
|
|
required this.onPttHeldChanged,
|
|
});
|
|
|
|
/// True when the session is currently joined to a voice channel.
|
|
final bool inChannel;
|
|
|
|
/// Active transmit mode.
|
|
final rust.BridgeTransmitMode transmitMode;
|
|
|
|
/// Hard-mute clamp state.
|
|
final bool hardMute;
|
|
|
|
/// Speaker (output) mute state. Mirrors the server-broadcast
|
|
/// `ClientOutputMuted` flag plus the engine's local output
|
|
/// silencer — toggling this hushes incoming voice immediately
|
|
/// AND tells the server so other clients see the headphone-off
|
|
/// icon next to our name.
|
|
final bool outputMuted;
|
|
|
|
/// Configured release-tail in milliseconds (0..=500). Surfaced as
|
|
/// a hint underneath the mode badge.
|
|
final int releaseTailMs;
|
|
|
|
/// Channel display name to show in the channel-name pill. Empty
|
|
/// string suppresses the pill (typically when `!inChannel`).
|
|
final String channelName;
|
|
|
|
/// Audio statistics (TX/RX frames + ptt_active) to drive the
|
|
/// level meter. Pass `null` to render an idle meter.
|
|
final rust.BridgeAudioStats? audioStats;
|
|
|
|
/// PTT capability badge inputs — passed through to
|
|
/// [`PttCapabilityBadge`].
|
|
final String pttLevel;
|
|
|
|
/// Backend id, e.g. `focused`, `windows-raw-input`.
|
|
final String pttBackendId;
|
|
|
|
/// Coarse bound input class.
|
|
final String pttBoundInputClass;
|
|
|
|
/// Platform-neutral key label captured by the binding dialog.
|
|
final String pttBoundKeyLabel;
|
|
|
|
/// Toggle the hard-mute clamp.
|
|
final VoidCallback onToggleMute;
|
|
|
|
/// Toggle speaker (output) mute.
|
|
final VoidCallback onToggleOutputMute;
|
|
|
|
/// Open the voice settings dialog. This is the SINGLE entry point
|
|
/// for transmit-mode selection, PTT key binding, and release-tail
|
|
/// configuration. The capability badge below is information-only
|
|
/// and intentionally does NOT have its own configure affordance.
|
|
final VoidCallback onConfigure;
|
|
|
|
/// Drive the press/release edges of the on-screen PTT button on
|
|
/// touch-only mobile platforms (iOS / iPadOS / Android). On
|
|
/// desktop platforms this callback is wired but never invoked
|
|
/// because the on-screen button is only rendered on mobile.
|
|
/// The callee should map `true` to `setPtt(active: true)` and
|
|
/// `false` to `setPtt(active: false)`; the Rust release-tail
|
|
/// timer handles the trailing tail (SDD-096).
|
|
final ValueChanged<bool> onPttHeldChanged;
|
|
|
|
String _modeLabel(AppL10n l10n) {
|
|
switch (transmitMode) {
|
|
case rust.BridgeTransmitMode.ptt:
|
|
return l10n.voiceModePtt;
|
|
case rust.BridgeTransmitMode.continuous:
|
|
return l10n.voiceModeContinuous;
|
|
case rust.BridgeTransmitMode.voiceActivity:
|
|
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final stats = audioStats;
|
|
final levelActive = stats?.pttActive ?? false;
|
|
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
|
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Row 1: channel pill + mute toggle. The pill is
|
|
// wrapped in `Flexible` so very long channel names
|
|
// truncate with an ellipsis instead of overflowing the
|
|
// Voice Bar's column width (320 dp in the wide layout)
|
|
// and pushing the mute icons under the adjacent channel
|
|
// tree.
|
|
Row(
|
|
children: [
|
|
if (inChannel && channelName.isNotEmpty) ...[
|
|
Flexible(
|
|
flex: 100,
|
|
fit: FlexFit.loose,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.tag,
|
|
size: 14,
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
channelName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
softWrap: false,
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(width: 8),
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: l10n.voiceOutputMuteLabel,
|
|
icon: Icon(
|
|
outputMuted ? Icons.headset_off : Icons.headset,
|
|
),
|
|
isSelected: outputMuted,
|
|
selectedIcon: const Icon(Icons.headset_off),
|
|
onPressed: onToggleOutputMute,
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.voiceHardMuteLabel,
|
|
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
|
|
isSelected: hardMute,
|
|
selectedIcon: const Icon(Icons.mic_off),
|
|
onPressed: onToggleMute,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
// Row 2: mode badge
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
transmitMode == rust.BridgeTransmitMode.ptt
|
|
? Icons.radio_button_checked
|
|
: Icons.podcasts,
|
|
size: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
_modeLabel(l10n),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.tune, size: 16),
|
|
label: Text(l10n.voiceSettingsTitle),
|
|
onPressed: onConfigure,
|
|
),
|
|
],
|
|
),
|
|
// Row 3: PTT-only secondary content.
|
|
//
|
|
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
|
// Web) this is a one-line bound-key + release-tail hint
|
|
// sitting right under the mode badge.
|
|
//
|
|
// On touch-only hosts (iOS / iPadOS / Android) the
|
|
// on-screen Push to Talk button is rendered AT THE
|
|
// BOTTOM of the Voice Bar (see below) so it sits
|
|
// closest to the user's thumb when the Voice Bar is
|
|
// pinned to the bottom of a narrow-layout screen. The
|
|
// release-tail value is folded into the small print
|
|
// under the button rather than shown here.
|
|
if (isPtt && !_isTouchOnlyPttHost)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 22, top: 2),
|
|
child: Text(
|
|
'${l10n.voiceModePtt}: '
|
|
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
|
' · '
|
|
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
// Row 4: level meter
|
|
_LevelMeter(active: levelActive),
|
|
const SizedBox(height: 4),
|
|
if (stats != null)
|
|
Text(
|
|
l10n.audioStatsLine(
|
|
stats.framesSent,
|
|
stats.framesReceived,
|
|
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
|
),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 6),
|
|
// PTT capability badge — only relevant when PTT mode is
|
|
// active. Hidden for Continuous / Voice Activity since
|
|
// there's no key binding to surface a capability for.
|
|
// The badge is information-only; the user reaches the
|
|
// bind-key flow through the Voice Bar's settings gear
|
|
// (single configuration entry point — see the comment
|
|
// on `onConfigure`).
|
|
//
|
|
// Hidden on touch-only mobile hosts (iOS / iPadOS /
|
|
// Android) because the capability story there is always
|
|
// "L0Focused via on-screen button" and that's already
|
|
// visually obvious from the PTT button being on the
|
|
// bar. Showing a degraded-capability badge there would
|
|
// be redundant + confusing.
|
|
if (isPtt && !_isTouchOnlyPttHost)
|
|
PttCapabilityBadge(
|
|
level: pttLevel,
|
|
backendId: pttBackendId,
|
|
boundInputClass: pttBoundInputClass,
|
|
),
|
|
// On touch-only mobile hosts the Push to Talk button is
|
|
// the LAST element of the Voice Bar so it lands closest
|
|
// to the user's thumb when the Voice Bar is pinned to
|
|
// the bottom of a narrow-layout screen. The release-
|
|
// tail value sits above the button so the user sees
|
|
// how long their voice continues after they let go.
|
|
if (isPtt && _isTouchOnlyPttHost) ...[
|
|
const SizedBox(height: 4),
|
|
Center(
|
|
child: Text(
|
|
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_PttHoldButton(
|
|
active: levelActive,
|
|
onHeldChanged: onPttHeldChanged,
|
|
),
|
|
],
|
|
// Leave-voice button intentionally absent: TeamSpeak's
|
|
// model is "user is always in some channel", not
|
|
// Discord's join/leave-voice. To stop being heard /
|
|
// hearing others, mute mic and/or speaker via the
|
|
// icons at the top of the bar. To physically move,
|
|
// tap a different channel in the tree below.
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// On-screen push-to-talk button for touch-only mobile platforms
|
|
/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in
|
|
/// favour of a bound key.
|
|
///
|
|
/// Behaviour:
|
|
/// * `onPanDown` (finger touches the button) → fires
|
|
/// `onHeldChanged(true)`. The Rust release-tail timer treats
|
|
/// this as `key_down`.
|
|
/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) →
|
|
/// fires `onHeldChanged(false)` → `key_up` → tail expires →
|
|
/// mic closes.
|
|
///
|
|
/// Using `GestureDetector` rather than `Listener` because we want
|
|
/// gesture-arena semantics: if the user starts dragging the
|
|
/// channel-tree underneath, the PTT should release. `onPanCancel`
|
|
/// fires in that case.
|
|
///
|
|
/// The button visually mirrors the `_LevelMeter` state via the
|
|
/// `active` flag so the user gets feedback that holding actually
|
|
/// engaged the mic.
|
|
class _PttHoldButton extends StatefulWidget {
|
|
const _PttHoldButton({required this.active, required this.onHeldChanged});
|
|
|
|
final bool active;
|
|
final ValueChanged<bool> onHeldChanged;
|
|
|
|
@override
|
|
State<_PttHoldButton> createState() => _PttHoldButtonState();
|
|
}
|
|
|
|
class _PttHoldButtonState extends State<_PttHoldButton> {
|
|
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 activeNow = _pressed || widget.active;
|
|
final l10n = AppL10n.of(context);
|
|
|
|
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: 64,
|
|
decoration: BoxDecoration(
|
|
color: activeNow
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: activeNow
|
|
? [
|
|
BoxShadow(
|
|
color: theme.colorScheme.primary.withAlpha(100),
|
|
blurRadius: 12,
|
|
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: 24,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: activeNow
|
|
? theme.colorScheme.onPrimary
|
|
: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|