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 <noreply@anthropic.com>
This commit is contained in:
Tommy Chow 2026-03-07 00:11:21 -08:00
parent 04e5479acd
commit 9e81b2d80a
4 changed files with 154 additions and 87 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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<void>? _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,9 +246,9 @@ 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 {
_disposeOverlayReaction = reaction((_) => settingsStore.showOverlay, (
showOverlay,
) async {
if (showOverlay) {
await _hideDefaultOverlay();
if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) {
@ -262,19 +265,20 @@ abstract class VideoStoreBase with Store {
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<void> _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;
if (!this.isCycling && !this.timeoutId) {
this._cycle();
}
},
stop() {
@ -513,12 +515,16 @@ 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() {
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;
@ -553,6 +559,8 @@ abstract class VideoStoreBase with Store {
this._readLatency();
await this._setStatsEnabled(false);
if (!this.isRunning) return;
const totalActiveTime = Date.now() - cycleStart;
// Calculate next interval
@ -565,6 +573,9 @@ abstract class VideoStoreBase with Store {
// Schedule next cycle
this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000));
} finally {
this.isCycling = false;
}
},
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<void> 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<void> _updateStreamInfoInternal() async {
try {
_streamInfo = await twitchApi.getStream(userLogin: userLogin);
// Clear offline info when stream is live

View file

@ -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', () {