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).
195 lines
6.4 KiB
Dart
195 lines
6.4 KiB
Dart
// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
|
|
// group, a bind-key button, and a release-tail slider.
|
|
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import '../src/rust/api.dart' as rust;
|
|
|
|
/// True when the host is a touch-only mobile platform without a
|
|
/// hardware keyboard the user would bind a PTT key on. Mirrors the
|
|
/// helper in `voice_bar.dart`.
|
|
bool get _isTouchOnlyPttHost {
|
|
if (kIsWeb) return false;
|
|
return Platform.isIOS || Platform.isAndroid;
|
|
}
|
|
|
|
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
|
|
/// cancelled dialog.
|
|
class VoiceSettingsResult {
|
|
/// Construct a result snapshot.
|
|
const VoiceSettingsResult({
|
|
required this.mode,
|
|
required this.releaseTailMs,
|
|
required this.bindKeyRequested,
|
|
});
|
|
|
|
/// Selected transmit mode.
|
|
final rust.BridgeTransmitMode mode;
|
|
|
|
/// Chosen release-tail in milliseconds (0..=500, step 25).
|
|
final int releaseTailMs;
|
|
|
|
/// True when the user tapped the "bind key" button. The caller
|
|
/// is expected to open the focus-scoped capture dialog
|
|
/// afterwards.
|
|
final bool bindKeyRequested;
|
|
}
|
|
|
|
/// Voice settings dialog widget.
|
|
class VoiceSettingsDialog extends StatefulWidget {
|
|
/// Construct a dialog seeded with the current settings.
|
|
const VoiceSettingsDialog({
|
|
super.key,
|
|
required this.initialMode,
|
|
required this.initialReleaseTailMs,
|
|
});
|
|
|
|
/// Currently active transmit mode.
|
|
final rust.BridgeTransmitMode initialMode;
|
|
|
|
/// Currently configured release tail in milliseconds.
|
|
final int initialReleaseTailMs;
|
|
|
|
@override
|
|
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
|
}
|
|
|
|
class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|
late rust.BridgeTransmitMode _mode;
|
|
late double _releaseTail;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mode = widget.initialMode;
|
|
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
return AlertDialog(
|
|
title: Text(l10n.voiceSettingsTitle),
|
|
content: SizedBox(
|
|
width: 360,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.voiceModeLabel,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
RadioListTile<rust.BridgeTransmitMode>(
|
|
dense: true,
|
|
value: rust.BridgeTransmitMode.ptt,
|
|
groupValue: _mode,
|
|
title: Text(l10n.voiceModePtt),
|
|
onChanged: (v) => setState(() => _mode = v!),
|
|
),
|
|
RadioListTile<rust.BridgeTransmitMode>(
|
|
dense: true,
|
|
value: rust.BridgeTransmitMode.continuous,
|
|
groupValue: _mode,
|
|
title: Text(l10n.voiceModeContinuous),
|
|
onChanged: (v) => setState(() => _mode = v!),
|
|
),
|
|
RadioListTile<rust.BridgeTransmitMode>(
|
|
dense: true,
|
|
value: rust.BridgeTransmitMode.voiceActivity,
|
|
groupValue: _mode,
|
|
title: Text(l10n.voiceModeVoiceActivity),
|
|
secondary: Text(
|
|
l10n.voiceModeComingSoon,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
// VoiceActivity is reserved per DEC-030 — keep the
|
|
// tile visible but disabled per SDD-095.
|
|
onChanged: null,
|
|
),
|
|
const Divider(),
|
|
// Bind-key + release-tail are PTT-only concepts. Hide
|
|
// them entirely when the user has switched to a
|
|
// non-PTT mode so the dialog stays focused on what's
|
|
// actually configurable for that mode.
|
|
//
|
|
// Additionally on touch-only mobile hosts (iOS / iPadOS
|
|
// / Android) there is no hardware keyboard to bind a
|
|
// key on — the VoiceBar renders an on-screen Push to
|
|
// Talk button instead. Hide the Bind Key affordance
|
|
// there but keep the release-tail slider since it
|
|
// still applies to the on-screen button's behaviour.
|
|
if (_mode == rust.BridgeTransmitMode.ptt) ...[
|
|
if (!_isTouchOnlyPttHost) ...[
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.keyboard),
|
|
label: Text(l10n.voiceBindKeyAction),
|
|
onPressed: () {
|
|
Navigator.of(context).pop(
|
|
VoiceSettingsResult(
|
|
mode: _mode,
|
|
releaseTailMs: _releaseTail.round(),
|
|
bindKeyRequested: true,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
Text(
|
|
l10n.voiceReleaseTailLabel,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Slider(
|
|
value: _releaseTail,
|
|
min: 0,
|
|
max: 500,
|
|
divisions: 20, // step 25 ms
|
|
label:
|
|
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
|
onChanged: (v) => setState(() => _releaseTail = v),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 64,
|
|
child: Text(
|
|
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
|
|
style: theme.textTheme.bodySmall,
|
|
textAlign: TextAlign.end,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(
|
|
VoiceSettingsResult(
|
|
mode: _mode,
|
|
releaseTailMs: _releaseTail.round(),
|
|
bindKeyRequested: false,
|
|
),
|
|
),
|
|
child: Text(l10n.pttConfigureSaveAction),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|