Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Tommy Chow
86afc4a6c8 docs: add chat performance audit backlog
remaining ranked findings from the 2026-07 chat perf audit (verified
against the pinned flutter 3.44.2 sdk and package sources), plus
keep/tune verdicts for the existing optimizations so they don't get
relitigated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:38 -04:00
Tommy Chow
16f2722a5b perf: async startup cache sweep, memoize chat name color
the orphaned-cache-file sweep ran a synchronous recursive listSync on
the UI isolate at startup against a cache that can hold 10k files; use
the async listing instead. memoize adjustChatNameColor keyed by (color,
background, target contrast) since chat repeats the same handful of
palette colors thousands of times and low-contrast ones pay a 16-step
search per call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:38 -04:00
Tommy Chow
979f46edfe perf(chat): pause chat work when hidden or backgrounded
inactive IndexedStack tabs stay mounted and their animated emotes keep
decoding frames forever; wrap them in TickerMode(enabled: false) so the
Image widget pauses animated codecs until the tab is active again.

while the app is backgrounded no frames are produced but dart timers
keep firing, so the 200ms flush timers (one per tab, plus the merged
render timer) were pure battery cost. pause them on
AppLifecycleState.paused and resume with an immediate catch-up flush;
messages keep buffering with the existing overflow drain bounding
growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:38 -04:00
Tommy Chow
57e7f73aa8 perf(chat): cut per-flush rebuild and decode costs in message list
every 200ms flush shifted all sliver indices (reverse list), and the
per-row ObjectKeys without findChildIndexCallback made the framework
deactivate and re-inflate every built row instead of moving it. add the
callback so elements move, and cache ChatMessage widget instances per
message (Expando) so unchanged rows skip build() entirely via the
identical-widget short-circuit. IRCMessage.renderRevision invalidates
rows the CLEARCHAT/CLEARMSG handlers mutate in place.

also decode emotes/badges at display size (memCacheHeight = logical
size x devicePixelRatio) instead of the CDN's 3x-4x intrinsic size:
several-fold less image memory and raster cost, animation preserved
(ResizeImage applies the target size to the whole codec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:20 -04:00
12 changed files with 456 additions and 49 deletions

93
docs/chat-perf-backlog.md Normal file
View file

@ -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 |

View file

@ -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();

View file

@ -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,

View file

@ -128,6 +128,12 @@ class _VideoChatState extends State<VideoChat>
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();
}
}

View file

@ -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<IRCMessage, int>? 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<IRCMessage, int>? 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<String, UserTwitch> 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) {

View file

@ -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<IRCMessage>();
@ -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();

View file

@ -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() {

View file

@ -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)

View file

@ -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);

View file

@ -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,

View file

@ -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,

View file

@ -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);