diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 258d905a..69fcc3e4 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -46,6 +46,37 @@ class VideoOverlay extends StatelessWidget { ), ); + final videoSettingsButton = IconButton( + onPressed: () { + videoStore.updateStreamQualities(); + showModalBottomSheet( + context: context, + builder: (context) => Observer( + builder: (context) => ListView.builder( + itemCount: videoStore.availableStreamQualities.length, + itemBuilder: (context, index) { + final streamQuality = + videoStore.availableStreamQualities[index]; + return ListTile( + leading: videoStore.streamQuality == streamQuality + ? const Icon(Icons.circle, size: 10) + : const SizedBox( + width: 10, + ), + title: Text(streamQuality), + onTap: () { + videoStore.setStreamQuality(streamQuality); + Navigator.pop(context); + }, + ); + }, + ), + ), + ); + }, + icon: const Icon(Icons.settings), + ); + final refreshButton = Tooltip( message: 'Refresh', preferBelow: false, @@ -135,6 +166,7 @@ class VideoOverlay extends StatelessWidget { return Stack( children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ backButton, @@ -150,6 +182,7 @@ class VideoOverlay extends StatelessWidget { if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, + videoSettingsButton, ], ), Center( diff --git a/lib/screens/channel/video/video_store.dart b/lib/screens/channel/video/video_store.dart index 7c255569..cbfdef55 100644 --- a/lib/screens/channel/video/video_store.dart +++ b/lib/screens/channel/video/video_store.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; @@ -39,6 +40,14 @@ abstract class VideoStoreBase with Store { WebViewController.fromPlatformCreationParams(_videoWebViewParams) ..setBackgroundColor(Colors.black) ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'StreamQualities', + onMessageReceived: (message) { + final data = jsonDecode(message.message) as List; + _availableStreamQualities = + data.map((item) => item as String).toList(); + }, + ) ..addJavaScriptChannel( 'VideoPause', onMessageReceived: (message) { @@ -89,11 +98,16 @@ abstract class VideoStoreBase with Store { @readonly StreamTwitch? _streamInfo; - /// The video URL to use for the webview. Controls will be disabled when custom overlay is enabled. - @computed - String get videoUrl => settingsStore.showOverlay - ? 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty' - : 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; + @readonly + List _availableStreamQualities = []; + + // The current stream quality string + @readonly + String _streamQuality = 'Auto'; + + /// The video URL to use for the webview. + String get videoUrl => + 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; VideoStoreBase({ required this.userLogin, @@ -137,6 +151,38 @@ abstract class VideoStoreBase with Store { updateStreamInfo(); } + @action + Future updateStreamQualities() async { + try { + await videoWebViewController.runJavaScript(''' + { + document.querySelector('[data-a-target="player-settings-button"]').click(); + document.querySelector('[data-a-target="player-settings-menu-item-quality"]').click(); + const qualities = [...document.querySelectorAll('[data-a-target="player-settings-submenu-quality-option"] label div')].map((el) => el.textContent); + document.querySelector('.tw-drop-down-menu-item-figure').click(); + document.querySelector('[data-a-target="player-settings-menu"] [role="menuitem"] button').click(); + StreamQualities.postMessage(JSON.stringify(qualities)); + } + '''); + } catch (e) { + debugPrint(e.toString()); + } + } + + @action + Future setStreamQuality(String newStreamQuality) async { + 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(); + '''); + _streamQuality = newStreamQuality; + } + /// Initializes the video webview. @action Future initVideo() async { @@ -155,6 +201,21 @@ abstract class VideoStoreBase with Store { document.getElementsByTagName("video")[0].textTracks[0].mode = "hidden"; });''', ); + 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 }); + } + '''); + } } 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 549f0cfc..6edc5ae2 100644 --- a/lib/screens/channel/video/video_store.g.dart +++ b/lib/screens/channel/video/video_store.g.dart @@ -9,14 +9,6 @@ part of 'video_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$VideoStore on VideoStoreBase, Store { - Computed? _$videoUrlComputed; - - @override - String get videoUrl => - (_$videoUrlComputed ??= Computed(() => super.videoUrl, - name: 'VideoStoreBase.videoUrl')) - .value; - late final _$_pausedAtom = Atom(name: 'VideoStoreBase._paused', context: context); @@ -89,6 +81,61 @@ mixin _$VideoStore on VideoStoreBase, Store { }); } + late final _$_availableStreamQualitiesAtom = + Atom(name: 'VideoStoreBase._availableStreamQualities', context: context); + + List get availableStreamQualities { + _$_availableStreamQualitiesAtom.reportRead(); + return super._availableStreamQualities; + } + + @override + List get _availableStreamQualities => availableStreamQualities; + + @override + set _availableStreamQualities(List value) { + _$_availableStreamQualitiesAtom + .reportWrite(value, super._availableStreamQualities, () { + super._availableStreamQualities = value; + }); + } + + late final _$_streamQualityAtom = + Atom(name: 'VideoStoreBase._streamQuality', context: context); + + String get streamQuality { + _$_streamQualityAtom.reportRead(); + return super._streamQuality; + } + + @override + String get _streamQuality => streamQuality; + + @override + set _streamQuality(String value) { + _$_streamQualityAtom.reportWrite(value, super._streamQuality, () { + super._streamQuality = value; + }); + } + + late final _$updateStreamQualitiesAsyncAction = + AsyncAction('VideoStoreBase.updateStreamQualities', context: context); + + @override + Future updateStreamQualities() { + return _$updateStreamQualitiesAsyncAction + .run(() => super.updateStreamQualities()); + } + + late final _$setStreamQualityAsyncAction = + AsyncAction('VideoStoreBase.setStreamQuality', context: context); + + @override + Future setStreamQuality(String newStreamQuality) { + return _$setStreamQualityAsyncAction + .run(() => super.setStreamQuality(newStreamQuality)); + } + late final _$initVideoAsyncAction = AsyncAction('VideoStoreBase.initVideo', context: context); @@ -155,7 +202,7 @@ mixin _$VideoStore on VideoStoreBase, Store { @override String toString() { return ''' -videoUrl: ${videoUrl} + '''; } }