1252 lines
40 KiB
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());
|
|
}
|
|
}
|
|
}
|