frosty/lib/screens/channel/video/video_store.dart

1252 lines
40 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/constants.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video/chat_latency_sync.dart';
import 'package:frosty/screens/channel/video/stream_info_poller.dart';
import 'package:frosty/screens/channel/video/video_timing_constants.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/services/stream_proxy_bridge.dart';
import 'package:frosty/services/stream_proxy_config.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';
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();
late final ChatLatencySync _chatLatencySync = ChatLatencySync(
settingsStore: settingsStore,
);
late final StreamInfoPoller _streamInfoPoller = StreamInfoPoller(
twitchApi: twitchApi,
userLogin: userLogin,
userId: userId,
);
var _firstTimeSettingQuality = true;
var _highLatencyCount = 0;
/// 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 the Android web view.
final PlatformWebViewControllerCreationParams _videoWebViewParams =
const PlatformWebViewControllerCreationParams();
late final NavigationDelegate _navigationDelegate = NavigationDelegate(
onPageFinished: _handlePageFinished,
);
final StreamProxyBridge _streamProxyBridge = StreamProxyBridge();
var _streamProxyAttached = false;
/// 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;
if (StreamProxyBridge.castState.value.isCasting) return;
final numericPart = receivedLatency.replaceAll(
RegExp(r'[^0-9.]'),
'',
);
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null &&
latencyAsDouble >=
VideoTimingConstants.highLatencyThresholdSeconds &&
!_paused &&
!_loading &&
_highLatencyCount <
VideoTimingConstants.maxHighLatencyRecoveries) {
_highLatencyCount++;
if (_highLatencyCount == 1) {
debugPrint(
'VideoStore: high latency (${latencyAsDouble.round()}s), seeking to live edge',
);
try {
videoWebViewController.runJavaScript('''
(function() {
const v = window._videoEl || document.querySelector("video");
if (v && v.buffered.length > 0) {
v.currentTime = v.buffered.end(v.buffered.length - 1);
}
})();
''');
} catch (e) {
debugPrint(e.toString());
}
} else {
debugPrint(
'VideoStore: latency still high (${latencyAsDouble.round()}s), refreshing',
);
handleRefresh(haptic: false);
}
return;
}
if (latencyAsDouble != null) {
_highLatencyCount = 0;
_chatLatencySync.report(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(
lastStreamQualityKey(userLogin),
);
if (lastStreamQuality == null) {
await _updateCastContext();
return;
}
await setStreamQuality(lastStreamQuality);
}
await _updateCastContext();
},
)
..addJavaScriptChannel(
'VideoPause',
onMessageReceived: (message) {
_paused = true;
if (Platform.isAndroid) pip.setIsPlaying(false);
},
)
..addJavaScriptChannel(
'VideoPlaying',
onMessageReceived: (message) {
_loading = false;
_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);
/// 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;
/// 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;
/// Disposes the stream proxy settings reaction.
ReactionDisposer? _disposeStreamProxyReaction;
@readonly
var _loading = true;
/// 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';
String get _castQuality {
final quality = streamQuality;
if (quality != 'Auto') return quality;
return settingsStore.defaultToHighestQuality ? 'highest' : 'auto';
}
@readonly
String? _latency;
/// Whether the app is currently in picture-in-picture mode.
@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,
}) {
_chatLatencySync.reset();
// 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();
_stopLatencyTracker();
}
});
// 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();
_stopLatencyTracker();
}
});
// 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);
}
});
_disposeStreamProxyReaction = reaction(
(_) => (
settingsStore.streamProxyMode,
settingsStore.streamProxyUrls.join('\n'),
settingsStore.streamProxyWhitelistedChannels.join('\n'),
),
(_) => unawaited(_syncStreamProxyBridge(refresh: true)),
);
StreamProxyBridge.castState.addListener(_handleCastStateChanged);
}
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;
if (!settingsStore.showVideo || !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();
// If autoSync was just enabled and we already have a cached latency
// value (e.g. from showLatency tracking), apply it immediately instead
// of waiting for the next tracker cycle (up to 60s).
if (autoSync &&
(_latency != null ||
StreamProxyBridge.castState.value.latency != null)) {
_syncChatDelayFromCurrentSource();
}
} else {
// Stop tracker if both settings are now disabled
_stopLatencyTracker();
}
},
);
}
StreamProxyConfig get _streamProxyConfig => StreamProxyConfig.fromSettings(
settingsStore: settingsStore,
currentChannelLogin: userLogin,
);
bool get isCasting => StreamProxyBridge.castState.value.isCasting;
void _handleCastStateChanged() {
final castState = StreamProxyBridge.castState.value;
if (castState.isCasting) {
unawaited(_pauseLocalPlaybackForCast());
_syncChatDelayFromCurrentSource();
return;
}
if (settingsStore.autoSyncChatDelay && _latency != null) {
_syncChatDelayFromLatencyText(_latency!);
}
}
Future<void> _pauseLocalPlaybackForCast() async {
try {
await videoWebViewController.runJavaScript(
'(window._videoEl || document.getElementsByTagName("video")[0])?.pause();',
);
} catch (e) {
debugPrint(e.toString());
} finally {
_paused = true;
if (Platform.isAndroid) pip.setIsPlaying(false);
}
}
void _syncChatDelayFromCurrentSource() {
if (!settingsStore.autoSyncChatDelay) return;
final castState = StreamProxyBridge.castState.value;
final castLatency = castState.latency;
if (castState.isCasting) {
if (castLatency != null) {
_chatLatencySync.report(
(castLatency.inMilliseconds / 1000).ceilToDouble(),
);
}
return;
}
final latency = _latency;
if (latency != null) {
_syncChatDelayFromLatencyText(latency);
}
}
void _syncChatDelayFromLatencyText(String latency) {
// Parse abbreviated Twitch latency format: "5s" -> 5.0.
final numericPart = latency.replaceAll(RegExp(r'[^0-9.]'), '');
final latencyAsDouble = double.tryParse(numericPart);
if (latencyAsDouble != null) {
_chatLatencySync.report(latencyAsDouble);
}
}
Future<void> initializeVideoPlayer() async {
if (!settingsStore.showVideo) return;
await _syncStreamProxyBridge(refresh: false);
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
}
Future<void> _handlePageFinished(String url) async {
if (url != videoUrl) return;
if (!_needsInit) return;
_needsInit = false;
await initVideo();
_acceptContentWarning();
}
Future<void> _syncStreamProxyBridge({required bool refresh}) async {
if (!Platform.isAndroid) return;
final platformController = videoWebViewController.platform;
if (platformController is! AndroidWebViewController) return;
if (_streamProxyAttached) {
await _streamProxyBridge.updateConfig(_streamProxyConfig);
} else {
await _streamProxyBridge.attach(
controller: platformController,
config: _streamProxyConfig,
onPageFinished: (url) => unawaited(_handlePageFinished(url)),
);
_streamProxyAttached = true;
}
await _updateCastContext();
if (refresh && settingsStore.showVideo) {
await handleRefresh(haptic: false);
}
}
Future<void> _updateCastContext() async {
if (!Platform.isAndroid || !_streamProxyAttached) return;
await _streamProxyBridge.updateCastContext(
config: _streamProxyConfig,
title: _streamInfo?.userName ?? userLogin,
subtitle: _streamInfo?.title,
quality: _castQuality,
);
}
@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());
} finally {
await _updateCastContext();
}
}
@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;
await _updateCastContext();
} 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 abbreviated unit: "4.69 sec." -> "4.69s"
const match = latencyText.match(/([0-9.]+)\s*sec/i);
if (match) {
latencyText = Math.round(parseFloat(match[1])) + '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 web video state changes.
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(
VideoTimingConstants.webviewStreamInfoInterval,
(_) => 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(
VideoTimingConstants.jsCleanupInterval,
(_) => _performJsSoftReset(),
);
}
void _stopJsCleanupTimer() {
_jsCleanupTimer?.cancel();
_jsCleanupTimer = null;
}
void _scheduleOverlayHide([
Duration delay = VideoTimingConstants.overlayAutoHide,
]) {
_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 {
final result = await _streamInfoPoller.fetch(forceUpdate: forceUpdate);
if (result == null) return;
runInAction(() {
if (result.stream != null) {
_streamInfo = result.stream;
_offlineChannelInfo = null;
return;
}
_overlayTimer?.cancel();
_loading = false;
_streamInfo = null;
_paused = true;
_offlineChannelInfo = result.offlineChannel;
// Restart overlay timer in chat-only mode even on error
if (!settingsStore.showVideo) {
_scheduleOverlayHide();
}
});
await _updateCastContext();
}
/// 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(VideoTimingConstants.overlayQuickHide);
}
}
// Stream info timer is managed automatically by the video mode reaction
}
/// Refreshes the stream webview and updates the stream info.
@action
Future<void> handleRefresh({bool haptic = true}) async {
if (isCasting) return;
if (haptic) {
HapticFeedback.lightImpact();
}
_paused = true;
_loading = true;
_firstTimeSettingQuality = true;
_highLatencyCount = 0;
_isInPipMode = false;
_latency = null;
_availableStreamQualities = [];
// Signal that initVideo() should run on the next onPageFinished
_needsInit = true;
_stopLatencyTracker();
await _syncStreamProxyBridge(refresh: false);
await videoWebViewController.loadRequest(Uri.parse(videoUrl));
updateStreamInfo();
}
/// Play or pause the video depending on the current state of [_paused].
void handlePausePlay() {
if (isCasting) return;
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.
///
/// Uses the native Android PiP API.
void requestPictureInPicture() {
if (isCasting) return;
try {
if (Platform.isAndroid) {
pip.enterPipMode(autoEnter: true);
}
} catch (e) {
debugPrint(e.toString());
}
}
/// Toggle picture-in-picture mode.
///
/// If not in PiP mode, enters PiP mode.
/// On Android, always enters PiP mode (no programmatic exit or state tracking).
@action
void togglePictureInPicture() {
try {
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();
_disposeStreamProxyReaction?.call();
if (Platform.isAndroid) {
StreamProxyBridge.castState.removeListener(_handleCastStateChanged);
}
final platformController = videoWebViewController.platform;
if (_streamProxyAttached &&
Platform.isAndroid &&
platformController is AndroidWebViewController) {
unawaited(_streamProxyBridge.detach(platformController));
}
_streamProxyBridge.dispose();
// 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.
_stopLatencyTracker();
}
void _stopLatencyTracker() {
try {
videoWebViewController.runJavaScript('window._latencyTracker?.stop()');
} catch (e) {
debugPrint(e.toString());
}
}
}