From d28a3279e49fd1e13c55349c942f03419671eaa9 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Mar 2026 00:11:42 -0800 Subject: [PATCH] replace per-message Future.delayed with lightweight callback queue drains delayed chat/7TV callbacks via the existing 200ms message buffer timer instead of creating one Future.delayed per incoming event, removing the biggest memory-pressure risk on busy channels with chat delay active Co-Authored-By: Claude Opus 4.6 --- .../channel/chat/stores/chat_store.dart | 137 +++++++++++++----- 1 file changed, 104 insertions(+), 33 deletions(-) diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 2ea1c347..7fedd2e8 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -88,6 +88,10 @@ abstract class ChatStoreBase with Store { return false; } + int get _chatDelaySeconds => settings.effectiveChatDelay.toInt(); + + bool get _hasActiveChatDelay => settings.showVideo && _chatDelaySeconds > 0; + final TwitchApi twitchApi; /// The amount of messages to free (remove) when the [_messageLimit] is reached. @@ -163,6 +167,12 @@ abstract class ChatStoreBase with Store { /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. final messageBuffer = ObservableList(); + /// Pending delayed IRC payloads waiting to be rendered. + final _pendingChatCallbacks = <_DelayedCallback>[]; + + /// Pending delayed 7TV payloads waiting to be rendered. + final _pendingSevenTVCallbacks = <_DelayedCallback>[]; + /// The set of message IDs that have been revealed by the user (for deleted messages). final revealedMessageIds = ObservableSet(); @@ -357,9 +367,10 @@ abstract class ChatStoreBase with Store { // Start chat delay countdown when toggling video on, cancel when off reactions.add( reaction((_) => settings.showVideo, (showVideo) { - if (showVideo && settings.chatDelay > 0) { + if (showVideo && settings.effectiveChatDelay > 0) { _startChatDelayCountdown(); } else if (!showVideo) { + _flushPendingDelayedCallbacks(); _cancelChatDelayCountdown(); _chatDelaySyncCompleted = false; } @@ -369,8 +380,9 @@ abstract class ChatStoreBase with Store { // Start chat delay countdown when chatDelay is set (for auto sync mode) // Cancel countdown if delay becomes 0 reactions.add( - reaction((_) => settings.chatDelay, (chatDelay) { + reaction((_) => settings.effectiveChatDelay, (chatDelay) { if (chatDelay == 0) { + _flushPendingDelayedCallbacks(); _cancelChatDelayCountdown(); _chatDelaySyncCompleted = false; } else if (settings.autoSyncChatDelay && @@ -430,6 +442,71 @@ abstract class ChatStoreBase with Store { }); } + void _enqueueDelayedCallback( + List<_DelayedCallback> queue, + void Function() callback, + ) { + queue.add( + _DelayedCallback( + releaseAt: DateTime.now().add(Duration(seconds: _chatDelaySeconds)), + callback: callback, + ), + ); + } + + void _flushDueDelayedCallbacks() { + _flushDueDelayedCallbacksForQueue(_pendingChatCallbacks); + _flushDueDelayedCallbacksForQueue(_pendingSevenTVCallbacks); + } + + void _flushDueDelayedCallbacksForQueue(List<_DelayedCallback> queue) { + if (queue.isEmpty) return; + + final now = DateTime.now(); + final readyCallbacks = <_DelayedCallback>[]; + + queue.removeWhere((delayedCallback) { + final isReady = !delayedCallback.releaseAt.isAfter(now); + if (isReady) { + readyCallbacks.add(delayedCallback); + } + return isReady; + }); + + for (final delayedCallback in readyCallbacks) { + delayedCallback.callback(); + } + } + + void _flushPendingDelayedCallbacks() { + _flushPendingDelayedCallbacksForQueue(_pendingChatCallbacks); + _flushPendingDelayedCallbacksForQueue(_pendingSevenTVCallbacks); + } + + void _flushPendingDelayedCallbacksForQueue(List<_DelayedCallback> queue) { + if (queue.isEmpty) return; + + final pendingCallbacks = List<_DelayedCallback>.from(queue); + queue.clear(); + + for (final delayedCallback in pendingCallbacks) { + delayedCallback.callback(); + } + } + + void _clearPendingDelayedCallbacks({ + bool clearChat = true, + bool clearSevenTV = true, + }) { + if (clearChat) { + _pendingChatCallbacks.clear(); + } + + if (clearSevenTV) { + _pendingSevenTVCallbacks.clear(); + } + } + /// Handle and process the provided string-representation of the IRC data. /// /// If a message, parses the IRC data into an [IRCMessage] and handles it based on the [Command]. @@ -726,14 +803,11 @@ abstract class ChatStoreBase with Store { ); _sevenTVChannel?.sink.close(1000); + _clearPendingDelayedCallbacks(clearChat: false); _sevenTVChannel = WebSocketChannel.connect( Uri.parse('wss://events.7tv.io/v3'), ); - // Track the current connection to prevent stale delayed callbacks - final connectionId = DateTime.now().millisecondsSinceEpoch; - var currentConnectionId = connectionId; - void listener(dynamic data) { // debugPrint(data); final decoded = jsonDecode(data); @@ -778,22 +852,18 @@ abstract class ChatStoreBase with Store { _sevenTVChannelListener?.cancel(); _sevenTVChannelListener = _sevenTVChannel?.stream.listen( (data) { - if (!settings.showVideo || settings.chatDelay == 0) { + if (!_hasActiveChatDelay) { listener(data); } else { - final capturedConnectionId = currentConnectionId; - Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { - // Only process if this is still the active connection - if (capturedConnectionId == currentConnectionId) { - listener(data); - } - }); + _enqueueDelayedCallback( + _pendingSevenTVCallbacks, + () => listener(data), + ); } }, onError: (error) => debugPrint('7TV events error: ${error.toString()}'), onDone: () { - // Invalidate the current connection to cancel pending delayed callbacks - currentConnectionId = 0; + _clearPendingDelayedCallbacks(clearChat: false); debugPrint('7TV events done'); }, ); @@ -808,6 +878,7 @@ abstract class ChatStoreBase with Store { // Cancel existing listener to prevent duplicate message processing _channelListener?.cancel(); + _clearPendingDelayedCallbacks(clearSevenTV: false); _channel?.sink.close(1000); _channel = WebSocketChannel.connect( @@ -815,38 +886,27 @@ abstract class ChatStoreBase with Store { ); // Only show chat delay countdown on initial connection or video toggle, not on reconnects - if (!isReconnect && settings.showVideo && settings.chatDelay > 0) { + if (!isReconnect && _hasActiveChatDelay) { _startChatDelayCountdown(); } - // Track the current connection to prevent stale delayed callbacks - final connectionId = DateTime.now().millisecondsSinceEpoch; - var currentConnectionId = connectionId; - // Listen for new messages and forward them to the handler. _channelListener = _channel?.stream.listen( (data) { final dataStr = data.toString(); // Process immediately if delay is disabled or command should bypass delay - if (!settings.showVideo || - settings.chatDelay == 0 || - _shouldBypassDelay(dataStr)) { + if (!_hasActiveChatDelay || _shouldBypassDelay(dataStr)) { _handleIRCData(dataStr); } else { - final capturedConnectionId = currentConnectionId; - Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { - // Only process if this is still the active connection - if (capturedConnectionId == currentConnectionId) { - _handleIRCData(dataStr); - } + _enqueueDelayedCallback(_pendingChatCallbacks, () { + _handleIRCData(dataStr); }); } }, onError: (error) => debugPrint('Chat error: ${error.toString()}'), onDone: () async { - // Invalidate the current connection to cancel pending delayed callbacks - currentConnectionId = 0; + _clearPendingDelayedCallbacks(clearSevenTV: false); // Mark connection as disconnected for UI _isConnected = false; @@ -986,6 +1046,8 @@ abstract class ChatStoreBase with Store { @action void addMessages() { + _flushDueDelayedCallbacks(); + if (!_autoScroll || messageBuffer.isEmpty) return; _messages.addAll(messageBuffer); @@ -1017,7 +1079,7 @@ abstract class ChatStoreBase with Store { messageBuffer.clear(); } - var remainingSeconds = settings.chatDelay.toInt(); + var remainingSeconds = _chatDelaySeconds; // Create and store reference to the countdown message _countdownMessage = IRCMessage.createNotice( @@ -1248,6 +1310,8 @@ abstract class ChatStoreBase with Store { void dispose() { _shouldDisconnect = true; + _clearPendingDelayedCallbacks(); + _messageBufferTimer?.cancel(); _notificationTimer?.cancel(); _sendingTimeoutTimer?.cancel(); @@ -1286,3 +1350,10 @@ abstract class ChatStoreBase with Store { textFieldFocusNode.requestFocus(); } } + +class _DelayedCallback { + _DelayedCallback({required this.releaseAt, required this.callback}); + + final DateTime releaseAt; + final void Function() callback; +}