frosty/lib/screens/channel/video/video_store.dart
Tommy Chow 9cec89a67c fix video refresh race condition causing black screen with play button
Replace JS-side `window._injected` guard with a Dart-controlled `_needsInit`
flag so `initVideo()` reliably runs after every refresh. The previous approach
loaded `about:blank` then `videoUrl` back-to-back, but `loadRequest` resolves
on navigation start (not page load), so the JS context often survived and
`onPageFinished` skipped re-initialization. Also adds an explicit `play()`
call to handle cases where the Twitch player loads in a paused state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:22:44 -08:00

1104 lines
36 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:simple_pip_mode/simple_pip.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
part 'video_store.g.dart';
class VideoStore = VideoStoreBase with _$VideoStore;
abstract class VideoStoreBase with Store {
final TwitchApi twitchApi;
/// The userlogin of the current channel.
final String userLogin;
/// The user ID of the current channel.
final String userId;
final AuthStore authStore;
final SettingsStore settingsStore;
/// The [SimplePip] instance used for initiating PiP on Android.
final pip = SimplePip();
var _firstTimeSettingQuality = true;
/// Whether [initVideo] should run on the next [onPageFinished].
///
/// Dart-controlled instead of JS-side (`window._injected`) to avoid race
/// conditions when `loadRequest` fires before the previous page fully loads.
var _needsInit = true;
/// The video web view params used for enabling auto play.
late final PlatformWebViewControllerCreationParams _videoWebViewParams;
/// The webview controller used for injecting JavaScript to control the webview and video player.
late final WebViewController videoWebViewController =
WebViewController.fromPlatformCreationParams(_videoWebViewParams)
..setBackgroundColor(Colors.black)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'Latency',
onMessageReceived: (message) {
final receivedLatency = message.message;
_latency = receivedLatency;
if (!settingsStore.autoSyncChatDelay) return;
// Parse latency from abbreviated format: "5s" -> 5.0
final numericPart = receivedLatency.replaceAll(
RegExp(r'[^0-9.]'),
'',
);
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
settingsStore.syncedChatDelay = latencyAsDouble;
}
},
)
..addJavaScriptChannel(
'StreamQualities',
onMessageReceived: (message) async {
final data = jsonDecode(message.message) as List;
_availableStreamQualities = data
.map((item) => item as String)
.toList();
if (_firstTimeSettingQuality) {
_firstTimeSettingQuality = false;
if (settingsStore.defaultToHighestQuality) {
await _setStreamQualityIndex(1);
return;
}
final prefs = await SharedPreferences.getInstance();
final lastStreamQuality = prefs.getString('last_stream_quality');
if (lastStreamQuality == null) return;
setStreamQuality(lastStreamQuality);
}
},
)
..addJavaScriptChannel(
'VideoPause',
onMessageReceived: (message) {
_paused = true;
if (Platform.isAndroid) pip.setIsPlaying(false);
},
)
..addJavaScriptChannel(
'VideoPlaying',
onMessageReceived: (message) {
_paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true);
},
)
..addJavaScriptChannel(
'PipEntered',
onMessageReceived: (message) {
_overlayWasVisibleBeforePip = _overlayVisible;
_isInPipMode = true;
_overlayTimer?.cancel();
_overlayVisible = true;
},
)
..addJavaScriptChannel(
'PipExited',
onMessageReceived: (message) {
_isInPipMode = false;
if (_overlayWasVisibleBeforePip) {
_updateLatencyTrackerVisibility(true);
_scheduleOverlayHide();
} else {
_overlayVisible = false;
_updateLatencyTrackerVisibility(false);
}
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) async {
if (url != videoUrl) return;
if (!_needsInit) return;
_needsInit = false;
await initVideo();
_acceptContentWarning();
},
),
);
/// The timer that handles hiding the overlay automatically
Timer? _overlayTimer;
/// Tracks the pre-PiP overlay visibility so we can restore it on exit.
bool _overlayWasVisibleBeforePip = true;
/// The timer that handles periodic stream info updates
Timer? _streamInfoTimer;
/// Timer for periodic JavaScript state cleanup to prevent memory accumulation.
Timer? _jsCleanupTimer;
/// 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;
/// Disposes the video mode reaction for timer management.
late final ReactionDisposer _disposeVideoModeReaction;
ReactionDisposer? _disposeAndroidAutoPipReaction;
/// Disposes the latency settings reaction.
ReactionDisposer? _disposeLatencySettingsReaction;
/// If the video is currently paused.
///
/// Does not pause or play the video, only used for rendering state of the overlay.
@readonly
var _paused = true;
/// If the overlay is should be visible.
@readonly
var _overlayVisible = true;
/// The current stream info, used for displaying relevant info on the overlay.
@readonly
StreamTwitch? _streamInfo;
/// The offline channel info, used for displaying channel details when offline.
@readonly
Channel? _offlineChannelInfo;
@readonly
List<String> _availableStreamQualities = [];
// The current stream quality index
@readonly
int _streamQualityIndex = 0;
// The current stream quality string
String get streamQuality =>
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
@readonly
String? _latency;
/// Whether the app is currently in picture-in-picture mode (iOS only).
/// On Android, this state is not tracked since there's no programmatic exit.
@readonly
var _isInPipMode = false;
/// The video URL to use for the webview.
String get videoUrl =>
'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty';
VideoStoreBase({
required this.userLogin,
required this.userId,
required this.twitchApi,
required this.authStore,
required this.settingsStore,
}) {
// Reset chat delay to 0 if auto sync is already enabled to prevent starting with old values
if (settingsStore.autoSyncChatDelay) {
settingsStore.syncedChatDelay = 0.0;
}
// Initialize the video webview params for iOS to enable video autoplay.
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
_videoWebViewParams = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
_videoWebViewParams = const PlatformWebViewControllerCreationParams();
}
// Initialize the video webview params for Android to enable video autoplay.
if (videoWebViewController.platform is AndroidWebViewController) {
(videoWebViewController.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
// Initialize the [_overlayTimer] to auto-hide the overlay after a delay (default 5 seconds).
_scheduleOverlayHide();
// 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());
}
}
});
// 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();
}
});
// Check initial state and start timer if already in chat-only mode
if (!settingsStore.showVideo) {
_startStreamInfoTimer();
_scheduleOverlayHide();
} else {
_startJsCleanupTimer();
}
// On Android, enable auto PiP mode (setAutoEnterEnabled) if the device supports it.
if (Platform.isAndroid) {
_disposeAndroidAutoPipReaction = autorun((_) async {
if (settingsStore.showVideo && await SimplePip.isAutoPipAvailable) {
pip.setAutoPipMode();
} else {
pip.setAutoPipMode(autoEnter: false);
}
});
}
updateStreamInfo();
// React to changes in latency-related settings mid-session
// This handles the case where user toggles autoSyncChatDelay or showLatency
// while already watching a stream
_disposeLatencySettingsReaction = reaction(
(_) => (settingsStore.showLatency, settingsStore.autoSyncChatDelay),
(values) async {
final (showLatency, autoSync) = values;
// Only act if overlay is enabled (latency tracker only works with custom overlay)
if (!settingsStore.showOverlay) return;
if (showLatency || autoSync) {
// Start tracker if either setting is now enabled
// The init() method is idempotent - won't double-start if already running
await _listenOnLatencyChanges();
} else {
// Stop tracker if both settings are now disabled
try {
videoWebViewController.runJavaScript(
'window._latencyTracker?.stop()',
);
} catch (e) {
debugPrint(e.toString());
}
}
},
);
}
@action
Future<void> updateStreamQualities() async {
try {
await videoWebViewController.runJavaScript(r'''
_queuePromise(async () => {
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
if (!settingsBtn) return;
try {
settingsBtn.click();
const qualityItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]');
if (!qualityItem) return;
qualityItem.click();
// Wait until at least one quality option is rendered
await _asyncQuerySelector(
'[data-a-target="player-settings-menu"] input[name="player-settings-submenu-quality-option"] + label'
);
// Grab every label, normalise whitespace, return as array
const qualities = Array.from(
document.querySelectorAll(
'[data-a-target="player-settings-menu"] input[name="player-settings-submenu-quality-option"] + label'
)
).map(l => l.textContent.replace(/\s+/g, ' ').trim());
StreamQualities.postMessage(JSON.stringify(qualities));
} finally {
// Always close the settings panel
settingsBtn.click();
}
});
''');
} catch (e) {
debugPrint(e.toString());
}
}
@action
Future<void> setStreamQuality(String newStreamQuality) async {
final indexOfStreamQuality = _availableStreamQualities.indexOf(
newStreamQuality,
);
if (indexOfStreamQuality == -1) return;
await _setStreamQualityIndex(indexOfStreamQuality);
}
@action
Future<void> _setStreamQualityIndex(int newStreamQualityIndex) async {
try {
await videoWebViewController.runJavaScript('''
_queuePromise(async () => {
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
if (!settingsBtn) return;
try {
settingsBtn.click();
const qualityItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]');
if (!qualityItem) return;
qualityItem.click();
await _asyncQuerySelector('[data-a-target="player-settings-submenu-quality-option"] input');
const inputs = [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')];
if (inputs[$newStreamQualityIndex]) {
inputs[$newStreamQualityIndex].click();
}
} finally {
settingsBtn.click();
}
});
''');
_streamQualityIndex = newStreamQualityIndex;
} catch (e) {
debugPrint(e.toString());
}
}
/// Hides the default Twitch overlay elements using CSS injection.
///
/// CSS rules apply automatically to present and future DOM elements,
/// so no MutationObserver is needed.
Future<void> _hideDefaultOverlay() async {
try {
await videoWebViewController.runJavaScript('''
{
if (!document.getElementById('frosty-overlay-styles')) {
const style = document.createElement('style');
style.id = 'frosty-overlay-styles';
style.textContent = `
.top-bar,
.player-controls,
#channel-player-disclosures,
[data-a-target="player-overlay-preview-background"],
[data-a-target="player-overlay-video-stats"] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
`;
document.head.appendChild(style);
}
}
''');
} catch (e) {
debugPrint(e.toString());
}
}
/// Removes the custom CSS that hides Twitch's default overlay,
/// restoring the native Twitch player controls.
void _showDefaultOverlay() {
try {
videoWebViewController.runJavaScript(
"document.getElementById('frosty-overlay-styles')?.remove();",
);
} catch (e) {
debugPrint(e.toString());
}
}
Future<void> _acceptContentWarning() async {
try {
await videoWebViewController.runJavaScript('''
{
(async () => {
const warningBtn = await _asyncQuerySelector('button[data-a-target*="content-classification-gate"]', 10000);
if (warningBtn) {
warningBtn.click();
}
})();
}
''');
} catch (e) {
debugPrint(e.toString());
}
}
/// Sets up visibility-aware latency tracking.
///
/// Key optimizations over previous implementation:
/// - Only tracks when overlay is visible (pauses when hidden)
/// - Properly clears setTimeout IDs to prevent accumulation
/// - Has explicit stop() method for cleanup
/// - Skips cycles entirely when overlay hidden (saves CPU)
Future<void> _listenOnLatencyChanges() async {
try {
await videoWebViewController.runJavaScript(r'''
window._latencyTracker ??= {
CYCLE_INTERVAL: 60000,
STATS_ACTIVE_TIME: 1500,
INITIAL_RETRY_INTERVAL: 3000,
MAX_INITIAL_RETRIES: 4,
cycleCount: 0,
hasInitialLatency: false,
timeoutId: null,
isRunning: false,
overlayVisible: true,
isCycling: false,
init() {
if (this.isRunning) return;
this.isRunning = true;
if (!this.isCycling && !this.timeoutId) {
this._cycle();
}
},
stop() {
this.isRunning = false;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
},
setOverlayVisible(visible) {
this.overlayVisible = visible;
// Resume immediately when overlay becomes visible
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;
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;
}
},
async _setStatsEnabled(enable) {
try {
await _queuePromise(async () => {
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
if (!settingsBtn) return;
settingsBtn.click();
const advancedItem = await _asyncQuerySelector('[data-a-target="player-settings-menu-item-advanced"]');
if (!advancedItem) {
settingsBtn.click();
return;
}
advancedItem.click();
const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input');
if (statsCheckbox && statsCheckbox.checked !== enable) {
statsCheckbox.click();
}
settingsBtn.click();
});
} catch (error) {
// Silently fail - stats panel may not be available
}
},
_readLatency() {
try {
const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]');
if (latencyElement && latencyElement.textContent) {
let latencyText = latencyElement.textContent.trim();
// Convert to whole number with abbreviated unit: "4.69 sec." -> "5s"
const match = latencyText.match(/([0-9.]+)\s*sec/i);
if (match) {
const rounded = Math.round(parseFloat(match[1]));
latencyText = rounded + 's';
this.hasInitialLatency = true;
}
if (window.Latency) {
Latency.postMessage(latencyText);
}
}
} catch (error) {
// Silently fail
}
}
};
window._latencyTracker.init();
''');
_updateLatencyTrackerVisibility(_overlayVisible);
} catch (e) {
debugPrint(e.toString());
}
}
/// Updates the latency tracker's overlay visibility state.
///
/// When overlay is hidden, the latency tracker pauses its polling cycle
/// to save CPU. When overlay becomes visible again, it resumes.
///
/// However, if autoSyncChatDelay is enabled, the tracker always runs
/// regardless of overlay visibility to keep chat delay accurate.
void _updateLatencyTrackerVisibility(bool visible) {
// Skip if latency tracking isn't active at all
if (!settingsStore.showLatency && !settingsStore.autoSyncChatDelay) return;
// If auto-sync is enabled, always keep tracker running (don't pause on hide)
// to ensure chat delay stays synchronized even during long viewing sessions
if (settingsStore.autoSyncChatDelay) {
// Always report visible to keep tracking active
try {
videoWebViewController.runJavaScript(
'window._latencyTracker?.setOverlayVisible(true)',
);
} catch (e) {
debugPrint(e.toString());
}
return;
}
// Only pause/resume based on visibility if just showLatency is enabled
try {
videoWebViewController.runJavaScript(
'window._latencyTracker?.setOverlayVisible($visible)',
);
} catch (e) {
debugPrint(e.toString());
}
}
/// Performs a soft reset of JavaScript state to prevent memory accumulation.
///
/// This runs periodically during long viewing sessions to clear accumulated
/// state without requiring a full page reload. Resets:
/// - Promise queue (clears any stale promise chains)
/// - Queue length counter
/// - Generation counter (prevents stale callbacks from decrementing length)
///
/// Only resets if queue is idle to avoid orphaning in-flight operations
/// (which could leave the settings menu open).
void _performJsSoftReset() {
try {
videoWebViewController.runJavaScript('''
// Only reset if queue is idle to avoid orphaning in-flight operations
if (window._promiseQueueLength === 0) {
window._PROMISE_QUEUE = Promise.resolve();
window._promiseQueueGen = (window._promiseQueueGen || 0) + 1;
}
''');
} catch (e) {
debugPrint('JS soft reset error: $e');
}
}
/// Initializes the video webview.
@action
Future<void> initVideo() async {
if (await videoWebViewController.currentUrl() == videoUrl) {
// Declare `window` level utility methods and add event listeners to notify the JavaScript channels when the video plays and pauses.
try {
await videoWebViewController.runJavaScript('''
// Promise queue with length limit and generation tracking
// Generation tracking prevents counter from going negative when queue resets
window._PROMISE_QUEUE = Promise.resolve();
window._promiseQueueLength = 0;
window._promiseQueueGen = 0;
window._queuePromise = (method) => {
window._promiseQueueLength++;
// Reset queue if it gets too long (prevents memory accumulation)
if (window._promiseQueueLength > 30) {
window._PROMISE_QUEUE = Promise.resolve();
window._promiseQueueLength = 1;
window._promiseQueueGen++;
}
const myGen = window._promiseQueueGen;
window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(async () => {
try {
await method();
} catch (e) {
console.warn('Queue promise error:', e);
} finally {
// Only decrement if still same generation (not reset)
if (myGen === window._promiseQueueGen) {
window._promiseQueueLength--;
}
}
});
return window._PROMISE_QUEUE;
};
window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => {
let element = document.querySelector(selector);
if (element) {
return resolve(element);
}
let timeoutId;
const observer = new MutationObserver(() => {
element = document.querySelector(selector);
if (element) {
observer.disconnect();
clearTimeout(timeoutId);
resolve(element);
}
});
// Must use subtree to detect nested elements in the player
observer.observe(document.body, { childList: true, subtree: true });
// Always set timeout with default of 10 seconds
timeoutId = setTimeout(() => {
observer.disconnect();
resolve(undefined);
}, timeout);
});
_queuePromise(async () => {
const videoElement = await _asyncQuerySelector("video");
// Null check in case element was not found within timeout
if (!videoElement) {
console.warn("Video element not found within timeout");
return;
}
// Cache for reuse in play/pause/PiP calls
window._videoEl = videoElement;
// Prevent duplicate event listener registration
if (!videoElement._listenersAdded) {
videoElement._listenersAdded = true;
videoElement.addEventListener("pause", () => {
VideoPause.postMessage("video paused");
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
videoElement.textTracks[0].mode = "hidden";
}
});
videoElement.addEventListener("playing", () => {
VideoPlaying.postMessage("video playing");
// Ensure video is unmuted and captions are hidden
videoElement.muted = false;
videoElement.volume = 1.0;
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
videoElement.textTracks[0].mode = "hidden";
}
});
// Add PiP event listeners for iOS
videoElement.addEventListener("enterpictureinpicture", () => {
PipEntered.postMessage("pip entered");
});
videoElement.addEventListener("leavepictureinpicture", () => {
PipExited.postMessage("pip exited");
});
}
if (!videoElement.paused) {
VideoPlaying.postMessage("video playing");
// Ensure video is unmuted and captions are hidden
videoElement.muted = false;
videoElement.volume = 1.0;
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
videoElement.textTracks[0].mode = "hidden";
}
}
// Explicitly attempt autoplay in case the player loaded paused
try { await videoElement.play(); } catch (e) {}
});
''');
if (settingsStore.showOverlay) {
await _hideDefaultOverlay();
// Start latency tracking if either:
// - showLatency is enabled (user wants to see it on overlay), OR
// - autoSyncChatDelay is enabled (needs latency data for syncing)
if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) {
await _listenOnLatencyChanges();
}
await updateStreamQualities();
}
} catch (e) {
debugPrint(e.toString());
}
}
}
/// Called whenever the video/overlay is tapped.
@action
void handleVideoTap() {
if (_isInPipMode) {
_overlayVisible = true;
return;
}
_overlayTimer?.cancel();
if (_overlayVisible) {
_overlayVisible = false;
// Notify latency tracker to pause when overlay hidden
_updateLatencyTrackerVisibility(false);
} else {
updateStreamInfo(forceUpdate: true);
_overlayVisible = true;
// Notify latency tracker to resume when overlay visible
_updateLatencyTrackerVisibility(true);
_scheduleOverlayHide();
}
}
/// Starts the periodic stream info timer for chat-only mode.
void _startStreamInfoTimer() {
// Only start if not already active
if (_streamInfoTimer?.isActive != true) {
_streamInfoTimer = Timer.periodic(
const Duration(seconds: 60),
(_) => updateStreamInfo(),
);
}
}
/// Stops the periodic stream info timer.
void _stopStreamInfoTimer() {
if (_streamInfoTimer?.isActive == true) {
_streamInfoTimer?.cancel();
_streamInfoTimer = null;
}
}
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();
if (_isInPipMode) {
_overlayVisible = true;
return;
}
_overlayTimer = Timer(delay, () {
if (_isInPipMode) return;
runInAction(() {
_overlayVisible = false;
});
// Notify latency tracker to pause when overlay auto-hides
_updateLatencyTrackerVisibility(false);
});
}
/// Handles app resume event for immediate stream info refresh in chat-only mode.
@action
void handleAppResume() {
// Only refresh immediately in chat-only mode
if (!settingsStore.showVideo) {
updateStreamInfo(forceUpdate: true);
}
}
/// Updates the stream info from the Twitch API.
///
/// If the stream is offline, fetches channel information to show offline details.
/// 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) {
final timeSinceLastUpdate = now.difference(_lastStreamInfoUpdate!);
if (timeSinceLastUpdate.inSeconds < 5) {
return; // Skip update if less than 5 seconds since last update
}
}
_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
_offlineChannelInfo = null;
} catch (e) {
_overlayTimer?.cancel();
_streamInfo = null;
_paused = true;
// Try to fetch offline channel information
try {
_offlineChannelInfo = await twitchApi.getChannel(userId: userId);
} catch (channelError) {
_offlineChannelInfo = null;
}
// Restart overlay timer in chat-only mode even on error
if (!settingsStore.showVideo) {
_scheduleOverlayHide();
}
}
}
/// Handles the toggle overlay options.
///
/// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay.
@action
void handleToggleOverlay() {
if (settingsStore.toggleableOverlay) {
HapticFeedback.mediumImpact();
settingsStore.showOverlay = !settingsStore.showOverlay;
if (settingsStore.showOverlay) {
_overlayVisible = true;
_scheduleOverlayHide(const Duration(seconds: 3));
}
}
// Stream info timer is managed automatically by the video mode reaction
}
/// Refreshes the stream webview and updates the stream info.
@action
Future<void> handleRefresh() async {
HapticFeedback.lightImpact();
_paused = true;
_firstTimeSettingQuality = true;
_isInPipMode = false;
_latency = null;
_availableStreamQualities = [];
// Signal that initVideo() should run on the next onPageFinished
_needsInit = true;
try {
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
} catch (e) {
// Ignore - may not exist yet
}
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
updateStreamInfo();
}
/// Play or pause the video depending on the current state of [_paused].
void handlePausePlay() {
try {
if (_paused) {
videoWebViewController.runJavaScript(
'(window._videoEl || document.getElementsByTagName("video")[0])?.play();',
);
} else {
videoWebViewController.runJavaScript(
'(window._videoEl || document.getElementsByTagName("video")[0])?.pause();',
);
}
} catch (e) {
debugPrint(e.toString());
}
}
/// Initiate picture in picture if available.
///
/// On Android, this will utilize the native Android PiP API.
/// On iOS, this will utilize the web picture-in-picture API.
void requestPictureInPicture() {
try {
if (Platform.isAndroid) {
pip.enterPipMode(autoEnter: true);
} else if (Platform.isIOS) {
videoWebViewController.runJavaScript(
'(window._videoEl || document.getElementsByTagName("video")[0])?.requestPictureInPicture();',
);
}
} catch (e) {
debugPrint(e.toString());
}
}
/// Toggle picture-in-picture mode.
///
/// If not in PiP mode, enters PiP mode.
/// If already in PiP mode on iOS, exits PiP mode.
/// On Android, always enters PiP mode (no programmatic exit or state tracking).
@action
void togglePictureInPicture() {
try {
if (Platform.isIOS && _isInPipMode) {
// Exit PiP mode on iOS
videoWebViewController.runJavaScript('''
(function() {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
}
})();
''');
} else {
// Enter PiP mode (both iOS and Android)
requestPictureInPicture();
}
} catch (e) {
debugPrint(e.toString());
}
}
@action
void dispose() {
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
if (Platform.isAndroid) {
SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) {
if (isAutoPipAvailable) pip.setAutoPipMode(autoEnter: false);
});
}
_overlayTimer?.cancel();
_streamInfoTimer?.cancel();
_jsCleanupTimer?.cancel();
_disposeOverlayReaction();
_disposeVideoModeReaction();
_disposeAndroidAutoPipReaction?.call();
_disposeLatencySettingsReaction?.call();
// Explicitly stop latency tracker as defense-in-depth.
// The Video widget also loads about:blank during disposal which clears
// all JavaScript state, but this provides an extra safety layer.
try {
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
} catch (e) {
// Ignore - page may already be unloading
}
}
}