From b1df7b0de473543283211a489dca698d9d241857 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Tue, 30 Sep 2025 21:38:04 -0700 Subject: [PATCH] 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 --- lib/screens/channel/video/video_store.dart | 167 +++++++++++++++---- lib/screens/channel/video/video_store.g.dart | 22 ++- 2 files changed, 146 insertions(+), 43 deletions(-) diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index d126ba19..4d1f3a99 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -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 _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 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()}'); + } } } diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 9aa9c712..df5fdbe1 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -231,6 +231,16 @@ mixin _$VideoStore on VideoStoreBase, Store { ); } + late final _$disposeAsyncAction = AsyncAction( + 'VideoStoreBase.dispose', + context: context, + ); + + @override + Future 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 '''