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 <noreply@anthropic.com>
This commit is contained in:
parent
9e81b2d80a
commit
d28a3279e4
1 changed files with 104 additions and 33 deletions
|
|
@ -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<IRCMessage>();
|
||||
|
||||
/// 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<String>();
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue