diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index d4e9578..1ff389a 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -547,6 +547,12 @@ abstract class AppL10n { /// **'Bind PTT key'** String get voiceBindKeyAction; + /// No description provided for @voiceBoundKeyLabel. + /// + /// In en, this message translates to: + /// **'Bound key'** + String get voiceBoundKeyLabel; + /// No description provided for @voicePttHoldHint. /// /// In en, this message translates to: @@ -565,6 +571,60 @@ abstract class AppL10n { /// **'off'** String get voiceMicOff; + /// No description provided for @voiceSheetTitle. + /// + /// In en, this message translates to: + /// **'Voice'** + String get voiceSheetTitle; + + /// No description provided for @audioOutputLabel. + /// + /// In en, this message translates to: + /// **'Audio output'** + String get audioOutputLabel; + + /// No description provided for @audioRouteSpeaker. + /// + /// In en, this message translates to: + /// **'Speaker'** + String get audioRouteSpeaker; + + /// No description provided for @audioRouteReceiver. + /// + /// In en, this message translates to: + /// **'iPhone receiver'** + String get audioRouteReceiver; + + /// No description provided for @audioRouteBluetooth. + /// + /// In en, this message translates to: + /// **'Bluetooth'** + String get audioRouteBluetooth; + + /// No description provided for @audioRouteWiredHeadset. + /// + /// In en, this message translates to: + /// **'Wired headset'** + String get audioRouteWiredHeadset; + + /// No description provided for @audioRouteCarAudio. + /// + /// In en, this message translates to: + /// **'Car audio'** + String get audioRouteCarAudio; + + /// No description provided for @audioRouteAirplay. + /// + /// In en, this message translates to: + /// **'AirPlay'** + String get audioRouteAirplay; + + /// No description provided for @audioRouteUnknown. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get audioRouteUnknown; + /// No description provided for @channelJoinFailedPermission. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 9a4a20e..b06d1be 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -266,6 +266,9 @@ class AppL10nEn extends AppL10n { @override String get voiceBindKeyAction => 'Bind PTT key'; + @override + String get voiceBoundKeyLabel => 'Bound key'; + @override String get voicePttHoldHint => 'Hold the button'; @@ -275,6 +278,33 @@ class AppL10nEn extends AppL10n { @override String get voiceMicOff => 'off'; + @override + String get voiceSheetTitle => 'Voice'; + + @override + String get audioOutputLabel => 'Audio output'; + + @override + String get audioRouteSpeaker => 'Speaker'; + + @override + String get audioRouteReceiver => 'iPhone receiver'; + + @override + String get audioRouteBluetooth => 'Bluetooth'; + + @override + String get audioRouteWiredHeadset => 'Wired headset'; + + @override + String get audioRouteCarAudio => 'Car audio'; + + @override + String get audioRouteAirplay => 'AirPlay'; + + @override + String get audioRouteUnknown => 'Unknown'; + @override String get channelJoinFailedPermission => 'Insufficient permission to join this channel.'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index fd9a8af..6f61208 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -260,6 +260,9 @@ class AppL10nZh extends AppL10n { @override String get voiceBindKeyAction => '绑定 PTT 按键'; + @override + String get voiceBoundKeyLabel => '已绑定按键'; + @override String get voicePttHoldHint => '按住按钮'; @@ -269,6 +272,33 @@ class AppL10nZh extends AppL10n { @override String get voiceMicOff => '关闭'; + @override + String get voiceSheetTitle => '语音'; + + @override + String get audioOutputLabel => '音频输出'; + + @override + String get audioRouteSpeaker => '扬声器'; + + @override + String get audioRouteReceiver => '听筒'; + + @override + String get audioRouteBluetooth => '蓝牙'; + + @override + String get audioRouteWiredHeadset => '有线耳机'; + + @override + String get audioRouteCarAudio => '车载音频'; + + @override + String get audioRouteAirplay => 'AirPlay'; + + @override + String get audioRouteUnknown => '未知'; + @override String get channelJoinFailedPermission => '权限不足,无法加入此频道。'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index a81b6c0..a5e376f 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -563,31 +563,18 @@ class _BetaHomeState extends State<_BetaHome> { } Future _askChannelPassword(AppL10n l10n) async { - final ctl = TextEditingController(); - final result = await showDialog( + // Same pattern as _onAddCurrentBookmark: route the dialog + // through a dedicated StatefulWidget so its + // TextEditingController is disposed at unmount time, not + // synchronously after `await showDialog` resumes. Inline + // dispose-after-await caused framework.dart:6268 + // _dependents.isEmpty assertions on iOS \u2014 the controller was + // torn out while EditableText still depended on InheritedWidgets + // belonging to the still-popping dialog route. + return await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(l10n.channelPasswordTitle), - content: TextField( - controller: ctl, - obscureText: true, - autofocus: true, - decoration: InputDecoration(labelText: l10n.fieldPassword), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(l10n.closeAction), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(ctl.text), - child: Text(l10n.connectAction), - ), - ], - ), + builder: (ctx) => const _ChannelPasswordDialog(), ); - ctl.dispose(); - return result; } Future _onRefresh() async { @@ -794,31 +781,33 @@ class _BetaHomeState extends State<_BetaHome> { } Future _onAddCurrentBookmark() async { - final l10n = AppL10n.of(context); - final nameCtl = TextEditingController(text: _hostCtl.text.trim()); + // Use the dialog's own context for AppL10n.of(...) inside its + // builder. Capturing the outer _HomePageState context's l10n in + // a closure and reading it inside the dialog's widget tree + // caused the dialog's TextField to depend on InheritedElements + // (Localizations / _LocalizationsScope) that belong to the + // outer route. When the dialog popped, the framework + // deactivated the dialog route's elements first while those + // outer-route InheritedElements were still alive but had + // dependents from the disposed dialog tree \u2014 producing the + // assertion 'package:flutter/src/widgets/framework.dart line + // 6268 _dependents.isEmpty is not true'. + // + // Also: dispose the TextEditingController via the dialog's own + // StatefulBuilder lifecycle instead of an inline dispose() right + // after showDialog returns. The inline dispose runs synchronously + // before the dialog route is fully torn down (the route pop + // animation is still mid-flight on iOS), and tearing the + // controller out from under EditableText while it has a live + // InheritedWidget dependency was the second trigger for the + // same assertion. + final initial = _hostCtl.text.trim(); final name = await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(l10n.bookmarkAddTitle), - content: TextField( - controller: nameCtl, - autofocus: true, - decoration: InputDecoration(labelText: l10n.fieldDisplayName), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(l10n.closeAction), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(nameCtl.text), - child: Text(l10n.bookmarkAddAction), - ), - ], - ), + builder: (ctx) => _BookmarkNameDialog(initialName: initial), ); - nameCtl.dispose(); if (name == null || name.trim().isEmpty) return; + if (!mounted) return; try { await rust.addBookmark( b: rust.BridgeBookmark( @@ -1238,6 +1227,108 @@ class _AppBarTitle extends StatelessWidget { } } +/// 'Save bookmark' name-entry dialog. Owns its own +/// TextEditingController via a StatefulWidget lifecycle so the +/// dispose() runs cleanly at unmount time (after the route pop +/// animation has fully detached the dialog subtree), not +/// synchronously from the caller's `await showDialog` resumption +/// point. +/// +/// Inline dispose-after-showDialog pattern previously caused +/// 'framework.dart line 6268 _dependents.isEmpty' on iOS because +/// the controller was torn down mid-pop while EditableText still +/// had live InheritedWidget dependencies on the dialog route. +class _BookmarkNameDialog extends StatefulWidget { + const _BookmarkNameDialog({required this.initialName}); + + final String initialName; + + @override + State<_BookmarkNameDialog> createState() => _BookmarkNameDialogState(); +} + +class _BookmarkNameDialogState extends State<_BookmarkNameDialog> { + late final TextEditingController _ctl = + TextEditingController(text: widget.initialName); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + return AlertDialog( + title: Text(l10n.bookmarkAddTitle), + content: TextField( + controller: _ctl, + autofocus: true, + decoration: InputDecoration(labelText: l10n.fieldDisplayName), + onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_ctl.text), + child: Text(l10n.bookmarkAddAction), + ), + ], + ); + } +} + +/// Channel-password dialog. Same StatefulWidget pattern as +/// [_BookmarkNameDialog] to keep TextEditingController disposal +/// inside the dialog's own lifecycle and avoid the +/// framework.dart:6268 _dependents.isEmpty assertion. +class _ChannelPasswordDialog extends StatefulWidget { + const _ChannelPasswordDialog(); + + @override + State<_ChannelPasswordDialog> createState() => + _ChannelPasswordDialogState(); +} + +class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> { + final TextEditingController _ctl = TextEditingController(); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + return AlertDialog( + title: Text(l10n.channelPasswordTitle), + content: TextField( + controller: _ctl, + obscureText: true, + autofocus: true, + decoration: InputDecoration(labelText: l10n.fieldPassword), + onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_ctl.text), + child: Text(l10n.connectAction), + ), + ], + ); + } +} + class _ConnectForm extends StatefulWidget { const _ConnectForm({ required this.hostCtl, diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 52e2523..438fbc3 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -505,6 +505,47 @@ impl ChanoraSession { cfg, sup_inner, }); + + // TS3 servers auto-place a newly-connected client into the + // server's default channel (or whichever channel the + // identity has a 'joined this channel last time' preference + // for). The protocol layer reports that channel id in the + // initial snapshot; we therefore consider the user 'in a + // channel' immediately after connect, without needing them + // to invoke voice_join manually. + // + // Without this auto-detect, the UI rendered the user as + // 'connected but not in a channel': no PTT button, no + // mic/headset AppBar icons, no voice modal sheet entry + // point. The user could see they were in the default + // channel in the channel tree, but had no way to talk + // because all voice controls were gated on _inChannel which + // was still false. + // + // We need to drop the inner lock before calling the public + // helpers (they re-lock self.inner). Scope the temporary. + drop(guard); + if let Some((_my_id, _channel_id)) = self.find_own_in(&snap).await { + self.voice_selector.set_in_channel(true); + self.emit_voice_state(true).await; + // Bring the audio engine up so the user can immediately + // hear other speakers + transmit on PTT. Tolerates + // failure the same way voice_join does: server-side + // we're in the channel regardless; if audio fails + // (missing mic permission, no device), the UI will + // still expose the controls and the user can resolve + // the underlying issue. + if let Err(audio_err) = self.ensure_audio_running().await { + warn!( + target: "chanora_core", + error = %audio_err, + "auto-join default channel: server placed us in a channel but audio engine \ + failed to start; continuing with no-audio in-channel state" + ); + let _ = self.events_tx.send(SessionEvent::AudioStopped); + } + } + Ok(snap) }