diff --git a/apps/chanora_flutter/lib/design/viewport_info.dart b/apps/chanora_flutter/lib/design/viewport_info.dart index 1279063..57cb088 100644 --- a/apps/chanora_flutter/lib/design/viewport_info.dart +++ b/apps/chanora_flutter/lib/design/viewport_info.dart @@ -57,8 +57,11 @@ class ViewportInfo extends InheritedWidget { bool get isWide => !isCompact; @override - bool updateShouldNotify(ViewportInfo old) => - layoutClass != old.layoutClass || - width != old.width || - height != old.height; + bool updateShouldNotify(ViewportInfo old) => layoutClass != old.layoutClass; + // NOTE: width/height changes within the same layout class do NOT trigger + // notification. Dependents who genuinely need pixel-level dimensions + // (rare — most layouts should switch on layoutClass) must use a local + // LayoutBuilder. Notifying on every pixel would rebuild every dependent + // on every resize frame, which is the exact pessimisation this + // InheritedWidget exists to avoid. } diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 946ba54..91c8b22 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -46,6 +46,7 @@ "retryAction": "Retry", "chatAction": "Chat", "chatCloseAction": "Close chat", + "chatPanelCollapsedHint": "Tap the chat button to continue your conversation", "chatNewPrivateAction": "New private chat", "chatSearchClientsHint": "Search clients...", "chatDirectMessageAction": "Private message", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 40cfe96..87388fc 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -39,6 +39,7 @@ "retryAction": "重试", "chatAction": "聊天", "chatCloseAction": "关闭聊天", + "chatPanelCollapsedHint": "点击聊天按钮以继续对话", "chatNewPrivateAction": "新建私聊", "chatSearchClientsHint": "搜索用户...", "chatDirectMessageAction": "私聊", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 8618835..13bc179 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -307,6 +307,12 @@ abstract class AppL10n { /// **'Close chat'** String get chatCloseAction; + /// No description provided for @chatPanelCollapsedHint. + /// + /// In en, this message translates to: + /// **'Tap the chat button to continue your conversation'** + String get chatPanelCollapsedHint; + /// No description provided for @chatNewPrivateAction. /// /// 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 10de84c..e77422c 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -123,6 +123,10 @@ class AppL10nEn extends AppL10n { @override String get chatCloseAction => 'Close chat'; + @override + String get chatPanelCollapsedHint => + 'Tap the chat button to continue your conversation'; + @override String get chatNewPrivateAction => 'New private chat'; 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 204b418..1c5fbd2 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -120,6 +120,9 @@ class AppL10nZh extends AppL10n { @override String get chatCloseAction => '关闭聊天'; + @override + String get chatPanelCollapsedHint => '点击聊天按钮以继续对话'; + @override String get chatNewPrivateAction => '新建私聊'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 9ce64e0..3f9e350 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1673,17 +1673,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }; } - /// Saves the current draft text for the current inline chat target. - void _saveCurrentDraft() { - // Drafts are continuously saved via onDraftChanged callback in Task 4. - // This method exists as an explicit save point. - final target = _inlineChatTarget; - if (target == null) return; - final key = _draftKeyForTarget(target); - if (_chatDrafts.containsKey(key)) { - _chatDrafts[key] = _chatDrafts[key] ?? ''; - } - } + /// Returns the current draft text for the inline chat target, or null + /// if no draft has been stored. + /// + /// Draft persistence relies on [ChatDetailView]'s own lifecycle: + /// `didUpdateWidget` flushes the outgoing target's draft via + /// `onDraftChanged` when the parent rebuilds with a new target, and + /// `dispose` flushes the final draft when the panel is torn down + /// (e.g. on [_closeInlineChat]). Both paths feed + /// [_chatDrafts] without requiring an explicit save call from this + /// class. + String? _currentDraftFor(rust.BridgeMessageTarget target) => + _chatDrafts[_draftKeyForTarget(target)]; Future _onOpenChat({ rust.BridgeMessageTarget? target, @@ -1722,8 +1723,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { layoutClassFromWidth(MediaQuery.sizeOf(context).width) == LayoutClass.expanded; if (isExpanded) { + // Outgoing draft is preserved by ChatDetailView.didUpdateWidget, + // which fires onDraftChanged with the old target's text on rebuild. setState(() { - _saveCurrentDraft(); _chatUnread = 0; _chatOpen = true; _inlineChatTarget = newTarget; @@ -1754,8 +1756,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } void _closeInlineChat() { + // Final draft for the dismissed target is preserved by + // ChatDetailView.dispose, which fires onDraftChanged when the panel + // is removed from the tree on the next rebuild. setState(() { - _saveCurrentDraft(); _lastDismissedTarget = _inlineChatTarget; _lastDismissedClientName = _inlineChatClientName; _chatOpen = false; @@ -1788,8 +1792,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _inlineChatTarget == null) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tap the chat button to continue your conversation'), + SnackBar( + content: Text(AppL10n.of(context).chatPanelCollapsedHint), ), ); }); @@ -2654,10 +2658,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { snapshot: _snapshot!, target: inlineChatTarget, clientName: _inlineChatClientName, - restoredDraft: - _chatDrafts[_draftKeyForTarget( - inlineChatTarget, - )], + restoredDraft: _currentDraftFor( + inlineChatTarget, + ), onDraftChanged: (text) { final draftKey = _draftKeyForTarget( inlineChatTarget, diff --git a/apps/chanora_flutter/lib/widgets/chat_panel.dart b/apps/chanora_flutter/lib/widgets/chat_panel.dart index 893e92b..cbc9b8b 100644 --- a/apps/chanora_flutter/lib/widgets/chat_panel.dart +++ b/apps/chanora_flutter/lib/widgets/chat_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../design/breakpoints.dart'; +import '../l10n/generated/app_localizations.dart'; import '../services/snapshot_state_mapper.dart'; import '../services/ts3_server_link.dart'; import '../src/rust/api.dart' as rust; @@ -51,14 +52,15 @@ class ChatPanel extends StatelessWidget { Widget build(BuildContext context) { final currentChannelId = ownClientSnapshotState(snapshot)?.channelId; final channelName = snapshotChannelName(snapshot, currentChannelId); + final l10n = AppL10n.of(context); return SizedBox( width: ChanoraBreakpoints.chatPanelWidth, child: DecoratedBox( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - border: Border( - left: BorderSide( + border: BorderDirectional( + start: BorderSide( color: Theme.of(context).colorScheme.outlineVariant, ), ), @@ -75,7 +77,7 @@ class ChatPanel extends StatelessWidget { onDraftChanged: onDraftChanged, messageMaxWidth: 500, headerTrailing: IconButton( - tooltip: 'Close chat', + tooltip: l10n.chatCloseAction, icon: const Icon(Icons.close), onPressed: onClose, ), diff --git a/apps/chanora_flutter/test/widgets/chat_panel_test.dart b/apps/chanora_flutter/test/widgets/chat_panel_test.dart index 28fcd48..4a5ad13 100644 --- a/apps/chanora_flutter/test/widgets/chat_panel_test.dart +++ b/apps/chanora_flutter/test/widgets/chat_panel_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; import 'package:chanora_flutter/src/rust/api.dart' as rust; import 'package:chanora_flutter/widgets/chat_panel.dart'; import 'package:chanora_flutter/widgets/chat_views.dart'; @@ -55,6 +56,8 @@ void main() { await tester.pumpWidget( MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, home: Scaffold( body: ChatPanel( messages: messages, @@ -87,6 +90,8 @@ void main() { required String? restoredDraft, }) { return MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, home: Scaffold( body: ChatDetailView( messages: messages, diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index bd9adff..ec61510 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -1818,18 +1818,27 @@ struct CaptureState { /// after warmup. Same precedent as `mono_scratch` above. frame_scratch: Vec, audio_processing_stats: Arc, - /// Decimation counter for the level meter. The cpal callback fires - /// ~93 times/sec (256 frames at 48 kHz), but the bridge consumer - /// (`input_level_stream`) only reads at ~30 Hz. Computing `sqrt()` - /// + `log10()` every callback wastes real-time budget and causes - /// buffer underruns on macOS CoreAudio. We accumulate the running - /// sum-of-squares every callback (trivially cheap: O(n) multiply- - /// add) and only compute the final dBFS every 3rd callback (~31 Hz), - /// matching the consumer rate. See commit history for the metering - /// regression that motivated this. - level_decimation_counter: u32, + /// Time-based decimation for the level meter. The cpal callback + /// cadence depends on platform (256 frames at 48 kHz ≈ 187 Hz, + /// 512 frames ≈ 94 Hz, 1024 frames ≈ 47 Hz) and can change at + /// runtime on device or sample-rate switch. The bridge consumer + /// (`input_level_stream`) only reads at ~30 Hz, so computing + /// `sqrt()` + `log10()` on every callback wastes real-time budget + /// and caused buffer underruns on macOS CoreAudio with small + /// buffer sizes. We only emit a new dBFS sample after at least + /// [LEVEL_METER_INTERVAL] has elapsed since the previous emit, + /// which is platform-cadence-independent. + last_level_emit: std::time::Instant, } +/// Minimum interval between input-level dBFS samples sent to the +/// bridge. Matches the consumer rate (`input_level_stream` at ~30 Hz). +/// Time-based gating is robust to cpal buffer-size and sample-rate +/// changes that a fixed callback-count would not be. +#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] +const LEVEL_METER_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(33); + #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] impl CaptureState { fn new( @@ -1857,7 +1866,10 @@ impl CaptureState { mono_scratch: Vec::with_capacity(4096), frame_scratch: Vec::with_capacity(FRAME_SAMPLES), audio_processing_stats, - level_decimation_counter: 0, + // Start in the past so the first ingest emits immediately. + last_level_emit: std::time::Instant::now() + .checked_sub(LEVEL_METER_INTERVAL) + .unwrap_or_else(std::time::Instant::now), } } @@ -1877,11 +1889,12 @@ impl CaptureState { self.mono_scratch.push(sum / frame.len() as f32); } - // Level meter: accumulate sum-of-squares on every callback - // (trivially cheap), but only pay for sqrt() + log10() every - // 3rd callback (~31 Hz, matching the bridge consumer rate). - self.level_decimation_counter = self.level_decimation_counter.wrapping_add(1); - if self.level_decimation_counter % 3 == 0 { + // Level meter: pay sqrt() + log10() only when at least + // LEVEL_METER_INTERVAL has elapsed, regardless of the + // platform's cpal callback cadence. + let now = std::time::Instant::now(); + if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL { + self.last_level_emit = now; self.audio_processing_stats .set_input_dbfs(crate::frame::dbfs(&self.mono_scratch)); }