From 11b9ce6348c3eae9488be06dd7a613bd6b551179 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 22:01:48 +0000 Subject: [PATCH 001/123] Bump mobx_codegen from 2.7.4 to 2.7.6 Bumps [mobx_codegen](https://github.com/mobxjs/mobx.dart) from 2.7.4 to 2.7.6. - [Release notes](https://github.com/mobxjs/mobx.dart/releases) - [Changelog](https://github.com/mobxjs/mobx.dart/blob/main/CHANGELOG.md) - [Commits](https://github.com/mobxjs/mobx.dart/compare/mobx_codegen-2.7.4...mobx_codegen-2.7.6) --- updated-dependencies: - dependency-name: mobx_codegen dependency-version: 2.7.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pubspec.lock | 24 ++++++++++++------------ pubspec.yaml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9d4a2ad6..611cb76e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "92.0.0" _flutterfire_internals: dependency: transitive description: @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "9.0.0" ansicolor: dependency: transitive description: @@ -692,10 +692,10 @@ packages: dependency: "direct dev" description: name: json_serializable - sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3 + sha256: "5b89c1e32ae3840bb20a1b3434e3a590173ad3cb605896fb0f60487ce2f8104e" url: "https://pub.dev" source: hosted - version: "6.11.2" + version: "6.11.4" leak_tracker: dependency: transitive description: @@ -788,18 +788,18 @@ packages: dependency: "direct main" description: name: mobx - sha256: bf1a90e5bcfd2851fc6984e20eef69557c65d9e4d0a88f5be4cf72c9819ce6b0 + sha256: "25ea9a3ee69243d578fb6288721d8eeb18550d0391c32f896d377d994154bff2" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" mobx_codegen: dependency: "direct dev" description: name: mobx_codegen - sha256: "471b02d0cf7f64d1366944e9dc64353674807d31e48faba2201918cc1601876d" + sha256: "406051a76157fb0a562700110bd2bedd85c959b1cd2d3df740c706bc07539c33" url: "https://pub.dev" source: hosted - version: "2.7.4" + version: "2.7.6" mocktail: dependency: "direct dev" description: @@ -1106,10 +1106,10 @@ packages: dependency: transitive description: name: source_helper - sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723" + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.10" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3be38265..0878e414 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,7 +84,7 @@ dev_dependencies: flutter_lints: ^6.0.0 build_runner: ^2.1.1 json_serializable: ^6.0.0 - mobx_codegen: ^2.0.6 + mobx_codegen: ^2.7.6 flutter_native_splash: ^2.0.5 flutter_launcher_icons: ^0.14.1 mocktail: ^1.0.4 From 93cd39c99f4689cddcad6beaf39f97703c431f47 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 23:18:54 -0800 Subject: [PATCH 002/123] consolidate overlay shadow constants, simplify overlay gradients Co-Authored-By: Claude Opus 4.6 --- .../channel/video/stream_info_bar.dart | 26 +--- lib/screens/channel/video/video_overlay.dart | 125 +++++------------- lib/theme.dart | 9 ++ 3 files changed, 45 insertions(+), 115 deletions(-) diff --git a/lib/screens/channel/video/stream_info_bar.dart b/lib/screens/channel/video/stream_info_bar.dart index 984ccf49..8673eb3a 100644 --- a/lib/screens/channel/video/stream_info_bar.dart +++ b/lib/screens/channel/video/stream_info_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/home/top/categories/category_streams.dart'; +import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/live_indicator.dart'; @@ -45,21 +46,6 @@ class StreamInfoBar extends StatelessWidget { this.displayName, }); - static const _iconShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; - - static const _textShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; TextStyle _getBaseTextStyle( BuildContext context, @@ -70,7 +56,7 @@ class StreamInfoBar extends StatelessWidget { fontSize: fontSize, fontWeight: fontWeight, color: textColor, - shadows: showTextShadows ? _textShadow : null, + shadows: showTextShadows ? kOverlayShadow : null, ) ?? const TextStyle(); } @@ -82,7 +68,7 @@ class StreamInfoBar extends StatelessWidget { color: textColor?.withValues(alpha: 0.7) ?? context.bodySmallColor?.withValues(alpha: 0.7), - shadows: showTextShadows ? _textShadow : null, + shadows: showTextShadows ? kOverlayShadow : null, ) ?? const TextStyle(); } @@ -199,7 +185,7 @@ class StreamInfoBar extends StatelessWidget { size: secondLineSize, color: (textColor ?? context.bodySmallColor) ?.withValues(alpha: 0.7), - shadows: _iconShadow, + shadows: kOverlayShadow, ), const SizedBox(width: 4), Flexible( @@ -270,7 +256,7 @@ class StreamInfoBar extends StatelessWidget { Icons.visibility, size: secondLineSize, color: textColor ?? context.bodySmallColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), const SizedBox(width: 4), Text( @@ -295,7 +281,7 @@ class StreamInfoBar extends StatelessWidget { Icons.gamepad, size: secondLineSize, color: textColor ?? context.bodySmallColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), const SizedBox(width: 4), Flexible( diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 0ab9eafb..4b50dcbe 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -33,21 +33,21 @@ class VideoOverlay extends StatelessWidget { required this.settingsStore, }); - static const _iconShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; - - static const _textShadow = [ - Shadow( - offset: Offset(0, 1), - blurRadius: 4, - color: Color.fromRGBO(0, 0, 0, 0.3), - ), - ]; + static BoxDecoration _overlayGradient({required bool fromTop}) => + BoxDecoration( + gradient: LinearGradient( + begin: fromTop ? Alignment.topCenter : Alignment.bottomCenter, + end: fromTop ? Alignment.bottomCenter : Alignment.topCenter, + colors: const [ + Colors.black, + Color.fromRGBO(0, 0, 0, 0.78), + Color.fromRGBO(0, 0, 0, 0.48), + Color.fromRGBO(0, 0, 0, 0.12), + Colors.transparent, + ], + stops: const [0.0, 0.25, 0.5, 0.8, 1.0], + ), + ); @override Widget build(BuildContext context) { @@ -62,7 +62,7 @@ class VideoOverlay extends StatelessWidget { icon: Icon( Icons.adaptive.arrow_back_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: Navigator.of(context).pop, ); @@ -75,14 +75,14 @@ class VideoOverlay extends StatelessWidget { onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, icon: videoStore.settingsStore.fullScreenChatOverlay - ? Icon(Icons.chat_rounded, shadows: _iconShadow) - : Icon(Icons.chat_outlined, shadows: _iconShadow), + ? Icon(Icons.chat_rounded, shadows: kOverlayShadow) + : Icon(Icons.chat_outlined, shadows: kOverlayShadow), color: surfaceColor, ), ); final videoSettingsButton = IconButton( - icon: Icon(Icons.settings, shadows: _iconShadow), + icon: Icon(Icons.settings, shadows: kOverlayShadow), color: surfaceColor, onPressed: () { videoStore.updateStreamQualities(); @@ -138,7 +138,7 @@ class VideoOverlay extends StatelessWidget { icon: Icon( Icons.refresh_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: videoStore.handleRefresh, ), @@ -155,7 +155,7 @@ class VideoOverlay extends StatelessWidget { ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, @@ -171,7 +171,7 @@ class VideoOverlay extends StatelessWidget { icon: Icon( Icons.screen_rotation_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: () async { if (context.isPortrait) { @@ -221,73 +221,8 @@ class VideoOverlay extends StatelessWidget { final streamInfo = videoStore.streamInfo; final offlineChannelInfo = videoStore.offlineChannelInfo; - // Top gradient - fades from top to bottom, covers top cluster area - final topGradient = BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black, // Solid black for controls - Colors.black.withValues(alpha: 0.95), // Strong coverage - Colors.black.withValues(alpha: 0.88), // Still very strong - Colors.black.withValues(alpha: 0.78), // Strong transition - Colors.black.withValues(alpha: 0.65), // Begin smooth fade - Colors.black.withValues(alpha: 0.48), // Faster fade - Colors.black.withValues(alpha: 0.32), // Quick transition - Colors.black.withValues(alpha: 0.18), // Rapid fade - Colors.black.withValues(alpha: 0.08), // Very light - Colors.black.withValues(alpha: 0.02), // Nearly gone - Colors.transparent, // Transparent end - ], - stops: [ - 0.0, // Top: Full black - solid area for controls - 0.1, // Maintain strong coverage for readability - 0.2, // Still strong black - 0.3, // Begin gradual fade - 0.42, // Smooth transition - 0.52, // Faster fade point - 0.62, // Quick transition - 0.7, // Rapid fade - 0.8, // Very light - 0.9, // Nearly gone - 1.0, // Bottom: Fully transparent - ], - ), - ); - - // Bottom gradient - fades from bottom to top, covers bottom cluster area - final bottomGradient = BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black, // Solid black for controls - Colors.black.withValues(alpha: 0.95), // Strong coverage - Colors.black.withValues(alpha: 0.88), // Still very strong - Colors.black.withValues(alpha: 0.78), // Strong transition - Colors.black.withValues(alpha: 0.65), // Begin smooth fade - Colors.black.withValues(alpha: 0.48), // Faster fade - Colors.black.withValues(alpha: 0.32), // Quick transition - Colors.black.withValues(alpha: 0.18), // Rapid fade - Colors.black.withValues(alpha: 0.08), // Very light - Colors.black.withValues(alpha: 0.02), // Nearly gone - Colors.transparent, // Transparent end - ], - stops: [ - 0.0, // Bottom: Full black - solid area for controls - 0.1, // Maintain strong coverage for readability - 0.2, // Still strong black - 0.3, // Begin gradual fade - 0.42, // Smooth transition - 0.52, // Faster fade point - 0.62, // Quick transition - 0.7, // Rapid fade - 0.8, // Very light - 0.9, // Nearly gone - 1.0, // Top: Fully transparent - ], - ), - ); + final topGradient = _overlayGradient(fromTop: true); + final bottomGradient = _overlayGradient(fromTop: false); if (streamInfo == null) { return Stack( @@ -465,7 +400,7 @@ class VideoOverlay extends StatelessWidget { style: TextStyle( color: surfaceColor, fontWeight: FontWeight.w500, - shadows: _textShadow, + shadows: kOverlayShadow, ), ), ], @@ -495,7 +430,7 @@ class VideoOverlay extends StatelessWidget { Icon( Icons.visibility, size: 14, - shadows: _iconShadow, + shadows: kOverlayShadow, color: surfaceColor, ), Text( @@ -508,7 +443,7 @@ class VideoOverlay extends StatelessWidget { fontFeatures: const [ FontFeature.tabularFigures(), ], - shadows: _textShadow, + shadows: kOverlayShadow, ), ), ], @@ -527,7 +462,7 @@ class VideoOverlay extends StatelessWidget { Icons.speed_rounded, size: 14, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), Observer( builder: (context) => Text( @@ -538,7 +473,7 @@ class VideoOverlay extends StatelessWidget { fontFeatures: const [ FontFeature.tabularFigures(), ], - shadows: _textShadow, + shadows: kOverlayShadow, ), ), ), @@ -566,7 +501,7 @@ class VideoOverlay extends StatelessWidget { ? Icons.picture_in_picture_alt_outlined : Icons.picture_in_picture_alt_rounded, color: surfaceColor, - shadows: _iconShadow, + shadows: kOverlayShadow, ), onPressed: videoStore.togglePictureInPicture, ), diff --git a/lib/theme.dart b/lib/theme.dart index c70c12c5..aa0ba153 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -2,6 +2,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; +/// Shared shadow for overlay controls and text on top of video content. +const kOverlayShadow = [ + Shadow( + offset: Offset(0, 1), + blurRadius: 4, + color: Color.fromRGBO(0, 0, 0, 0.3), + ), +]; + class FrostyThemes { final Color colorSchemeSeed; From a3b8ac3237e2a0403c650810c7a6eb6de83bdcca Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 23:19:02 -0800 Subject: [PATCH 003/123] clean up and harden webview video JS infrastructure Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/video/video_store.dart | 167 ++++++++++++--------- 1 file changed, 92 insertions(+), 75 deletions(-) diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 2d2573b8..ca9579a1 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -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 _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 _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) { From a0b46eaf3da94f694d6c8e7d9ef47f87b46c284b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 23:19:13 -0800 Subject: [PATCH 004/123] fix overlay toggle stream reload, fix spring-back animation listener Co-Authored-By: Claude Opus 4.6 --- lib/screens/channel/channel.dart | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index bda8b7b7..a4fa6b76 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -91,14 +91,17 @@ class _VideoChatState extends State ); // Spring-back animation with smooth easing - _springBackAnimation = - Tween(begin: 0, end: 0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOut), - )..addListener(() { - setState(() { - _pipDragDistance = _springBackAnimation.value; - }); - }); + _springBackAnimation = Tween(begin: 0, end: 0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + + // Attach listener to the controller (not the derived animation) so it + // survives _springBackAnimation being reassigned in _animateSpringBack. + _animationController.addListener(() { + setState(() { + _pipDragDistance = _springBackAnimation.value; + }); + }); // Register as observer for app lifecycle events WidgetsBinding.instance.addObserver(this); @@ -201,7 +204,7 @@ class _VideoChatState extends State /// and an instructional overlay during the drag gesture. Widget _buildPipGestureWrapper({required Widget child, double? aspectRatio}) { return AnimatedBuilder( - animation: Listenable.merge([_animationController, _springBackAnimation]), + animation: _animationController, builder: (context, _) { final currentDragDistance = _isPipDragging ? _pipDragDistance @@ -321,9 +324,12 @@ class _VideoChatState extends State final video = Observer( builder: (context) { - if (!_videoStore.settingsStore.showOverlay) return player; - - return Stack(children: [player, overlay]); + return Stack( + children: [ + player, + if (_videoStore.settingsStore.showOverlay) overlay, + ], + ); }, ); From 68a6c57cf23d3bd39f1426431dfd0cbf107f320d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sat, 7 Feb 2026 14:17:07 -0800 Subject: [PATCH 005/123] update android config: migrate gradle to kts, upgrade firebase plugins, fix edge-to-edge - migrate settings.gradle, build.gradle, app/build.gradle to kotlin dsl (.kts) - upgrade java/kotlin compatibility from 1.8 to 17 - upgrade firebase gradle plugins (google-services 4.4.4, crashlytics 3.0.6, perf 2.0.2) - remove jetifier, add gradle caching and parallel builds - fix windowDrawsSystemBarBackgrounds for edge-to-edge at targetSdk 36 - add dataExtractionRules for android 12+ backup compliance - use layout.buildDirectory api (replaces deprecated buildDir) Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle | 60 ------------------- android/app/build.gradle.kts | 60 +++++++++++++++++++ android/app/src/main/AndroidManifest.xml | 3 +- .../src/main/res/values-night-v31/styles.xml | 2 +- .../app/src/main/res/values-night/styles.xml | 2 +- .../app/src/main/res/values-v31/styles.xml | 2 +- android/app/src/main/res/values/styles.xml | 2 +- .../main/res/xml/data_extraction_rules.xml | 9 +++ android/build.gradle | 18 ------ android/build.gradle.kts | 24 ++++++++ android/gradle.properties | 3 +- android/settings.gradle | 28 --------- android/settings.gradle.kts | 28 +++++++++ 13 files changed, 129 insertions(+), 112 deletions(-) delete mode 100644 android/app/build.gradle create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml delete mode 100644 android/build.gradle create mode 100644 android/build.gradle.kts delete mode 100644 android/settings.gradle create mode 100644 android/settings.gradle.kts diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index d2a897d0..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,60 +0,0 @@ -plugins { - id "com.android.application" - // START: FlutterFire Configuration - id 'com.google.gms.google-services' - id 'com.google.firebase.firebase-perf' - id 'com.google.firebase.crashlytics' - // END: FlutterFire Configuration - id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file("key.properties") -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace = "com.tommychow.frosty" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - - defaultConfig { - applicationId = "com.tommychow.frosty" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - signingConfigs { - release { - keyAlias keystoreProperties["keyAlias"] - keyPassword keystoreProperties["keyPassword"] - storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null - storePassword keystoreProperties["storePassword"] - } - } - buildTypes { - release { - signingConfig signingConfigs.release - } - } -} - -flutter { - source = "../.." -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..c9f675d5 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,60 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") + id("com.google.gms.google-services") + id("com.google.firebase.firebase-perf") + id("com.google.firebase.crashlytics") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "com.tommychow.frosty" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.tommychow.frosty" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7b2ba6ba..4beaec33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,7 +13,8 @@ android:label="Frosty" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:allowBackup="false"> + android:allowBackup="false" + android:dataExtractionRules="@xml/data_extraction_rules"> false false - false + true shortEdges