diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 3411f384..0ec28159 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -112,6 +112,7 @@ class _VideoChatState extends State { final videoOverlay = VideoOverlay( videoStore: _videoStore, chatStore: _chatStore, + settingsStore: settingsStore, ); if (_videoStore.paused || _videoStore.streamInfo == null) { diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index a17274ba..90e86ff2 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -7,6 +7,7 @@ import 'package:frosty/screens/channel/chat/details/chat_users_list.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/video/video_bar.dart'; import 'package:frosty/screens/channel/video/video_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/uptime.dart'; import 'package:intl/intl.dart'; @@ -15,11 +16,13 @@ import 'package:intl/intl.dart'; class VideoOverlay extends StatelessWidget { final VideoStore videoStore; final ChatStore chatStore; + final SettingsStore settingsStore; const VideoOverlay({ super.key, required this.videoStore, required this.chatStore, + required this.settingsStore, }); @override @@ -92,6 +95,33 @@ class VideoOverlay extends StatelessWidget { }, ); + final latencyTooltip = Tooltip( + message: 'Latency to broadcaster', + preferBelow: false, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + Observer( + builder: (context) => Text( + videoStore.latency ?? '', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox( + width: 8, + ), + const Icon( + Icons.speed, + ), + ], + ), + ), + ); + final refreshButton = Tooltip( message: 'Refresh', preferBelow: false, @@ -223,7 +253,7 @@ class VideoOverlay extends StatelessWidget { children: [ Expanded( child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(14), child: Row( children: [ Tooltip( @@ -294,6 +324,9 @@ class VideoOverlay extends StatelessWidget { ), ), ), + if (orientation == Orientation.landscape && + settingsStore.showLatency) + latencyTooltip, Tooltip( message: 'Enter picture-in-picture', preferBelow: false, diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index eece60e5..c91fe19a 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -42,6 +42,12 @@ abstract class VideoStoreBase with Store { WebViewController.fromPlatformCreationParams(_videoWebViewParams) ..setBackgroundColor(Colors.black) ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'Latency', + onMessageReceived: (message) { + _latency = message.message; + }, + ) ..addJavaScriptChannel( 'StreamQualities', onMessageReceived: (message) { @@ -124,6 +130,9 @@ abstract class VideoStoreBase with Store { @readonly String _streamQuality = 'Auto'; + @readonly + String? _latency; + /// The video URL to use for the webview. String get videoUrl => 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; @@ -199,15 +208,61 @@ abstract class VideoStoreBase with Store { final indexOfStreamQuality = _availableStreamQualities.indexOf(newStreamQuality); await videoWebViewController.runJavaScript(''' - document.querySelector('[data-a-target="player-settings-button"]').click(); - document.querySelector('[data-a-target="player-settings-menu-item-quality"]').click(); - [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')][$indexOfStreamQuality].click(); - document.querySelector('.tw-drop-down-menu-item-figure').click(); - document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click(); + { + document.querySelector('[data-a-target="player-settings-button"]').click(); + document.querySelector('[data-a-target="player-settings-menu-item-quality"]').click(); + [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] input')][$indexOfStreamQuality].click(); + document.querySelector('.tw-drop-down-menu-item-figure').click(); + document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click(); + } '''); _streamQuality = newStreamQuality; } + void _hideDefaultOverlay() { + videoWebViewController.runJavaScript(''' + { + const hideElements = (...el) => { + el.forEach((el) => { + el?.style.setProperty("display", "none", "important"); + }) + } + const hide = () => { + const topBar = document.querySelector(".top-bar"); + const playerControls = document.querySelector(".player-controls"); + const channelDisclosures = document.querySelector("#channel-player-disclosures"); + hideElements(topBar, playerControls, channelDisclosures); + } + const observer = new MutationObserver(() => { + const videoOverlay = document.querySelector('.video-player__overlay'); + if(!videoOverlay) return; + hide(); + const videoOverlayObserver = new MutationObserver(hide); + videoOverlayObserver.observe(videoOverlay, { childList: true, subtree: true }); + observer.disconnect(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + '''); + } + + void _listenOnLatencyChanges() { + videoWebViewController.runJavaScript(''' + { + document.querySelector('[data-a-target="player-settings-button"]').click(); + document.querySelector('[data-a-target="player-settings-menu-item-advanced"]').click(); + document.querySelector('[data-a-target="player-settings-submenu-advanced-video-stats"] input').click(); + document.querySelector('.tw-drop-down-menu-item-figure').click(); + document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click(); + document.querySelector('[data-a-target="player-overlay-video-stats"]').style.display = "none"; + const observer = new MutationObserver((changes) => { + Latency.postMessage(changes[0].target.textContent); + }) + observer.observe(document.querySelector('[aria-label="Latency To Broadcaster"]'), { characterData: true, attributes: false, childList: false, subtree: true }); + } + '''); + } + /// Initializes the video webview. @action Future initVideo() async { @@ -227,19 +282,8 @@ abstract class VideoStoreBase with Store { });''', ); if (settingsStore.showOverlay) { - videoWebViewController.runJavaScript(''' - { - const observer = new MutationObserver(() => { - const classificationGate = document.querySelector('[data-a-target="content-classification-gate-overlay"]'); - if(classificationGate) return; - const overlay = document.querySelector('.video-player__overlay'); - if(!overlay) return; - overlay.style.display = "none"; - observer.disconnect(); - }); - observer.observe(document.body, { childList: true, subtree: true }); - } - '''); + _hideDefaultOverlay(); + _listenOnLatencyChanges(); } } catch (e) { debugPrint(e.toString()); diff --git a/lib/screens/channel/video/video_store.g.dart b/lib/screens/channel/video/video_store.g.dart index 6edc5ae2..1fd717ba 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -118,6 +118,24 @@ mixin _$VideoStore on VideoStoreBase, Store { }); } + late final _$_latencyAtom = + Atom(name: 'VideoStoreBase._latency', context: context); + + String? get latency { + _$_latencyAtom.reportRead(); + return super._latency; + } + + @override + String? get _latency => latency; + + @override + set _latency(String? value) { + _$_latencyAtom.reportWrite(value, super._latency, () { + super._latency = value; + }); + } + late final _$updateStreamQualitiesAsyncAction = AsyncAction('VideoStoreBase.updateStreamQualities', context: context); diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 593fd876..1753e219 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -57,6 +57,7 @@ abstract class _SettingsStoreBase with Store { // Player defaults static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; + static const defaultShowLatency = true; // Overlay defaults static const defaultShowOverlay = true; @@ -72,6 +73,10 @@ abstract class _SettingsStoreBase with Store { @observable var defaultToHighestQuality = defaultDefaultToHighestQuality; + @JsonKey(defaultValue: defaultShowLatency) + @observable + var showLatency = defaultShowLatency; + // Overlay options @JsonKey(defaultValue: defaultShowOverlay) @observable @@ -89,6 +94,7 @@ abstract class _SettingsStoreBase with Store { void resetVideoSettings() { showVideo = defaultShowVideo; defaultToHighestQuality = defaultDefaultToHighestQuality; + showLatency = defaultShowLatency; showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index f9b5cc93..c55b182c 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -17,6 +17,7 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false + ..showLatency = json['showLatency'] as bool? ?? true ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5 @@ -65,6 +66,7 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'launchUrlExternal': instance.launchUrlExternal, 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, + 'showLatency': instance.showLatency, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'overlayOpacity': instance.overlayOpacity, @@ -220,6 +222,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$showLatencyAtom = + Atom(name: '_SettingsStoreBase.showLatency', context: context); + + @override + bool get showLatency { + _$showLatencyAtom.reportRead(); + return super.showLatency; + } + + @override + set showLatency(bool value) { + _$showLatencyAtom.reportWrite(value, super.showLatency, () { + super.showLatency = value; + }); + } + late final _$showOverlayAtom = Atom(name: '_SettingsStoreBase.showOverlay', context: context); @@ -757,6 +775,7 @@ largeStreamCard: ${largeStreamCard}, launchUrlExternal: ${launchUrlExternal}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, +showLatency: ${showLatency}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, overlayOpacity: ${overlayOpacity}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 2242a632..c5dd1489 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -30,6 +30,11 @@ class VideoSettings extends StatelessWidget { onChanged: (newValue) => settingsStore.defaultToHighestQuality = newValue, ), + SettingsListSwitch( + title: 'Show latency', + value: settingsStore.showLatency, + onChanged: (newValue) => settingsStore.showLatency = newValue, + ), const SectionHeader('Overlay', showDivider: true), SettingsListSwitch( title: 'Use custom video overlay',