diff --git a/docs/chat-perf-backlog.md b/docs/chat-perf-backlog.md new file mode 100644 index 00000000..53e3dc7e --- /dev/null +++ b/docs/chat-perf-backlog.md @@ -0,0 +1,93 @@ +# Chat performance backlog + +Findings from a chat-rendering performance audit (2026-07), verified against the pinned Flutter 3.44.2 SDK source and installed package sources. The top-ranked confirmed items were fixed on `tc/chat-perf`; this file tracks what remains, ranked by expected impact. Line references are to the SDK/package versions pinned at audit time. + +## Fixed in the initial pass (for context) + +- `findChildIndexCallback` on both chat ListViews. Per-item `ObjectKey`s without it forced deactivate + re-inflate of every built row on every 200 ms flush (`SliverMultiBoxAdaptorElement.performRebuild` matches purely by position when `findIndexByKey` returns null; key mismatch at each shifted index lands in the remount branch of `Element.updateChild`, framework.dart:4047). +- Per-message widget caching (`Expando` in `chat.dart`). Identical widget instances short-circuit `Element.updateChild` (framework.dart:4014), so a flush only builds genuinely new messages instead of re-running `generateSpan` for every visible row. Invalidated via `IRCMessage.renderRevision` (bumped by the in-place CLEARCHAT/CLEARMSG mutations). +- `memCacheHeight` for emotes and badges (decode at display size instead of the CDN's 3x-4x intrinsic size; verified animation-safe: `ResizeImage` applies the target size to the codec, all frames included). +- `TickerMode(enabled: false)` on inactive `IndexedStack` tabs (the `Image` widget pauses animated codecs when TickerMode is off, image.dart:1151; IndexedStack's own `Visibility(maintainAnimation: true)` wrapper never disables tickers). +- Flush timers pause while the app is backgrounded (`onAppPaused`/`onAppResumed` through `ChatTabsStore` -> each tab's `ChatStore` + the merged render timer). Frames stop when paused (`SchedulerBinding.framesEnabled`) but Dart timers don't, so the 5 Hz wakeups were pure battery cost. +- Startup orphaned-cache sweep switched from `listSync` (synchronous I/O on the UI isolate, up to ~10k files) to async `list()`. +- `adjustChatNameColor` memoized keyed by (color, background, target contrast). + +## Remaining items, ranked + +### 1. "Animate emotes" setting (first-frame / static mode) + +The biggest remaining battery lever. Every mounted animated emote keeps a decode -> `scheduleFrameCallback` loop running indefinitely while any listener is attached (cached_network_image ships its own copy of the SDK's `MultiFrameImageStreamCompleter` with identical behavior: `multi_image_stream_completer.dart:165`). "Mounted" includes rows inside the 500 px `scrollCacheExtent`, not just visible ones. The engine never idles at vsync while a busy chat is on screen. OS reduce-motion already pauses this (Image honors `MediaQuery.disableAnimations`), and inactive tabs now pause via TickerMode, but the visible tab still animates dozens of emotes continuously. + +Options: a setting that requests static CDN variants (7TV/BTTV/FFZ all serve static URLs), or a one-frame custom ImageProvider. Effort: M-L. User-visible setting. + +### 2. Disconnect IRC while backgrounded (Android) + +Flush timers now pause, but every IRC line is still received and parsed while backgrounded (50+ msg/s in busy channels = continuous CPU + radio with zero visible output; on iOS the OS suspends the runtime anyway, so this is Android + iOS-PiP). Twitch's own app disconnects chat in background. Disconnect after a grace period, reconnect on resume, optionally backfill via recent-messages when the setting is on. Behavior-visible: a scrollback gap while backgrounded. Effort: M. + +### 3. Skip flushing inactive tabs + +Offstage IndexedStack tabs still lay out and rebuild their lists on every 200 ms flush (builds are cheap now with widget caching, but `RenderStack.performLayout` is O(N) over all children, stack.dart:678, and each offstage viewport lays out its cache-extent rows). Gate `addMessages` for non-active tabs; the buffer overflow drain already bounds growth. Must keep flushing when `mergedMode` is on (merged view reads each tab's `messages`), and `hasUnreadMessages` should read the buffer too. Effort: M. + +### 4. BTTV badge SVGs bypass the disk cache + +`SvgPicture.network` (irc.dart `_createBadgeWidget`) uses plain http with a ref-counted in-memory picture cache (flutter_svg 2.3.0 loaders.dart:436; vector_graphics `_livePictureCache` evicts when the last widget goes away). Badges re-download every session and can re-fetch within a session. Route bytes through `CustomCacheManager` and feed `SvgPicture.string`/a bytes loader. Effort: S-M. + +### 5. Merged-mode dirty check + +`_refreshMergedMessages` collects + sorts up to tabs x 100 items and assigns a new list identity every 200 ms tick even when no new messages arrived, re-firing the merged list observer at a constant 5 Hz. Track per-tab message counts and short-circuit when unchanged. Effort: S. + +### 6. Trim IRCMessage memory (drop `raw` retention) + +Each message retains the full raw IRC line (0.5-1 KB) plus substring copies in `tags`, the `split` word list, and an unconditionally allocated (usually empty) `localEmotes` map: roughly 1.5-3 KB per message, ~8-15 MB per tab at the 5000 cap, ~100 MB order across 10 busy tabs. Verify nothing consumes `raw` after parse and drop or lazily derive it; skip allocating empty `localEmotes`. Effort: S-M. + +### 7. Dispose TapGestureRecognizers from generateSpan + +`TextSpan.recognizer` contract says the owner must dispose (text_span.dart:124), and nothing does. Verified low severity: an idle recognizer holds no timers, arena entries, or router routes (those attach on pointer-down only), so undisposed ones are plain GC-able garbage, and widget caching now creates them once per message instead of per rebuild. Remaining real defect: a row rebuild between pointer-down and up swaps recognizers and silently drops that tap. Proper fix needs recognizer ownership with a dispose hook (stateful row or registry keyed to message eviction). Effort: M. + +### 8. Lighter tap targets than per-emote InkWell + +Each emote/badge InkWell eagerly builds ~7 widgets, a FocusNode, and a global focus-highlight listener (ink_well.dart:929, build at :1386); Tooltips on historical/shared-chat badges each add a global pointer route consulted on every pointer event (raw_tooltip.dart:793). Mostly a first-build cost now that rows are cached; if profiling still shows element bloat, swap emotes to `GestureDetector` (loses the ripple, which never realistically fires in chat). Effort: S. + +### 9. memCache sizing for remaining image sites + +Not yet covered: the shared-chat source avatar (raw `CachedNetworkImage` with `imageBuilder` in irc.dart, needs verification of how memCacheWidth interacts with imageBuilder's provider), `ProfilePicture`, and the emote menu panels. Effort: S. + +### 10. Adaptive CDN density selection + +Emote URLs still request max density (Twitch `3.0`, BTTV `3x`, FFZ `4x`, 7TV largest non-AVIF) and rely on decode-time downscaling. Picking the 2x variant when DPR x display-size allows would also save bandwidth and disk. Tradeoff: cache churn when `emoteScale` changes. 7TV note: dims come from `files.first` (1x) while the URL is the largest file; that is intentional (layout at 1x size), keep it. Effort: M. + +### 11. Parse-path micro-optimizations + +All parse-time (once per message, not per frame), verified minor: per-grapheme `regexEmoji.hasMatch` loop in `IRCMessage.fromString` (single-pass scan instead), blocked-users linear list scan per message (use a Set of user ids), muted-words O(words x patterns). Effort: S. + +### 12. errorWidget for failed images + +No `errorWidget`/`errorBuilder` anywhere in lib; failed emote/badge/avatar loads render as silent blanks. Hygiene + debuggability. Effort: S. + +### 13. Screen-reader story for the chat list + +With a screen reader on, a 5 Hz-updating list of WidgetSpan-heavy rows is a semantics hazard (each emote is an unlabeled node; constant re-announcement). Semantics are compiled only when assistive tech is active (object.dart:1397), so this costs nothing today, but consider per-row `Semantics(label:)` flattening and update throttling when semantics are enabled. Effort: M-L. + +### 14. Measurement follow-ups + +- Profile-mode DevTools timeline during a busy raid chat: UI thread (span builds should now be flat between new messages) and raster thread (animated emote layers). +- Memory view: ImageCache pressure before/after memCacheHeight (entry count is the binding cap: 1000 images / 100 MiB defaults, image_cache.dart:18). +- Android `dumpsys batterystats` / Xcode Energy gauge for the background and animated-emote items. +- A benchmark harness feeding recorded IRC traffic through `ChatStore` would make regressions visible in CI; nothing covers chat rendering today. + +## Existing optimizations: verified verdicts (do not relitigate) + +| Optimization | Verdict | +| --- | --- | +| 200 ms flush batching | KEEP (right pattern; interval fine) | +| Render cap 100 while autoscrolling, 5000 buffer + 20% trim | KEEP (trim realloc is per-event, cheap; see item 6 for the per-message footprint) | +| `ObjectKey(ircMessage)` on rows | KEEP, now paired with `findChildIndexCallback` (alone it was actively harmful: forced remounts) | +| `addAutomaticKeepAlives: false` | KEEP (micro; known tradeoff: in-flight ink splash can be culled, SelectionArea rows lose keep-alive) | +| `scrollCacheExtent: 500 px` | KEEP (default is 250, applied both sides; revisit only alongside item 1 since it doubles the live animated-emote set) | +| Disk cache 10k objects / 30 d | KEEP (cleanup is lazy, debounced, SQL-side, bounded to 200 deletes per pass) | +| `useFade: false` + const placeholders on emotes | KEEP | +| `cacheKey` on FrostyCachedNetworkImage | KEEP (live for stream thumbnails' 5-min busting; unused in chat paths by design) | +| ProfilePicture static URL cache | KEEP (unbounded but tiny; known staleness until app restart) | +| IndexedStack tab keep-alive | KEEP, now paired with TickerMode gating | +| Per-message `Opacity` fades | KEEP (compositing layer, not saveLayer; static opacity is cheap and adds a repaint boundary) | +| Muted-word pre-normalization, interaction pause, delayed-callback queue, 60 s socket ping | KEEP | diff --git a/lib/cache_manager.dart b/lib/cache_manager.dart index b3d61f48..d6d30879 100644 --- a/lib/cache_manager.dart +++ b/lib/cache_manager.dart @@ -23,7 +23,10 @@ class CustomCacheManager { return; } - final allFiles = cacheDir.listSync(recursive: true).toList(); + // Async listing — the cache can hold up to maxNrOfCacheObjects (10k) + // files, and a synchronous recursive listing that large would block the + // UI isolate during startup. + final allFiles = await cacheDir.list(recursive: true).toList(); await _repo.open(); final cachedObjects = await _repo.getAllObjects(); diff --git a/lib/models/irc.dart b/lib/models/irc.dart index e7f7ce78..7009ca23 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -42,6 +42,12 @@ class IRCMessage { final VoidCallback? actionCallback; final String? actionLabel; + /// Bumped whenever the message is mutated in place after creation (the + /// moderation marking in [clearChat]/[clearMessage]), so render output + /// cached against this message's identity knows to invalidate. Purely a + /// render-cache signal — not part of the IRC model. + int renderRevision = 0; + IRCMessage({ required this.raw, required this.command, @@ -85,6 +91,7 @@ class IRCMessage { if (message.user == bannedUser) { // Mark the message for removal. messages[i].command = Command.clearChat; + messages[i].renderRevision++; // If timed-out, indicate the duration. if (banDuration != null) { @@ -98,6 +105,7 @@ class IRCMessage { if (bufferedMessage.user == bannedUser) { // Mark the message for removal. bufferedMessages[i].command = Command.clearChat; + bufferedMessages[i].renderRevision++; // If timed-out, indicate the duration. if (banDuration != null) { @@ -119,6 +127,7 @@ class IRCMessage { for (var i = 0; i < messages.length; i++) { if (messages[i].tags['id'] == targetId) { messages[i].command = Command.clearMessage; + messages[i].renderRevision++; break; } } @@ -127,6 +136,7 @@ class IRCMessage { for (var i = 0; i < bufferedMessages.length; i++) { if (bufferedMessages[i].tags['id'] == targetId) { bufferedMessages[i].command = Command.clearMessage; + bufferedMessages[i].renderRevision++; break; } } @@ -591,6 +601,10 @@ class IRCMessage { imageUrl: emote.url, height: emote.height != null ? emote.height! * emoteScale : emoteSize, width: emote.width != null ? emote.width! * emoteScale : emoteSize, + memCacheHeight: _memCacheDimension( + context, + emote.height != null ? emote.height! * emoteScale : emoteSize, + ), useFade: false, ), ), @@ -748,6 +762,7 @@ class IRCMessage { double? size, Color? backgroundColor, bool? isSvg, + int? memCacheHeight, }) { if (backgroundColor != null) { return ColoredBox( @@ -756,6 +771,7 @@ class IRCMessage { imageUrl: badge.url, height: size, width: size, + memCacheHeight: memCacheHeight, useFade: false, ), ); @@ -766,6 +782,7 @@ class IRCMessage { imageUrl: badge.url, height: size, width: size, + memCacheHeight: memCacheHeight, useFade: false, ); } @@ -801,6 +818,7 @@ class IRCMessage { size: size, backgroundColor: backgroundColor, isSvg: isSvg, + memCacheHeight: _memCacheDimension(context, size), ), ), ); @@ -825,6 +843,9 @@ class IRCMessage { imageUrl: emote.url, height: height, width: width, + // Decode at the rendered size instead of the CDN's 3x-4x intrinsic + // size. Height-only preserves the aspect ratio. + memCacheHeight: _memCacheDimension(context, height), useFade: false, placeholder: (context, url) => const SizedBox(), ), @@ -832,6 +853,11 @@ class IRCMessage { ); } + /// Converts a layout dimension to a physical-pixel decode cap for the + /// in-memory image cache. + static int _memCacheDimension(BuildContext context, double logicalSize) => + (logicalSize * MediaQuery.devicePixelRatioOf(context)).round(); + static TextSpan _createTextSpan({ required String text, required bool launchExternal, diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index c66dae36..6c130a8a 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -128,6 +128,12 @@ class _VideoChatState extends State if (state == AppLifecycleState.resumed) { _videoStore.handleAppResume(); + _chatTabsStore.onAppResumed(); + } else if (state == AppLifecycleState.paused) { + // No frames are produced while backgrounded, but Dart timers keep + // firing — stop the chat flush timers until resume. (Messages keep + // buffering; PiP hides chat anyway, so pausing during PiP is safe.) + _chatTabsStore.onAppPaused(); } } diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 80ceda50..ffe2e0ee 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/models/irc.dart'; +import 'package:frosty/models/user.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; @@ -16,6 +18,27 @@ import 'package:frosty/widgets/frosty_scrollbar.dart'; /// ahead of fast scroll-back without holding the entire history. const _chatCacheExtent = ScrollCacheExtent.pixels(500.0); +/// Cached [ChatMessage] instances keyed by message identity. Returning the +/// identical widget instance across list rebuilds lets the framework skip the +/// row's build entirely (`Element.updateChild` short-circuits on identical +/// widgets), so a batch flush only builds the genuinely new messages instead +/// of regenerating every visible row's spans. Entries are GC'd together with +/// their messages; [IRCMessage.renderRevision] invalidates rows the moderation +/// handlers mutate in place. MobX reactivity is unaffected — each row's +/// Observer subscription lives on its element, not its widget instance. +final _messageWidgetCache = Expando<_CachedMessageWidget>(); + +/// Merged-mode cache, kept separate because merged rows carry per-active-tab +/// parameters that must invalidate the cached widget when they change. +final _mergedMessageWidgetCache = Expando<_CachedMessageWidget>(); + +class _CachedMessageWidget { + const _CachedMessageWidget(this.widget, this.revision); + + final ChatMessage widget; + final int revision; +} + class Chat extends StatelessWidget { final ChatStore chatStore; final EdgeInsetsGeometry? listPadding; @@ -176,6 +199,10 @@ class Chat extends StatelessWidget { ScrollController scrollController, double bottomPadding, ) { + final messages = chatStore.renderMessages; + // Built lazily on the first keyed-child lookup of a rebuild. + Map? sliverIndexByMessage; + return ListView.builder( reverse: true, padding: (listPadding ?? EdgeInsets.zero).add( @@ -184,23 +211,51 @@ class Chat extends StatelessWidget { addAutomaticKeepAlives: false, scrollCacheExtent: _chatCacheExtent, controller: scrollController, - itemCount: chatStore.renderMessages.length, + itemCount: messages.length, + // Every appended batch shifts all sliver indices (the list is reversed, + // so new messages land at index 0). Mapping a row's key back to its new + // index here lets the framework *move* the existing element — without + // it, the key mismatch at each shifted index would deactivate and + // re-inflate every built row on every flush. + findChildIndexCallback: (key) { + final lookup = sliverIndexByMessage ??= { + for (var i = 0; i < messages.length; i++) + messages[messages.length - 1 - i]: i, + }; + return lookup[(key as ObjectKey).value]; + }, itemBuilder: (context, index) { - final ircMessage = chatStore - .renderMessages[chatStore.renderMessages.length - 1 - index]; - // Key by message identity so the list reconciles by message rather - // than by slot. This keeps each message's element (and its gesture - // recognizers) bound to the same message when new messages arrive - // mid-interaction, fixing long-press landing on the wrong message. - return ChatMessage( - key: ObjectKey(ircMessage), - ircMessage: ircMessage, - chatStore: chatStore, - ); + final ircMessage = messages[messages.length - 1 - index]; + return _cachedChatMessage(ircMessage); }, ); } + /// Returns the (cached) widget for [ircMessage], building it on first + /// render. Keyed by message identity so the list reconciles by message + /// rather than by slot — this keeps each message's element (and its gesture + /// recognizers) bound to the same message when new messages arrive + /// mid-interaction, fixing long-press landing on the wrong message. + Widget _cachedChatMessage(IRCMessage ircMessage) { + final cached = _messageWidgetCache[ircMessage]; + if (cached != null && + cached.revision == ircMessage.renderRevision && + identical(cached.widget.chatStore, chatStore)) { + return cached.widget; + } + + final widget = ChatMessage( + key: ObjectKey(ircMessage), + ircMessage: ircMessage, + chatStore: chatStore, + ); + _messageWidgetCache[ircMessage] = _CachedMessageWidget( + widget, + ircMessage.renderRevision, + ); + return widget; + } + Widget _buildMergedList( ScrollController scrollController, double bottomPadding, @@ -208,6 +263,8 @@ class Chat extends StatelessWidget { final mergedMessages = chatTabsStore!.mergedMessages; final channelIdToUserTwitch = chatTabsStore!.mergedChannelIdToUserTwitch; final currentChannelId = chatTabsStore!.activeTab.channelId; + // Built lazily on the first keyed-child lookup of a rebuild. + Map? sliverIndexByMessage; return ListView.builder( reverse: true, @@ -218,29 +275,73 @@ class Chat extends StatelessWidget { scrollCacheExtent: _chatCacheExtent, controller: scrollController, itemCount: mergedMessages.length, + // See _buildNormalList — moves existing elements across index shifts. + findChildIndexCallback: (key) { + final lookup = sliverIndexByMessage ??= { + for (var i = 0; i < mergedMessages.length; i++) + mergedMessages[mergedMessages.length - 1 - i].ircMessage: i, + }; + return lookup[(key as ObjectKey).value]; + }, itemBuilder: (context, index) { final merged = mergedMessages[mergedMessages.length - 1 - index]; - return ChatMessage( - key: ObjectKey(merged.ircMessage), - ircMessage: merged.ircMessage, - chatStore: merged.chatStore, - inputChatStore: chatStore, - onActivateSourceTab: () { - final tabs = chatTabsStore!.tabs; - final idx = tabs.indexWhere( - (t) => t.channelId == merged.chatStore.channelId, - ); - if (idx != -1 && idx != chatTabsStore!.activeTabIndex) { - chatTabsStore!.setActiveTab(idx, silent: true); - } - }, - overrideChannelIdToUserTwitch: channelIdToUserTwitch, - overrideCurrentChannelId: currentChannelId, + return _cachedMergedChatMessage( + merged, + channelIdToUserTwitch, + currentChannelId, ); }, ); } + /// Merged-mode variant of [_cachedChatMessage]. The extra identity checks + /// invalidate the cached widget when the active tab (send target) or the + /// combined channel-profile map changes. + Widget _cachedMergedChatMessage( + MergedMessage merged, + Map channelIdToUserTwitch, + String currentChannelId, + ) { + final ircMessage = merged.ircMessage; + final cached = _mergedMessageWidgetCache[ircMessage]; + final cachedWidget = cached?.widget; + if (cached != null && + cachedWidget != null && + cached.revision == ircMessage.renderRevision && + identical(cachedWidget.chatStore, merged.chatStore) && + identical(cachedWidget.inputChatStore, chatStore) && + identical( + cachedWidget.overrideChannelIdToUserTwitch, + channelIdToUserTwitch, + ) && + cachedWidget.overrideCurrentChannelId == currentChannelId) { + return cachedWidget; + } + + final widget = ChatMessage( + key: ObjectKey(ircMessage), + ircMessage: ircMessage, + chatStore: merged.chatStore, + inputChatStore: chatStore, + onActivateSourceTab: () { + final tabs = chatTabsStore!.tabs; + final idx = tabs.indexWhere( + (t) => t.channelId == merged.chatStore.channelId, + ); + if (idx != -1 && idx != chatTabsStore!.activeTabIndex) { + chatTabsStore!.setActiveTab(idx, silent: true); + } + }, + overrideChannelIdToUserTwitch: channelIdToUserTwitch, + overrideCurrentChannelId: currentChannelId, + ); + _mergedMessageWidgetCache[ircMessage] = _CachedMessageWidget( + widget, + ircMessage.renderRevision, + ); + return widget; + } + Widget _buildResumeScrollButton() { return Builder( builder: (context) { diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 356d09db..a905c5fb 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -189,6 +189,12 @@ abstract class ChatStoreBase with Store { /// The periodic timer used for batching chat message re-renders. Timer? _messageBufferTimer; + /// Whether the app is backgrounded. Gates the message flush timer — no + /// frames are produced while paused, so the 5x/s wakeups would be pure + /// battery cost. Messages keep parsing into [messageBuffer] and are flushed + /// on resume (with the overflow drain in [_handleIRCData] bounding growth). + var _isAppPaused = false; + /// The list of chat messages to add once autoscroll is resumed. /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. final messageBuffer = ObservableList(); @@ -817,8 +823,10 @@ abstract class ChatStoreBase with Store { continue; } - if (!_autoScroll) { - // While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak. + if (!_autoScroll || _isAppPaused) { + // While the flush is held (scrolled up, or app backgrounded), + // occasionally move messages from the buffer to the messages to + // prevent unbounded buffer growth. if (messageBuffer.length >= _messagesToRemove) { _messages.addAll(messageBuffer); messageBuffer.clear(); @@ -841,13 +849,7 @@ abstract class ChatStoreBase with Store { ); // Activate the message buffer. - // Cancel any existing timer before creating a new one to prevent duplicates on reconnect. - _messageBufferTimer?.cancel(); - // Create a timer that will add messages from the buffer every 200 milliseconds. - _messageBufferTimer = Timer.periodic( - const Duration(milliseconds: 200), - (timer) => addMessages(), - ); + _startMessageBufferTimer(); // Set up 7TV real-time listener (assets already fetched in connectToChat) if (settings.show7TVEmotes) { @@ -1251,6 +1253,38 @@ abstract class ChatStoreBase with Store { } } + /// (Re)starts the periodic buffer flush. Cancels any existing timer first + /// to prevent duplicates on reconnect; no-ops while the app is backgrounded + /// ([onAppResumed] restarts it). + void _startMessageBufferTimer() { + _messageBufferTimer?.cancel(); + _messageBufferTimer = null; + if (_isAppPaused) return; + // Create a timer that will add messages from the buffer every 200 milliseconds. + _messageBufferTimer = Timer.periodic( + const Duration(milliseconds: 200), + (timer) => addMessages(), + ); + } + + /// Stops the flush timer while the app is backgrounded. + void onAppPaused() { + _isAppPaused = true; + _messageBufferTimer?.cancel(); + _messageBufferTimer = null; + } + + /// Restarts the flush timer after [onAppPaused] and flushes the backlog + /// immediately so chat is current on the first visible frame. + void onAppResumed() { + if (!_isAppPaused) return; + _isAppPaused = false; + if (_hasConnected) { + _startMessageBufferTimer(); + addMessages(); + } + } + @action void addMessages() { _flushDueDelayedCallbacks(); diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index ee095442..5b06cb78 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -407,6 +407,32 @@ abstract class ChatTabsStoreBase with Store { } } + /// Forwards app-pause to every activated tab and stops the merged render + /// timer — no frames are produced while the app is backgrounded, so the + /// periodic flush/recompute work would be pure battery cost. + void onAppPaused() { + _mergedRenderTimer?.cancel(); + _mergedRenderTimer = null; + for (final tab in _tabs) { + tab.chatStore?.onAppPaused(); + } + } + + /// Resumes tab flush timers and (if merged mode is active) the merged + /// render timer after [onAppPaused]. + void onAppResumed() { + for (final tab in _tabs) { + tab.chatStore?.onAppResumed(); + } + if (mergedMode && _mergedRenderTimer == null) { + _refreshMergedMessages(); + _mergedRenderTimer = Timer.periodic( + const Duration(milliseconds: 200), + (_) => _refreshMergedMessages(), + ); + } + } + /// Re-enables auto-scroll and jumps to the latest message in merged view. @action void resumeMergedScroll() { diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 490ce0df..0321169b 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -112,17 +112,26 @@ class ChatTabs extends StatelessWidget { ) : IndexedStack( index: activeIndex, - children: tabs.map((tabInfo) { - if (tabInfo.chatStore == null) { - return const SizedBox.shrink(); - } - return Chat( - key: ValueKey(tabInfo.channelId), - chatStore: tabInfo.chatStore!, - listPadding: adjustedPadding, - onAddChat: () => _handleAddChat(context), - ); - }).toList(), + children: [ + for (var i = 0; i < tabs.length; i++) + if (tabs[i].chatStore == null) + const SizedBox.shrink() + else + // IndexedStack children stay mounted (and keep + // animating) when hidden. Disabling TickerMode + // pauses inactive tabs' animated emotes — the + // Image widget stops its frame-decode loop while + // the ticker mode is off and resumes on switch. + TickerMode( + key: ValueKey(tabs[i].channelId), + enabled: i == activeIndex, + child: Chat( + chatStore: tabs[i].chatStore!, + listPadding: adjustedPadding, + onAddChat: () => _handleAddChat(context), + ), + ), + ], ), ), if (showTabBar) diff --git a/lib/utils.dart b/lib/utils.dart index f0b35278..dbf2e0fd 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -24,6 +24,12 @@ double _contrastRatio(Color a, Color b) { return (hi + 0.05) / (lo + 0.05); } +/// Memoized results for [adjustChatNameColor]. Chat renders the same handful +/// of Twitch palette colors thousands of times per session, and every call +/// costs luminance math (plus a 16-step search for low-contrast colors) — the +/// answer only depends on this key, so compute it once per combination. +final _adjustedNameColorCache = <(int, int, double), Color>{}; + /// Adjusts [color] just enough to reach [targetContrast] on the app's background. /// Defaults to Theme.of(context).scaffoldBackgroundColor; if transparent, falls /// back to theme.colorScheme.surface. @@ -44,6 +50,18 @@ Color adjustChatNameColor( bg = theme.colorScheme.surface; } + final cacheKey = (color.toARGB32(), bg.toARGB32(), targetContrast); + final cached = _adjustedNameColorCache[cacheKey]; + if (cached != null) return cached; + + final adjusted = _adjustNameColorUncached(color, bg, targetContrast); + // Custom hex name colors are unbounded in theory; keep the cache small. + if (_adjustedNameColorCache.length >= 1024) _adjustedNameColorCache.clear(); + _adjustedNameColorCache[cacheKey] = adjusted; + return adjusted; +} + +Color _adjustNameColorUncached(Color color, Color bg, double targetContrast) { if (_contrastRatio(color, bg) >= targetContrast) return color; final hsl0 = HSLColor.fromColor(color); diff --git a/lib/widgets/frosty_cached_network_image.dart b/lib/widgets/frosty_cached_network_image.dart index 0f1f9934..4a2def96 100644 --- a/lib/widgets/frosty_cached_network_image.dart +++ b/lib/widgets/frosty_cached_network_image.dart @@ -8,6 +8,16 @@ class FrostyCachedNetworkImage extends StatelessWidget { final String? cacheKey; final double? width; final double? height; + + /// Decode size caps (physical pixels) for the in-memory image. Without + /// these, images decode and stay resident at their intrinsic size — emote + /// CDNs serve 3x-4x assets (~112-128 px) that render in ~28 lp boxes, so + /// capping the decode cuts memory and raster cost several-fold. Applies to + /// animated codecs too (all frames decode at the target size; animation is + /// preserved). Providing only one axis keeps the aspect ratio. + final int? memCacheWidth; + final int? memCacheHeight; + final Color? color; final BlendMode? colorBlendMode; final Widget Function(BuildContext, String)? placeholder; @@ -21,6 +31,8 @@ class FrostyCachedNetworkImage extends StatelessWidget { this.cacheKey, this.width, this.height, + this.memCacheWidth, + this.memCacheHeight, this.color, this.colorBlendMode, this.placeholder, @@ -36,6 +48,8 @@ class FrostyCachedNetworkImage extends StatelessWidget { cacheKey: cacheKey, width: width, height: height, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, color: color, colorBlendMode: colorBlendMode, placeholder: placeholder, diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 56b1cf99..a3851935 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -339,6 +339,35 @@ void main() { expect(affectedMessage.tags['ban-duration'], '300'); }); + test('bumps renderRevision only on affected messages', () { + final clearChatMsg = IRCMessage( + raw: '', + command: Command.clearChat, + tags: {'ban-duration': '600'}, + message: 'testuser', + ); + bufferedMessages = [ + IRCMessage.fromString(basicPrivmsg), // from testuser + ]; + + IRCMessage.clearChat( + messages: messages, + bufferedMessages: bufferedMessages, + ircMessage: clearChatMsg, + ); + + // Mutated-in-place messages must signal render caches to invalidate. + expect( + messages.where((m) => m.user == 'testuser').first.renderRevision, + 1, + ); + expect(bufferedMessages.first.renderRevision, 1); + expect( + messages.where((m) => m.user == 'moduser').first.renderRevision, + 0, + ); + }); + test('clears entire chat when no target user', () { final clearAllMsg = IRCMessage( raw: '', @@ -408,6 +437,15 @@ void main() { messages.firstWhere((m) => m.tags['id'] == 'msg-2').command, Command.clearMessage, ); + // Mutated-in-place message must signal render caches to invalidate. + expect( + messages.firstWhere((m) => m.tags['id'] == 'msg-2').renderRevision, + 1, + ); + expect( + messages.firstWhere((m) => m.tags['id'] == 'msg-1').renderRevision, + 0, + ); // Other messages unchanged expect( messages.firstWhere((m) => m.tags['id'] == 'msg-1').command, diff --git a/test/utils_test.dart b/test/utils_test.dart index 846540f6..7d359aad 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -198,6 +198,45 @@ void main() { expect(adjustedColor, isNot(const Color(0xFF00FF00))); }); + testWidgets('memoized results are stable and keyed by background', + (tester) async { + late Color first; + late Color second; + late Color onLightBackground; + const darkBackground = Color(0xFF121212); + const lowContrastColor = Color(0xFF1F1F1F); // Near-black on dark bg + + await tester.pumpWidget( + buildTestWidget( + scaffoldBackground: darkBackground, + child: Builder( + builder: (context) { + // Repeated calls with identical inputs must return the same + // adjusted color (second call is served from the memo cache). + first = adjustChatNameColor(context, lowContrastColor); + second = adjustChatNameColor(context, lowContrastColor); + // A different background must not be served the dark-background + // cache entry: near-black already passes on white, so it should + // come back unchanged. + onLightBackground = adjustChatNameColor( + context, + lowContrastColor, + background: const Color(0xFFFFFFFF), + ); + return const SizedBox(); + }, + ), + ), + ); + + expect(second, first); + expect( + first.computeLuminance(), + greaterThan(lowContrastColor.computeLuminance()), + ); + expect(onLightBackground, lowContrastColor); + }); + testWidgets('handles transparent scaffold background', (tester) async { late Color adjustedColor; const darkColor = Color(0xFF000000);