From 9e81b2d80a2890dc4614bdbcac06f07f20066204 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Mar 2026 00:11:21 -0800 Subject: [PATCH] make auto-synced chat delay session-only and improve video store - split chatDelay into persisted (manual) and session-only (synced) fields so auto-synced latency values don't carry over between channels - make latency tracker singleton/idempotent to prevent overlapping JS pollers - add isCycling guard to prevent re-entrant latency cycles - deduplicate concurrent stream info API requests - tie JS cleanup timer to video mode only (skip in chat-only) - update UI to reflect effective delay instead of persisted value Co-Authored-By: Claude Opus 4.6 --- .../channel/chat/widgets/chat_bottom_bar.dart | 6 +- .../channel/chat/widgets/chat_message.dart | 11 +- lib/screens/channel/video/video_store.dart | 200 +++++++++++------- .../settings/stores/settings_store_test.dart | 24 ++- 4 files changed, 154 insertions(+), 87 deletions(-) diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index de1a7c2b..f24c1009 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -63,9 +63,11 @@ class ChatBottomBar extends StatelessWidget { final isFullscreenOverlay = chatStore.settings.fullScreen && context.isLandscape; + final effectiveChatDelay = chatStore.settings.effectiveChatDelay; + // Check if chat delay is active (for indicator only, doesn't block input) final hasChatDelay = - chatStore.settings.showVideo && chatStore.settings.chatDelay > 0; + chatStore.settings.showVideo && effectiveChatDelay > 0; const loginTooltipMessage = 'Log in to chat'; @@ -301,7 +303,7 @@ class ChatBottomBar extends StatelessWidget { : chatStore.replyingToMessage != null ? 'Reply' : hasChatDelay - ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)' + ? 'Chat (${effectiveChatDelay.toInt()}s delay)' : 'Chat', ), controller: chatStore.textController, diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 0ac1355a..c113bed0 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -125,13 +125,14 @@ class ChatMessage extends StatelessWidget { onTap: () async { await copyMessage(); + final effectiveChatDelay = chatStore.settings.effectiveChatDelay; + final hasChatDelay = - chatStore.settings.showVideo && - chatStore.settings.chatDelay > 0; + chatStore.settings.showVideo && effectiveChatDelay > 0; if (hasChatDelay) { chatStore.updateNotification( - 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)', + 'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)', ); if (context.mounted) { Navigator.pop(context); @@ -278,7 +279,9 @@ class ChatMessage extends StatelessWidget { } // If user is being mentioned in the message, highlight it red. - if (ircMessage.mention == true) highlightColor = context.colorScheme.error; + if (ircMessage.mention == true) { + highlightColor = context.colorScheme.error; + } if (messageHeader != null) { renderMessage = Column( diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index ca9579a1..df9559ac 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -62,7 +62,7 @@ abstract class VideoStoreBase with Store { final latencyAsDouble = double.tryParse(numericPart); if (latencyAsDouble != null) { - settingsStore.chatDelay = latencyAsDouble; + settingsStore.syncedChatDelay = latencyAsDouble; } }, ) @@ -159,6 +159,9 @@ abstract class VideoStoreBase with Store { /// Tracks the last time stream info was updated to prevent double refresh DateTime? _lastStreamInfoUpdate; + /// Tracks an in-flight stream info request so refreshes can be deduplicated. + Future? _streamInfoRequest; + /// Disposes the overlay reactions. late final ReactionDisposer _disposeOverlayReaction; @@ -220,7 +223,7 @@ abstract class VideoStoreBase with Store { }) { // Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values if (settingsStore.autoSyncChatDelay) { - settingsStore.chatDelay = 0.0; + settingsStore.syncedChatDelay = 0.0; } // Initialize the video webview params for iOS to enable video autoplay. if (WebViewPlatform.instance is WebKitWebViewPlatform) { @@ -243,38 +246,39 @@ abstract class VideoStoreBase with Store { // Initialize a reaction that incrementally applies/removes overlay customizations // without reloading the webview (which would tear down the HLS stream). - _disposeOverlayReaction = reaction( - (_) => settingsStore.showOverlay, - (showOverlay) async { - if (showOverlay) { - await _hideDefaultOverlay(); - if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { - await _listenOnLatencyChanges(); - } - await updateStreamQualities(); - } else { - _showDefaultOverlay(); - try { - videoWebViewController.runJavaScript( - 'window._latencyTracker?.stop()', - ); - } catch (e) { - debugPrint(e.toString()); - } + _disposeOverlayReaction = reaction((_) => settingsStore.showOverlay, ( + showOverlay, + ) async { + if (showOverlay) { + await _hideDefaultOverlay(); + if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) { + await _listenOnLatencyChanges(); } - }, - ); + await updateStreamQualities(); + } else { + _showDefaultOverlay(); + try { + videoWebViewController.runJavaScript( + 'window._latencyTracker?.stop()', + ); + } catch (e) { + debugPrint(e.toString()); + } + } + }); - // Initialize a reaction to manage stream info timer based on video mode + // Initialize a reaction to manage timers based on video mode _disposeVideoModeReaction = reaction((_) => settingsStore.showVideo, ( showVideo, ) { if (showVideo) { // In video mode, stop the timer since overlay taps handle refreshing _stopStreamInfoTimer(); + _startJsCleanupTimer(); } else { // In chat-only mode, start the timer for automatic updates _startStreamInfoTimer(); + _stopJsCleanupTimer(); // Ensure overlay timer is active for clean UI _scheduleOverlayHide(); } @@ -284,6 +288,8 @@ abstract class VideoStoreBase with Store { if (!settingsStore.showVideo) { _startStreamInfoTimer(); _scheduleOverlayHide(); + } else { + _startJsCleanupTimer(); } // On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it. @@ -299,13 +305,6 @@ abstract class VideoStoreBase with Store { updateStreamInfo(); - // Initialize periodic JavaScript cleanup timer (every 10 minutes) - // This prevents memory accumulation during long viewing sessions - _jsCleanupTimer = Timer.periodic( - const Duration(minutes: 10), - (_) => _performJsSoftReset(), - ); - // React to changes in latency-related settings mid-session // This handles the case where user toggles autoSyncChatDelay or showLatency // while already watching a stream @@ -484,7 +483,7 @@ abstract class VideoStoreBase with Store { Future _listenOnLatencyChanges() async { try { await videoWebViewController.runJavaScript(r''' - window._latencyTracker = { + window._latencyTracker ??= { CYCLE_INTERVAL: 60000, STATS_ACTIVE_TIME: 1500, INITIAL_RETRY_INTERVAL: 3000, @@ -495,11 +494,14 @@ abstract class VideoStoreBase with Store { timeoutId: null, isRunning: false, overlayVisible: true, + isCycling: false, init() { if (this.isRunning) return; this.isRunning = true; - this._cycle(); + if (!this.isCycling && !this.timeoutId) { + this._cycle(); + } }, stop() { @@ -513,58 +515,67 @@ abstract class VideoStoreBase with Store { setOverlayVisible(visible) { this.overlayVisible = visible; // Resume immediately when overlay becomes visible - if (visible && this.isRunning && !this.timeoutId) { + if (visible && this.isRunning && !this.isCycling && !this.timeoutId) { this._cycle(); } }, async _cycle() { - // Clear timeout ID synchronously at entry to prevent race conditions - // (e.g., setOverlayVisible calling _cycle while timeout is firing) - const currentTimeoutId = this.timeoutId; - this.timeoutId = null; - if (currentTimeoutId) { - clearTimeout(currentTimeoutId); + if (this.isCycling) return; + this.isCycling = true; + + try { + // Clear timeout ID synchronously at entry to prevent race conditions + // (e.g., setOverlayVisible calling _cycle while timeout is firing) + const currentTimeoutId = this.timeoutId; + this.timeoutId = null; + if (currentTimeoutId) { + clearTimeout(currentTimeoutId); + } + + if (!this.isRunning) return; + + // Skip cycle entirely if overlay not visible - major CPU savings + if (!this.overlayVisible) { + this.timeoutId = setTimeout(() => this._cycle(), 5000); + return; + } + + // Defer if another operation (e.g., quality selection) is using the settings menu + if (window._promiseQueueLength > 0) { + this.timeoutId = setTimeout(() => this._cycle(), 2000); + return; + } + + this.cycleCount++; + const cycleStart = Date.now(); + + await this._setStatsEnabled(true); + + // Wait longer on first cycle for stats to populate + const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; + await new Promise(resolve => setTimeout(resolve, waitTime)); + + this._readLatency(); + await this._setStatsEnabled(false); + + if (!this.isRunning) return; + + const totalActiveTime = Date.now() - cycleStart; + + // Calculate next interval + let nextInterval; + if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) { + nextInterval = this.INITIAL_RETRY_INTERVAL; + } else { + nextInterval = this.CYCLE_INTERVAL - totalActiveTime; + } + + // Schedule next cycle + this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); + } finally { + this.isCycling = false; } - - if (!this.isRunning) return; - - // Skip cycle entirely if overlay not visible - major CPU savings - if (!this.overlayVisible) { - this.timeoutId = setTimeout(() => this._cycle(), 5000); - return; - } - - // Defer if another operation (e.g., quality selection) is using the settings menu - if (window._promiseQueueLength > 0) { - this.timeoutId = setTimeout(() => this._cycle(), 2000); - return; - } - - this.cycleCount++; - const cycleStart = Date.now(); - - await this._setStatsEnabled(true); - - // Wait longer on first cycle for stats to populate - const waitTime = this.cycleCount === 1 ? 3000 : this.STATS_ACTIVE_TIME; - await new Promise(resolve => setTimeout(resolve, waitTime)); - - this._readLatency(); - await this._setStatsEnabled(false); - - const totalActiveTime = Date.now() - cycleStart; - - // Calculate next interval - let nextInterval; - if (!this.hasInitialLatency && this.cycleCount < this.MAX_INITIAL_RETRIES) { - nextInterval = this.INITIAL_RETRY_INTERVAL; - } else { - nextInterval = this.CYCLE_INTERVAL - totalActiveTime; - } - - // Schedule next cycle - this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000)); }, async _setStatsEnabled(enable) { @@ -619,6 +630,7 @@ abstract class VideoStoreBase with Store { window._latencyTracker.init(); '''); + _updateLatencyTrackerVisibility(_overlayVisible); } catch (e) { debugPrint(e.toString()); } @@ -859,6 +871,20 @@ abstract class VideoStoreBase with Store { } } + void _startJsCleanupTimer() { + if (_jsCleanupTimer?.isActive == true) return; + + _jsCleanupTimer = Timer.periodic( + const Duration(minutes: 10), + (_) => _performJsSoftReset(), + ); + } + + void _stopJsCleanupTimer() { + _jsCleanupTimer?.cancel(); + _jsCleanupTimer = null; + } + void _scheduleOverlayHide([Duration delay = const Duration(seconds: 5)]) { _overlayTimer?.cancel(); @@ -893,6 +919,11 @@ abstract class VideoStoreBase with Store { /// Set [forceUpdate] to true to bypass the rate limiting check. @action Future updateStreamInfo({bool forceUpdate = false}) async { + if (_streamInfoRequest != null) { + await _streamInfoRequest; + return; + } + // Rate limiting: prevent too frequent updates unless forced final now = DateTime.now(); if (!forceUpdate && _lastStreamInfoUpdate != null) { @@ -904,6 +935,19 @@ abstract class VideoStoreBase with Store { _lastStreamInfoUpdate = now; + final request = _updateStreamInfoInternal(); + _streamInfoRequest = request; + + try { + await request; + } finally { + if (identical(_streamInfoRequest, request)) { + _streamInfoRequest = null; + } + } + } + + Future _updateStreamInfoInternal() async { try { _streamInfo = await twitchApi.getStream(userLogin: userLogin); // Clear offline info when stream is live diff --git a/test/screens/settings/stores/settings_store_test.dart b/test/screens/settings/stores/settings_store_test.dart index 8dc7dcc8..d2fad875 100644 --- a/test/screens/settings/stores/settings_store_test.dart +++ b/test/screens/settings/stores/settings_store_test.dart @@ -27,6 +27,8 @@ void main() { expect(store.timestampType, TimestampType.disabled); expect(store.autoSyncChatDelay, isFalse); expect(store.chatDelay, 0.0); + expect(store.syncedChatDelay, 0.0); + expect(store.effectiveChatDelay, 0.0); expect(store.highlightFirstTimeChatter, isTrue); expect(store.showUserNotices, isTrue); expect(store.emoteMenuButtonOnLeft, isFalse); @@ -63,6 +65,7 @@ void main() { store.showThumbnails = false; store.fontSize = 16.0; store.chatDelay = 5.0; + store.syncedChatDelay = 8.0; store.showVideo = false; store.timestampType = TimestampType.twelve; store.landscapeCutout = LandscapeCutoutType.both; @@ -77,6 +80,8 @@ void main() { expect(restored.showThumbnails, isFalse); expect(restored.fontSize, 16.0); expect(restored.chatDelay, 5.0); + expect(restored.syncedChatDelay, 0.0); + expect(json.containsKey('syncedChatDelay'), isFalse); expect(restored.showVideo, isFalse); expect(restored.timestampType, TimestampType.twelve); expect(restored.landscapeCutout, LandscapeCutoutType.both); @@ -85,9 +90,7 @@ void main() { }); test('unknown ThemeType enum value falls back to system', () { - final store = SettingsStore.fromJson({ - 'themeType': 'nonexistent_theme', - }); + final store = SettingsStore.fromJson({'themeType': 'nonexistent_theme'}); expect(store.themeType, ThemeType.system); }); @@ -147,6 +150,7 @@ void main() { store.showDeletedMessages = true; store.timestampType = TimestampType.twentyFour; store.chatDelay = 10.0; + store.syncedChatDelay = 7.0; store.emoteMenuButtonOnLeft = true; store.landscapeCutout = LandscapeCutoutType.right; store.chatWidth = 0.5; @@ -162,6 +166,7 @@ void main() { expect(store.showDeletedMessages, isFalse); expect(store.timestampType, TimestampType.disabled); expect(store.chatDelay, 0.0); + expect(store.syncedChatDelay, 0.0); expect(store.emoteMenuButtonOnLeft, isFalse); expect(store.landscapeCutout, LandscapeCutoutType.none); expect(store.chatWidth, 0.2); @@ -236,6 +241,19 @@ void main() { expect(store.showLatency, isFalse); expect(store.fontSize, 20.0); // Should remain changed }); + + test('effectiveChatDelay uses synced value only during auto sync', () { + final store = SettingsStore.fromJson({}); + + store.chatDelay = 4.0; + store.syncedChatDelay = 9.0; + + expect(store.effectiveChatDelay, 4.0); + + store.autoSyncChatDelay = true; + + expect(store.effectiveChatDelay, 9.0); + }); }); group('Enums', () {