fix(ui,audio,ios,macos): eight P0 mobile fixes

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).
This commit is contained in:
EdisonJwa
2026-05-16 15:41:08 +08:00
parent 278df25fd7
commit d4c04b6a72
8 changed files with 234 additions and 72 deletions
@@ -35,6 +35,22 @@ import AVFoundation
NSLog("chanora_flutter: AVAudioSession setup failed: \(error)")
}
// Request microphone access on first launch rather than waiting
// for the user's first voice-channel join. The latter is
// surprising: the user has only tapped "connect to server" and
// suddenly iOS pops the permission prompt because joining a
// text channel happens to trigger audio engine startup. Asking
// up-front matches user expectations for a voice-chat client.
//
// The request is asynchronous and non-blocking. If the user
// denies, voice_join will surface a clearer error later when
// the audio engine fails to open the input device. The
// permission state is cached by iOS so subsequent launches
// skip the prompt.
AVAudioSession.sharedInstance().requestRecordPermission { granted in
NSLog("chanora_flutter: microphone permission granted=\(granted)")
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+21 -7
View File
@@ -72,6 +72,10 @@ class ChanoraApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
// No "DEBUG" banner in the top-right corner. This is purely
// cosmetic for the developer-build experience; release builds
// never render it regardless of this flag.
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3F51B5),
@@ -833,11 +837,11 @@ class _BetaHomeState extends State<_BetaHome> {
onPressed: () => _onShowDiagnostics(context),
),
if (_phase == _Phase.connected) ...[
IconButton(
tooltip: l10n.refreshAction,
icon: const Icon(Icons.refresh),
onPressed: _onRefresh,
),
// The previous Refresh action (Icons.refresh + _onRefresh)
// was removed in v1.0.0-rc.8 — the snapshot stream the
// bridge pushes via BridgeEvent::SnapshotChanged keeps the
// tree current automatically, and a manual refresh was a
// no-op from the user's perspective.
IconButton(
tooltip: l10n.disconnectAction,
icon: const Icon(Icons.logout),
@@ -1006,9 +1010,19 @@ class _BetaHomeState extends State<_BetaHome> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
voiceBar,
const SizedBox(height: 12),
// In narrow / one-column layout the Voice
// Bar lives at the BOTTOM of the body so the
// PTT button (rendered as the Voice Bar's
// last element on touch-only mobile hosts)
// sits closest to the user's thumb. The
// channel tree fills the remaining space
// above. In wide mode (Row layout above)
// the Voice Bar is the left column with the
// channel tree on the right, so the
// ordering question doesn't apply.
Expanded(child: snapshotView),
const SizedBox(height: 12),
voiceBar,
],
);
},
+40 -26
View File
@@ -229,32 +229,17 @@ class VoiceBar extends StatelessWidget {
// Row 3: PTT-only secondary content.
//
// On hardware-keyboard hosts (Windows / macOS / Linux /
// Web) this is a one-line bound-key + release-tail
// hint.
// Web) this is a one-line bound-key + release-tail hint
// sitting right under the mode badge.
//
// On touch-only hosts (iOS / iPadOS / Android) there is
// no hardware key to bind, so we replace the hint with
// a touch-and-hold on-screen PTT button driven by
// `_PttHoldButton`. The release-tail still applies; the
// small print below the button shows it for parity
// with the desktop hint line.
if (isPtt && _isTouchOnlyPttHost) ...[
const SizedBox(height: 8),
_PttHoldButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
] else if (isPtt)
// 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(
@@ -288,12 +273,41 @@ class VoiceBar extends StatelessWidget {
// bind-key flow through the Voice Bar's settings gear
// (single configuration entry point — see the comment
// on `onConfigure`).
if (isPtt)
//
// 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 /
@@ -1,11 +1,22 @@
// 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 {
@@ -107,21 +118,30 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
// 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) ...[
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),
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,
@@ -1,8 +1,22 @@
import Cocoa
import FlutterMacOS
import AVFoundation
@main
class AppDelegate: FlutterAppDelegate {
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
// Ask for microphone access on launch rather than on first
// voice-channel join. Matches user expectations for a voice
// chat client and saves the user from a surprising prompt
// mid-flow. macOS caches the choice; subsequent launches skip
// the prompt.
AVCaptureDevice.requestAccess(for: .audio) { granted in
NSLog("chanora_flutter: microphone permission granted=\(granted)")
}
}
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}