From 1f604af6576519a95cc0682f964f58f2396f794d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 9 Jan 2022 19:51:38 -0500 Subject: [PATCH] Fix pause/play on video overlay & add toggle --- lib/screens/channel/stores/video_store.dart | 24 +- lib/screens/channel/stores/video_store.g.dart | 34 +- lib/screens/channel/video/video.dart | 106 +++++-- lib/screens/channel/video/video_overlay.dart | 293 +++++++++--------- .../settings/stores/settings_store.dart | 4 + .../settings/stores/settings_store.g.dart | 19 ++ lib/screens/settings/video_settings.dart | 9 +- 7 files changed, 278 insertions(+), 211 deletions(-) diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index 37acc3b1..08580221 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -19,10 +19,10 @@ abstract class _VideoStoreBase with Store { late Timer updateTimer; @readonly - var _menuVisible = true; + var _overlayVisible = true; - @readonly - var _paused = false; + @observable + var paused = true; @readonly StreamTwitch? _streamInfo; @@ -36,32 +36,32 @@ abstract class _VideoStoreBase with Store { required this.authStore, required this.settingsStore, }) { - overlayTimer = Timer(const Duration(seconds: 3), () => _menuVisible = false); + overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false); updateStreamInfo(); } @action void handlePausePlay() { - if (_paused) { + if (paused) { controller?.runJavascript('document.getElementsByTagName("video")[0].play();'); } else { controller?.runJavascript('document.getElementsByTagName("video")[0].pause();'); } - _paused = !_paused; + paused = !paused; } @action void handleVideoTap() { - if (_menuVisible) { + if (_overlayVisible) { overlayTimer.cancel(); - _menuVisible = false; + _overlayVisible = false; } else { overlayTimer.cancel(); - overlayTimer = Timer(const Duration(seconds: 5), () => _menuVisible = false); + overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); - _menuVisible = true; + _overlayVisible = true; updateStreamInfo(); } @@ -79,7 +79,7 @@ abstract class _VideoStoreBase with Store { void handleExpand() { overlayTimer.cancel(); settingsStore.expandInfo = !settingsStore.expandInfo; - overlayTimer = Timer(const Duration(seconds: 5), () => _menuVisible = false); + overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); } void handleRefresh() async { @@ -90,6 +90,8 @@ abstract class _VideoStoreBase with Store { void initVideo() { try { controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;'); + controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => Pause.postMessage("video paused"));'); + controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("play", () => Play.postMessage("video playing"));'); } catch (e) { debugPrint(e.toString()); } diff --git a/lib/screens/channel/stores/video_store.g.dart b/lib/screens/channel/stores/video_store.g.dart index a592ff26..d7004c3d 100644 --- a/lib/screens/channel/stores/video_store.g.dart +++ b/lib/screens/channel/stores/video_store.g.dart @@ -9,37 +9,35 @@ 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 mixin _$VideoStore on _VideoStoreBase, Store { - final _$_menuVisibleAtom = Atom(name: '_VideoStoreBase._menuVisible'); + final _$_overlayVisibleAtom = Atom(name: '_VideoStoreBase._overlayVisible'); - bool get menuVisible { - _$_menuVisibleAtom.reportRead(); - return super._menuVisible; + bool get overlayVisible { + _$_overlayVisibleAtom.reportRead(); + return super._overlayVisible; } @override - bool get _menuVisible => menuVisible; + bool get _overlayVisible => overlayVisible; @override - set _menuVisible(bool value) { - _$_menuVisibleAtom.reportWrite(value, super._menuVisible, () { - super._menuVisible = value; + set _overlayVisible(bool value) { + _$_overlayVisibleAtom.reportWrite(value, super._overlayVisible, () { + super._overlayVisible = value; }); } - final _$_pausedAtom = Atom(name: '_VideoStoreBase._paused'); + final _$pausedAtom = Atom(name: '_VideoStoreBase.paused'); + @override bool get paused { - _$_pausedAtom.reportRead(); - return super._paused; + _$pausedAtom.reportRead(); + return super.paused; } @override - bool get _paused => paused; - - @override - set _paused(bool value) { - _$_pausedAtom.reportWrite(value, super._paused, () { - super._paused = value; + set paused(bool value) { + _$pausedAtom.reportWrite(value, super.paused, () { + super.paused = value; }); } @@ -107,7 +105,7 @@ mixin _$VideoStore on _VideoStoreBase, Store { @override String toString() { return ''' - +paused: ${paused} '''; } } diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart index f36b37fb..354f287b 100644 --- a/lib/screens/channel/video/video.dart +++ b/lib/screens/channel/video/video.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/stores/video_store.dart'; @@ -16,32 +17,87 @@ class Video extends StatelessWidget { @override Widget build(BuildContext context) { - return AspectRatio( - aspectRatio: 16 / 9, - child: Observer( - builder: (context) { - if (videoStore.settingsStore.showOverlay) { - return Stack( - children: [ - WebView( - initialUrl: 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty', - javascriptMode: JavascriptMode.unrestricted, - allowsInlineMediaPlayback: true, - initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, - onWebViewCreated: (controller) => videoStore.controller = controller, - onPageFinished: (string) => videoStore.initVideo(), - ), - VideoOverlay(videoStore: videoStore), - ], + return GestureDetector( + onLongPress: videoStore.settingsStore.toggleableOverlay ? () => videoStore.settingsStore.showOverlay = !videoStore.settingsStore.showOverlay : null, + child: AspectRatio( + aspectRatio: 16 / 9, + child: Observer( + builder: (context) { + if (videoStore.settingsStore.showOverlay) { + return Stack( + children: [ + WebView( + initialUrl: 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty', + javascriptMode: JavascriptMode.unrestricted, + allowsInlineMediaPlayback: true, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + onWebViewCreated: (controller) => videoStore.controller = controller, + onPageFinished: (string) => videoStore.initVideo(), + navigationDelegate: (navigation) { + if (navigation.url.startsWith('https://player.twitch.tv')) { + return NavigationDecision.navigate; + } + return NavigationDecision.prevent; + }, + javascriptChannels: { + JavascriptChannel( + name: 'Pause', + onMessageReceived: (message) { + videoStore.paused = true; + }, + ), + JavascriptChannel( + name: 'Play', + onMessageReceived: (message) { + videoStore.paused = false; + }, + ), + }, + ), + Observer( + builder: (context) { + if (videoStore.paused) { + return VideoOverlay(videoStore: videoStore); + } + return GestureDetector( + onTap: videoStore.handleVideoTap, + child: SizedBox.expand( + child: Observer( + builder: (context) { + return AnimatedOpacity( + opacity: videoStore.overlayVisible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: ColoredBox( + color: const Color.fromRGBO(0, 0, 0, 0.5), + child: IgnorePointer( + ignoring: !videoStore.overlayVisible, + child: VideoOverlay(videoStore: videoStore), + ), + ), + ); + }, + ), + ), + ); + }, + ) + ], + ); + } + return WebView( + initialUrl: 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty', + javascriptMode: JavascriptMode.unrestricted, + allowsInlineMediaPlayback: true, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + navigationDelegate: (navigation) { + if (navigation.url.startsWith('https://player.twitch.tv')) { + return NavigationDecision.navigate; + } + return NavigationDecision.prevent; + }, ); - } - return WebView( - initialUrl: 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty', - javascriptMode: JavascriptMode.unrestricted, - allowsInlineMediaPlayback: true, - initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, - ); - }, + }, + ), ), ); } diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 0361b87c..3e6a4085 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -15,163 +15,146 @@ class VideoOverlay extends StatelessWidget { Widget build(BuildContext context) { final portrait = MediaQuery.of(context).orientation == Orientation.portrait; - return GestureDetector( - onTap: videoStore.handleVideoTap, - child: SizedBox.expand( - child: Observer( - builder: (context) { - return AnimatedOpacity( - opacity: videoStore.menuVisible ? 1.0 : 0.0, - duration: const Duration(milliseconds: 100), - child: ColoredBox( - color: const Color.fromRGBO(0, 0, 0, 0.5), - child: IgnorePointer( - ignoring: !videoStore.menuVisible, - child: Stack( - children: [ - Column( - children: [ - Row( - children: [ - if (!videoStore.settingsStore.fullScreen) - IconButton( - tooltip: 'Back', - icon: Icon( - Icons.adaptive.arrow_back, - color: const Color(0xFFFFFFFF), - ), - onPressed: Navigator.of(context).pop, - ), - const Spacer(), - IconButton( - tooltip: 'Settings', - icon: const Icon( - Icons.settings, - color: Color(0xFFFFFFFF), - ), - onPressed: () => showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) { - return SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: Settings(settingsStore: videoStore.settingsStore), - ); - }, - ), - ), - ], - ), - const Spacer(), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: GestureDetector( - onTap: videoStore.handleExpand, - child: Padding( - padding: const EdgeInsets.all(10.0), - child: videoStore.streamInfo != null - ? videoStore.settingsStore.expandInfo - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - videoStore.streamInfo!.userName, - style: const TextStyle( - color: Color(0xFFFFFFFF), - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 5.0), - Tooltip( - message: videoStore.streamInfo!.title, - preferBelow: false, - padding: const EdgeInsets.all(10.0), - child: Text( - videoStore.streamInfo!.title, - maxLines: portrait ? 1 : 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFFFFFFFF), - ), - ), - ), - const SizedBox(height: 5.0), - Text( - '${videoStore.streamInfo?.gameName} for ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers', - style: const TextStyle( - color: Color(0xFFFFFFFF), - ), - ), - ], - ) - : Text( - videoStore.streamInfo!.userName, - style: const TextStyle( - color: Color(0xFFFFFFFF), - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ) - : null, - ), - ), - ), - IconButton( - tooltip: 'Refresh', - icon: const Icon( - Icons.refresh, - color: Color(0xFFFFFFFF), - ), - onPressed: videoStore.handleRefresh, - ), - if (Platform.isIOS && videoStore.settingsStore.pictureInPicture) - IconButton( - tooltip: 'Picture-in-Picture', - icon: const Icon( - Icons.picture_in_picture_alt_rounded, - color: Color(0xFFFFFFFF), - ), - onPressed: videoStore.requestPictureInPicture, - ), - if (MediaQuery.of(context).orientation == Orientation.landscape) - IconButton( - tooltip: 'Fullscreen', - icon: const Icon( - Icons.fullscreen, - color: Color(0xFFFFFFFF), - ), - onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, - ) - ], - ) - ], - ), - Center( - child: IconButton( - tooltip: videoStore.paused ? 'Play' : 'Pause', - iconSize: 50.0, - icon: videoStore.paused - ? const Icon( - Icons.play_arrow, - color: Color(0xFFFFFFFF), - ) - : const Icon( - Icons.pause, - color: Color(0xFFFFFFFF), - ), - onPressed: videoStore.handlePausePlay, - ), - ), - ], - ), + return Observer( + builder: (context) => Stack( + children: [ + if (!videoStore.settingsStore.fullScreen) + Align( + alignment: Alignment.topLeft, + child: IconButton( + tooltip: 'Back', + icon: Icon( + Icons.adaptive.arrow_back, + color: const Color(0xFFFFFFFF), ), + onPressed: Navigator.of(context).pop, ), - ); - }, - ), + ), + Align( + alignment: Alignment.topRight, + child: IconButton( + tooltip: 'Settings', + icon: const Icon( + Icons.settings, + color: Color(0xFFFFFFFF), + ), + onPressed: () => showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Settings(settingsStore: videoStore.settingsStore), + ); + }, + ), + ), + ), + Center( + child: IconButton( + tooltip: videoStore.paused ? 'Play' : 'Pause', + iconSize: 50.0, + icon: videoStore.paused + ? const Icon( + Icons.play_arrow, + color: Color(0xFFFFFFFF), + ) + : const Icon( + Icons.pause, + color: Color(0xFFFFFFFF), + ), + onPressed: videoStore.handlePausePlay, + ), + ), + Align( + alignment: Alignment.bottomLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: videoStore.streamInfo != null + ? GestureDetector( + onTap: videoStore.handleExpand, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: videoStore.settingsStore.expandInfo + ? Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + videoStore.streamInfo!.userName, + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 5.0), + Tooltip( + message: videoStore.streamInfo!.title, + preferBelow: false, + padding: const EdgeInsets.all(10.0), + child: Text( + videoStore.streamInfo!.title, + maxLines: portrait ? 1 : 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(height: 5.0), + Text( + '${videoStore.streamInfo?.gameName} for ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers', + style: const TextStyle( + color: Color(0xFFFFFFFF), + ), + ), + ], + ) + : Text( + videoStore.streamInfo!.userName, + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ) + : const SizedBox(), + ), + IconButton( + tooltip: 'Refresh', + icon: const Icon( + Icons.refresh, + color: Color(0xFFFFFFFF), + ), + onPressed: videoStore.handleRefresh, + ), + if (Platform.isIOS && videoStore.settingsStore.pictureInPicture) + IconButton( + tooltip: 'Picture-in-Picture', + icon: const Icon( + Icons.picture_in_picture_alt_rounded, + color: Color(0xFFFFFFFF), + ), + onPressed: videoStore.requestPictureInPicture, + ), + if (MediaQuery.of(context).orientation == Orientation.landscape) + IconButton( + tooltip: 'Fullscreen', + icon: const Icon( + Icons.fullscreen, + color: Color(0xFFFFFFFF), + ), + onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, + ) + ], + ), + ) + ], ), ); } diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 6d11d330..c69990fb 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -31,6 +31,10 @@ abstract class _SettingsStoreBase with Store { @observable var showOverlay = true; + @JsonKey(defaultValue: false) + @observable + var toggleableOverlay = false; + @JsonKey(defaultValue: false) @observable var pictureInPicture = false; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 14ba423f..c55dafa1 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -12,6 +12,7 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ..showThumbnailUptime = json['showThumbnailUptime'] as bool? ?? false ..showVideo = json['showVideo'] as bool? ?? true ..showOverlay = json['showOverlay'] as bool? ?? true + ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..pictureInPicture = json['pictureInPicture'] as bool? ?? false ..showDeletedMessages = json['showDeletedMessages'] as bool? ?? false ..showZeroWidth = json['showZeroWidth'] as bool? ?? false @@ -30,6 +31,7 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'showThumbnailUptime': instance.showThumbnailUptime, 'showVideo': instance.showVideo, 'showOverlay': instance.showOverlay, + 'toggleableOverlay': instance.toggleableOverlay, 'pictureInPicture': instance.pictureInPicture, 'showDeletedMessages': instance.showDeletedMessages, 'showZeroWidth': instance.showZeroWidth, @@ -110,6 +112,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + final _$toggleableOverlayAtom = + Atom(name: '_SettingsStoreBase.toggleableOverlay'); + + @override + bool get toggleableOverlay { + _$toggleableOverlayAtom.reportRead(); + return super.toggleableOverlay; + } + + @override + set toggleableOverlay(bool value) { + _$toggleableOverlayAtom.reportWrite(value, super.toggleableOverlay, () { + super.toggleableOverlay = value; + }); + } + final _$pictureInPictureAtom = Atom(name: '_SettingsStoreBase.pictureInPicture'); @@ -272,6 +290,7 @@ useOledTheme: ${useOledTheme}, showThumbnailUptime: ${showThumbnailUptime}, showVideo: ${showVideo}, showOverlay: ${showOverlay}, +toggleableOverlay: ${toggleableOverlay}, pictureInPicture: ${pictureInPicture}, showDeletedMessages: ${showDeletedMessages}, showZeroWidth: ${showZeroWidth}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 90a859b0..480905c7 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -25,11 +25,16 @@ class VideoSettings extends StatelessWidget { ), SwitchListTile.adaptive( isThreeLine: true, - title: const Text('Use custom video overlay'), - subtitle: const Text('Replaces the default video overlay with a mobile-friendly version.'), + title: const Text('Use custom stream overlay'), + subtitle: const Text('Replaces the default stream overlay with a mobile-friendly version.'), value: settingsStore.showOverlay, onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null, ), + SwitchListTile.adaptive( + title: const Text('Long-press player to toggle overlay'), + value: settingsStore.toggleableOverlay, + onChanged: settingsStore.showOverlay ? (newValue) => settingsStore.toggleableOverlay = newValue : null, + ), if (Platform.isIOS) SwitchListTile.adaptive( title: const Text('Picture-in-picture button'),