Compare commits
4 commits
main
...
tc/chat-pe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86afc4a6c8 | ||
|
|
16f2722a5b | ||
|
|
979f46edfe | ||
|
|
57e7f73aa8 |
12 changed files with 456 additions and 49 deletions
93
docs/chat-perf-backlog.md
Normal file
93
docs/chat-perf-backlog.md
Normal 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 |
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,21 +211,49 @@ 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(
|
||||
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(
|
||||
|
|
@ -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,11 +275,52 @@ 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,
|
||||
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: () {
|
||||
|
|
@ -237,8 +335,11 @@ class Chat extends StatelessWidget {
|
|||
overrideChannelIdToUserTwitch: channelIdToUserTwitch,
|
||||
overrideCurrentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
_mergedMessageWidgetCache[ircMessage] = _CachedMessageWidget(
|
||||
widget,
|
||||
ircMessage.renderRevision,
|
||||
);
|
||||
return widget;
|
||||
}
|
||||
|
||||
Widget _buildResumeScrollButton() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
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),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showTabBar)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue