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>
This commit is contained in:
parent
57e7f73aa8
commit
979f46edfe
4 changed files with 95 additions and 20 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue