clean up and harden webview video JS infrastructure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93cd39c99f
commit
a3b8ac3237
1 changed files with 92 additions and 75 deletions
|
|
@ -241,10 +241,28 @@ abstract class VideoStoreBase with Store {
|
|||
// Initialize the [_overlayTimer] to auto-hide the overlay after a delay (default 5 seconds).
|
||||
_scheduleOverlayHide();
|
||||
|
||||
// Initialize a reaction that will reload the webview whenever the overlay is toggled.
|
||||
// Initialize a reaction that incrementally applies/removes overlay customizations
|
||||
// without reloading the webview (which would tear down the HLS stream).
|
||||
_disposeOverlayReaction = reaction(
|
||||
(_) => settingsStore.showOverlay,
|
||||
(_) => videoWebViewController.loadRequest(Uri.parse(videoUrl)),
|
||||
(showOverlay) async {
|
||||
if (showOverlay) {
|
||||
await _hideDefaultOverlay();
|
||||
if (settingsStore.showLatency || settingsStore.autoSyncChatDelay) {
|
||||
await _listenOnLatencyChanges();
|
||||
}
|
||||
await updateStreamQualities();
|
||||
} else {
|
||||
_showDefaultOverlay();
|
||||
try {
|
||||
videoWebViewController.runJavaScript(
|
||||
'window._latencyTracker?.stop()',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Initialize a reaction to manage stream info timer based on video mode
|
||||
|
|
@ -321,26 +339,33 @@ abstract class VideoStoreBase with Store {
|
|||
try {
|
||||
await videoWebViewController.runJavaScript(r'''
|
||||
_queuePromise(async () => {
|
||||
// Open the settings → quality submenu
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]')).click();
|
||||
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
|
||||
if (!settingsBtn) return;
|
||||
|
||||
// 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'
|
||||
);
|
||||
try {
|
||||
settingsBtn.click();
|
||||
|
||||
// Grab every label, normalise whitespace, return as array
|
||||
const qualities = Array.from(
|
||||
document.querySelectorAll(
|
||||
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'
|
||||
)
|
||||
).map(l => l.textContent.replace(/\s+/g, ' ').trim());
|
||||
);
|
||||
|
||||
StreamQualities.postMessage(JSON.stringify(qualities));
|
||||
// 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());
|
||||
|
||||
// Close the settings panel again
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
|
||||
StreamQualities.postMessage(JSON.stringify(qualities));
|
||||
} finally {
|
||||
// Always close the settings panel
|
||||
settingsBtn.click();
|
||||
}
|
||||
});
|
||||
''');
|
||||
} catch (e) {
|
||||
|
|
@ -362,11 +387,24 @@ abstract class VideoStoreBase with Store {
|
|||
try {
|
||||
await videoWebViewController.runJavaScript('''
|
||||
_queuePromise(async () => {
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-menu-item-quality"]')).click();
|
||||
await _asyncQuerySelector('[data-a-target="player-settings-submenu-quality-option"] input');
|
||||
[...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')][$newStreamQualityIndex].click();
|
||||
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
|
||||
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;
|
||||
|
|
@ -377,11 +415,8 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
/// Hides the default Twitch overlay elements using CSS injection.
|
||||
///
|
||||
/// This approach is far more efficient than JavaScript DOM manipulation:
|
||||
/// - CSS rules are applied by the browser's native rendering engine
|
||||
/// - No MutationObserver overhead watching the entire DOM tree
|
||||
/// - Handles dynamically added elements automatically via CSS cascade
|
||||
/// - Single one-time observer that disconnects immediately after player loads
|
||||
/// CSS rules apply automatically to present and future DOM elements,
|
||||
/// so no MutationObserver is needed.
|
||||
Future<void> _hideDefaultOverlay() async {
|
||||
try {
|
||||
await videoWebViewController.runJavaScript('''
|
||||
|
|
@ -402,18 +437,6 @@ abstract class VideoStoreBase with Store {
|
|||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Single one-time observer just to detect when player loads
|
||||
// Disconnects immediately - CSS handles everything after
|
||||
const observer = new MutationObserver((_, obs) => {
|
||||
if (document.querySelector('.video-player__overlay')) {
|
||||
obs.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Safety timeout: disconnect observer if player never loads
|
||||
setTimeout(() => observer.disconnect(), 30000);
|
||||
}
|
||||
''');
|
||||
} catch (e) {
|
||||
|
|
@ -421,6 +444,18 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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('''
|
||||
|
|
@ -496,22 +531,27 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
// Skip cycle entirely if overlay not visible - major CPU savings
|
||||
if (!this.overlayVisible) {
|
||||
// Check again in 5 seconds in case overlay becomes visible
|
||||
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._enableStats();
|
||||
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._disableStats();
|
||||
await this._setStatsEnabled(false);
|
||||
|
||||
const totalActiveTime = Date.now() - cycleStart;
|
||||
|
||||
|
|
@ -527,7 +567,7 @@ abstract class VideoStoreBase with Store {
|
|||
this.timeoutId = setTimeout(() => this._cycle(), Math.max(nextInterval, 1000));
|
||||
},
|
||||
|
||||
async _enableStats() {
|
||||
async _setStatsEnabled(enable) {
|
||||
try {
|
||||
await _queuePromise(async () => {
|
||||
const settingsBtn = await _asyncQuerySelector('[data-a-target="player-settings-button"]');
|
||||
|
|
@ -542,7 +582,7 @@ abstract class VideoStoreBase with Store {
|
|||
advancedItem.click();
|
||||
|
||||
const statsCheckbox = await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input');
|
||||
if (statsCheckbox && !statsCheckbox.checked) {
|
||||
if (statsCheckbox && statsCheckbox.checked !== enable) {
|
||||
statsCheckbox.click();
|
||||
}
|
||||
|
||||
|
|
@ -553,32 +593,6 @@ abstract class VideoStoreBase with Store {
|
|||
}
|
||||
},
|
||||
|
||||
async _disableStats() {
|
||||
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) {
|
||||
statsCheckbox.click();
|
||||
}
|
||||
|
||||
settingsBtn.click();
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
_readLatency() {
|
||||
try {
|
||||
const latencyElement = document.querySelector('[aria-label="Latency To Broadcaster"]');
|
||||
|
|
@ -709,7 +723,7 @@ abstract class VideoStoreBase with Store {
|
|||
|
||||
return window._PROMISE_QUEUE;
|
||||
};
|
||||
window._asyncQuerySelector = (selector, timeout = 30000) => new Promise((resolve) => {
|
||||
window._asyncQuerySelector = (selector, timeout = 10000) => new Promise((resolve) => {
|
||||
let element = document.querySelector(selector);
|
||||
if (element) {
|
||||
return resolve(element);
|
||||
|
|
@ -727,7 +741,7 @@ abstract class VideoStoreBase with Store {
|
|||
// Must use subtree to detect nested elements in the player
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Always set timeout with default of 30 seconds
|
||||
// Always set timeout with default of 10 seconds
|
||||
timeoutId = setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(undefined);
|
||||
|
|
@ -743,6 +757,9 @@ abstract class VideoStoreBase with Store {
|
|||
return;
|
||||
}
|
||||
|
||||
// Cache for reuse in play/pause/PiP calls
|
||||
window._videoEl = videoElement;
|
||||
|
||||
// Prevent duplicate event listener registration
|
||||
if (!videoElement._listenersAdded) {
|
||||
videoElement._listenersAdded = true;
|
||||
|
|
@ -961,11 +978,11 @@ abstract class VideoStoreBase with Store {
|
|||
try {
|
||||
if (_paused) {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].play();',
|
||||
'(window._videoEl || document.getElementsByTagName("video")[0])?.play();',
|
||||
);
|
||||
} else {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].pause();',
|
||||
'(window._videoEl || document.getElementsByTagName("video")[0])?.pause();',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -983,7 +1000,7 @@ abstract class VideoStoreBase with Store {
|
|||
pip.enterPipMode(autoEnter: true);
|
||||
} else if (Platform.isIOS) {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].requestPictureInPicture();',
|
||||
'(window._videoEl || document.getElementsByTagName("video")[0])?.requestPictureInPicture();',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue