video store optimization attempts

* Dispose WebViewController and remove JavaScript channels properly
  * Track and cleanup all MutationObservers to prevent accumulation
  * Throttle latency updates to once per 5 seconds (reduces CPU load)
  * Add timeout protection and null checks for robustness
  * Prevent duplicate event listeners on page reloads
This commit is contained in:
Tommy Chow 2025-09-30 21:38:04 -07:00
parent 9ff61890a4
commit b1df7b0de4
2 changed files with 146 additions and 43 deletions

View file

@ -347,6 +347,11 @@ abstract class VideoStoreBase with Store {
try {
await videoWebViewController.runJavaScript('''
{
// Initialize observer tracking array if it doesn't exist
if (!window._observers) {
window._observers = [];
}
const hideElements = (...el) => {
el.forEach((el) => {
el?.style.setProperty("display", "none", "important");
@ -365,9 +370,13 @@ abstract class VideoStoreBase with Store {
hide();
const videoOverlayObserver = new MutationObserver(hide);
videoOverlayObserver.observe(videoOverlay, { childList: true, subtree: true });
// Track the overlay observer for cleanup
window._observers.push(videoOverlayObserver);
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
// Track the main observer for cleanup
window._observers.push(observer);
}
''');
} catch (e) {
@ -402,10 +411,31 @@ abstract class VideoStoreBase with Store {
(await _asyncQuerySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input')).click();
(await _asyncQuerySelector('[data-a-target="player-overlay-video-stats"]')).style.display = "none";
(await _asyncQuerySelector('[data-a-target="player-settings-button"]')).click();
// Wait for and verify latency element exists
const latencyElement = await _asyncQuerySelector('[aria-label="Latency To Broadcaster"]');
if (!latencyElement) {
console.warn("Latency element not found");
return;
}
// Throttle latency updates to once every 5 seconds minimum
let lastLatencyUpdate = 0;
const LATENCY_UPDATE_INTERVAL = 5000; // 5 seconds in milliseconds
const observer = new MutationObserver((changes) => {
Latency.postMessage(changes[0].target.textContent);
const now = Date.now();
if (now - lastLatencyUpdate >= LATENCY_UPDATE_INTERVAL) {
lastLatencyUpdate = now;
Latency.postMessage(changes[0].target.textContent);
}
})
observer.observe(document.querySelector('[aria-label="Latency To Broadcaster"]'), { characterData: true, attributes: false, childList: false, subtree: true });
observer.observe(latencyElement, { characterData: true, attributes: false, childList: false, subtree: true });
// Track the latency observer for cleanup
if (!window._observers) {
window._observers = [];
}
window._observers.push(observer);
});
''');
} catch (e) {
@ -426,50 +456,82 @@ abstract class VideoStoreBase with Store {
window._PROMISE_QUEUE = window._PROMISE_QUEUE.then(method, method);
return window._PROMISE_QUEUE;
};
window._asyncQuerySelector = (selector, timeout = undefined) => new Promise((resolve) => {
window._asyncQuerySelector = (selector, timeout = 30000) => new Promise((resolve) => {
let element = document.querySelector(selector);
if (element) {
return resolve(element);
}
let timeoutId;
const cleanup = () => {
observer.disconnect();
if (timeoutId) clearTimeout(timeoutId);
// Remove from tracking array with null safety
if (window._observers) {
const index = window._observers.indexOf(observer);
if (index > -1) {
window._observers.splice(index, 1);
}
}
};
const observer = new MutationObserver(() => {
element = document.querySelector(selector);
if (element) {
observer.disconnect();
cleanup();
resolve(element);
}
});
observer.observe(document.body, { childList: true, subtree: true });
if (timeout) {
setTimeout(() => {
observer.disconnect();
resolve(undefined);
}, timeout);
// Track observer for cleanup
if (!window._observers) {
window._observers = [];
}
window._observers.push(observer);
// Always set timeout with default of 30 seconds
timeoutId = setTimeout(() => {
cleanup();
resolve(undefined);
}, timeout);
});
_queuePromise(async () => {
const videoElement = await _asyncQuerySelector("video");
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");
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");
});
// Null check in case element was not found within timeout
if (!videoElement) {
console.warn("Video element not found within timeout");
return;
}
// 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");
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");
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
@ -673,8 +735,35 @@ abstract class VideoStoreBase with Store {
}
}
/// Cleans up JavaScript state and observers to prevent memory leaks.
Future<void> _cleanupJavaScript() async {
try {
await videoWebViewController.runJavaScript('''
(function() {
// Disconnect all MutationObservers created by our app
if (window._observers) {
window._observers.forEach(observer => observer.disconnect());
delete window._observers;
}
// Clear window-level state
delete window._PROMISE_QUEUE;
delete window._queuePromise;
delete window._asyncQuerySelector;
delete window._injected;
// Note: Video element listener cleanup is unnecessary since
// about:blank will be loaded immediately after this (see video.dart:63-65)
})();
''');
} catch (e) {
// Ignore errors during cleanup as the WebView may already be disposed
debugPrint('Error during JavaScript cleanup: ${e.toString()}');
}
}
@action
void dispose() {
Future<void> dispose() async {
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
if (Platform.isAndroid) {
SimplePip.isAutoPipAvailable.then((isAutoPipAvailable) {
@ -688,5 +777,21 @@ abstract class VideoStoreBase with Store {
_disposeOverlayReaction();
_disposeVideoModeReaction();
_disposeAndroidAutoPipReaction?.call();
// Clean up JavaScript state and observers
await _cleanupJavaScript();
// Remove all JavaScript channels to prevent memory leaks
try {
videoWebViewController.removeJavaScriptChannel('Latency');
videoWebViewController.removeJavaScriptChannel('StreamQualities');
videoWebViewController.removeJavaScriptChannel('VideoPause');
videoWebViewController.removeJavaScriptChannel('VideoPlaying');
videoWebViewController.removeJavaScriptChannel('PipEntered');
videoWebViewController.removeJavaScriptChannel('PipExited');
} catch (e) {
// Ignore errors if channels were already removed
debugPrint('Error removing JavaScript channels: ${e.toString()}');
}
}
}

View file

@ -231,6 +231,16 @@ mixin _$VideoStore on VideoStoreBase, Store {
);
}
late final _$disposeAsyncAction = AsyncAction(
'VideoStoreBase.dispose',
context: context,
);
@override
Future<void> dispose() {
return _$disposeAsyncAction.run(() => super.dispose());
}
late final _$VideoStoreBaseActionController = ActionController(
name: 'VideoStoreBase',
context: context,
@ -296,18 +306,6 @@ mixin _$VideoStore on VideoStoreBase, Store {
}
}
@override
void dispose() {
final _$actionInfo = _$VideoStoreBaseActionController.startAction(
name: 'VideoStoreBase.dispose',
);
try {
return super.dispose();
} finally {
_$VideoStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''