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:
parent
04e5479acd
commit
9e81b2d80a
4 changed files with 154 additions and 87 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,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<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;
|
||||
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<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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue