From 07c260def527a54b4bb85e04a253ee42de5eeded Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 3 Jun 2022 17:19:40 -0400 Subject: [PATCH] Move video and overlay to the same file (#130) --- lib/screens/channel/video/video.dart | 27 -- lib/screens/channel/video/video_overlay.dart | 321 ----------------- lib/screens/channel/video_chat.dart | 346 ++++++++++++++++++- 3 files changed, 344 insertions(+), 350 deletions(-) delete mode 100644 lib/screens/channel/video/video.dart delete mode 100644 lib/screens/channel/video/video_overlay.dart diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart deleted file mode 100644 index c52cdba5..00000000 --- a/lib/screens/channel/video/video.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:frosty/screens/channel/stores/video_store.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -class Video extends StatelessWidget { - final VideoStore videoStore; - - const Video({ - Key? key, - required this.videoStore, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return WebView( - backgroundColor: Colors.black, - initialUrl: videoStore.videoUrl, - javascriptMode: JavascriptMode.unrestricted, - allowsInlineMediaPlayback: true, - initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, - onWebViewCreated: (controller) => videoStore.controller = controller, - onPageFinished: (string) => videoStore.initVideo(), - navigationDelegate: videoStore.handleNavigation, - javascriptChannels: videoStore.javascriptChannels, - ); - } -} diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart deleted file mode 100644 index d8437453..00000000 --- a/lib/screens/channel/video/video_overlay.dart +++ /dev/null @@ -1,321 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/constants/constants.dart'; -import 'package:frosty/main.dart'; -import 'package:frosty/screens/channel/stores/video_store.dart'; -import 'package:frosty/screens/settings/settings.dart'; -import 'package:frosty/widgets/profile_picture.dart'; -import 'package:intl/intl.dart'; - -class VideoOverlay extends StatelessWidget { - final VideoStore videoStore; - - const VideoOverlay({Key? key, required this.videoStore}) : super(key: key); - - Future _showSleepTimerDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Sleep Timer'), - content: Observer( - builder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - DropdownButton( - value: videoStore.sleepHours, - items: List.generate(24, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(), - onChanged: (int? hours) => videoStore.sleepHours = hours!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Hours'), - ], - ), - Row( - children: [ - DropdownButton( - value: videoStore.sleepMinutes, - items: List.generate(60, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(), - onChanged: (int? minutes) => videoStore.sleepMinutes = minutes!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Minutes'), - ], - ), - if (videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive) - Row( - children: [ - const Icon(Icons.timer), - Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'), - const Spacer(), - IconButton( - tooltip: 'Cancel Timer', - onPressed: videoStore.cancelSleepTimer, - icon: const Icon(Icons.cancel), - ), - ], - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom(primary: Colors.red), - child: const Text('Dismiss'), - ), - Observer( - builder: (context) => ElevatedButton( - onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0 - ? null - : () => videoStore.updateSleepTimer( - onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst), - ), - child: const Text('Set Timer'), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final orientation = MediaQuery.of(context).orientation; - - final backButton = Align( - alignment: Alignment.topLeft, - child: IconButton( - tooltip: 'Back', - icon: Icon( - Icons.adaptive.arrow_back, - color: Colors.white, - ), - onPressed: Navigator.of(context).pop, - ), - ); - - final settingsButton = IconButton( - tooltip: 'Settings', - icon: const Icon( - Icons.settings, - color: Colors.white, - ), - onPressed: () => showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: Settings(settingsStore: videoStore.settingsStore), - ), - ), - ); - - final chatOverlayButton = IconButton( - tooltip: 'Toggle Chat Overlay', - onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, - icon: const Icon(Icons.chat_bubble_outline), - color: Colors.white, - ); - - final refreshButton = IconButton( - tooltip: 'Refresh', - icon: const Icon( - Icons.refresh, - color: Colors.white, - ), - onPressed: videoStore.handleRefresh, - ); - - final fullScreenButton = IconButton( - tooltip: videoStore.settingsStore.fullScreen ? 'Exit Fullscreen' : 'Enter Fullscreen', - icon: videoStore.settingsStore.fullScreen - ? const Icon( - Icons.fullscreen_exit, - color: Colors.white, - ) - : const Icon( - Icons.fullscreen, - color: Colors.white, - ), - onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, - ); - - final sleepTimerButton = IconButton( - tooltip: 'Sleep Timer', - icon: const Icon( - Icons.timer, - color: Colors.white, - ), - onPressed: () => _showSleepTimerDialog(context), - ); - - final streamInfo = videoStore.streamInfo; - if (streamInfo == null) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - backButton, - const Spacer(), - settingsButton, - ], - ), - Align( - alignment: Alignment.bottomRight, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, - refreshButton, - if (orientation == Orientation.landscape) fullScreenButton, - ], - ), - ), - ], - ); - } - - final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})'; - final streamer = Row( - children: [ - ProfilePicture( - userLogin: streamInfo.userLogin, - radius: 10.0, - ), - const SizedBox(width: 5.0), - Flexible( - child: Tooltip( - message: streamerName, - preferBelow: false, - child: Text( - streamerName, - style: const TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ); - - return Observer( - builder: (context) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - backButton, - const Spacer(), - sleepTimerButton, - settingsButton, - ], - ), - - // Add a play button when paused for Android - // When an ad is paused on Android there is no way to unpause, so a play button is necessary. - if (Platform.isAndroid) - Center( - child: IconButton( - tooltip: videoStore.paused ? 'Play' : 'Pause', - iconSize: 50.0, - icon: videoStore.paused - ? const Icon( - Icons.play_arrow, - color: Colors.white, - ) - : const Icon( - Icons.pause, - color: Colors.white, - ), - onPressed: videoStore.handlePausePlay, - ), - ) - else if (!videoStore.paused) - Center( - child: IconButton( - tooltip: 'Pause', - iconSize: 50.0, - icon: const Icon( - Icons.pause, - color: Colors.white, - ), - onPressed: videoStore.handlePausePlay, - ), - ), - Align( - alignment: Alignment.bottomLeft, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: GestureDetector( - onTap: videoStore.handleExpand, - child: Padding( - padding: const EdgeInsets.all(10.0), - child: videoStore.settingsStore.expandInfo - ? Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - streamer, - const SizedBox(height: 5.0), - Tooltip( - message: videoStore.streamInfo!.title.trim(), - preferBelow: false, - padding: const EdgeInsets.all(10.0), - child: Text( - videoStore.streamInfo!.title.trim(), - maxLines: orientation == Orientation.portrait ? 1 : 5, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(height: 5.0), - Text( - '${videoStore.streamInfo?.gameName} \u2022 ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers', - style: const TextStyle( - color: Colors.white, - ), - ), - ], - ) - : streamer, - ), - ), - ), - if (Platform.isIOS && videoStore.settingsStore.pictureInPicture) - IconButton( - tooltip: 'Picture-in-Picture', - icon: const Icon( - Icons.picture_in_picture_alt_rounded, - color: Colors.white, - ), - onPressed: videoStore.requestPictureInPicture, - ), - if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, - refreshButton, - if (orientation == Orientation.landscape) fullScreenButton - ], - ), - ) - ], - ); - }, - ); - } -} diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index 2bc08259..a40ee726 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -7,17 +10,20 @@ import 'package:frosty/api/seventv_api.dart'; import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/constants/constants.dart'; import 'package:frosty/core/auth/auth_store.dart'; +import 'package:frosty/main.dart'; import 'package:frosty/screens/channel/chat/chat.dart'; import 'package:frosty/screens/channel/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/stores/chat_details_store.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; import 'package:frosty/screens/channel/stores/video_store.dart'; -import 'package:frosty/screens/channel/video/video.dart'; -import 'package:frosty/screens/channel/video/video_overlay.dart'; import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:frosty/widgets/profile_picture.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +/// Creates a widget that shows the video stream (if live) and chat of the given user. class VideoChat extends StatefulWidget { final String userId; final String userName; @@ -250,3 +256,339 @@ class _VideoChatState extends State { super.dispose(); } } + +/// Creates a [WebView] widget that shows a channel's video stream. +class Video extends StatelessWidget { + final VideoStore videoStore; + + const Video({ + Key? key, + required this.videoStore, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return WebView( + backgroundColor: Colors.black, + initialUrl: videoStore.videoUrl, + javascriptMode: JavascriptMode.unrestricted, + allowsInlineMediaPlayback: true, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + onWebViewCreated: (controller) => videoStore.controller = controller, + onPageFinished: (string) => videoStore.initVideo(), + navigationDelegate: videoStore.handleNavigation, + javascriptChannels: videoStore.javascriptChannels, + ); + } +} + +/// Creates a widget containing controls which enable interactions with an underlying [Video] widget. +class VideoOverlay extends StatelessWidget { + final VideoStore videoStore; + + const VideoOverlay({Key? key, required this.videoStore}) : super(key: key); + + Future _showSleepTimerDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sleep Timer'), + content: Observer( + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + DropdownButton( + value: videoStore.sleepHours, + items: List.generate(24, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(), + onChanged: (int? hours) => videoStore.sleepHours = hours!, + menuMaxHeight: 200, + ), + const SizedBox(width: 10.0), + const Text('Hours'), + ], + ), + Row( + children: [ + DropdownButton( + value: videoStore.sleepMinutes, + items: List.generate(60, (index) => index).map((e) => DropdownMenuItem(value: e, child: Text(e.toString()))).toList(), + onChanged: (int? minutes) => videoStore.sleepMinutes = minutes!, + menuMaxHeight: 200, + ), + const SizedBox(width: 10.0), + const Text('Minutes'), + ], + ), + if (videoStore.sleepTimer != null && videoStore.sleepTimer!.isActive) + Row( + children: [ + const Icon(Icons.timer), + Text(' ${videoStore.timeRemaining.toString().split('.')[0]}'), + const Spacer(), + IconButton( + tooltip: 'Cancel Timer', + onPressed: videoStore.cancelSleepTimer, + icon: const Icon(Icons.cancel), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + style: TextButton.styleFrom(primary: Colors.red), + child: const Text('Dismiss'), + ), + Observer( + builder: (context) => ElevatedButton( + onPressed: videoStore.sleepHours == 0 && videoStore.sleepMinutes == 0 + ? null + : () => videoStore.updateSleepTimer( + onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst), + ), + child: const Text('Set Timer'), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final orientation = MediaQuery.of(context).orientation; + + final backButton = Align( + alignment: Alignment.topLeft, + child: IconButton( + tooltip: 'Back', + icon: Icon( + Icons.adaptive.arrow_back, + color: Colors.white, + ), + onPressed: Navigator.of(context).pop, + ), + ); + + final settingsButton = IconButton( + tooltip: 'Settings', + icon: const Icon( + Icons.settings, + color: Colors.white, + ), + onPressed: () => showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: Settings(settingsStore: videoStore.settingsStore), + ), + ), + ); + + final chatOverlayButton = IconButton( + tooltip: 'Toggle Chat Overlay', + onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, + icon: const Icon(Icons.chat_bubble_outline), + color: Colors.white, + ); + + final refreshButton = IconButton( + tooltip: 'Refresh', + icon: const Icon( + Icons.refresh, + color: Colors.white, + ), + onPressed: videoStore.handleRefresh, + ); + + final fullScreenButton = IconButton( + tooltip: videoStore.settingsStore.fullScreen ? 'Exit Fullscreen' : 'Enter Fullscreen', + icon: videoStore.settingsStore.fullScreen + ? const Icon( + Icons.fullscreen_exit, + color: Colors.white, + ) + : const Icon( + Icons.fullscreen, + color: Colors.white, + ), + onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen, + ); + + final sleepTimerButton = IconButton( + tooltip: 'Sleep Timer', + icon: const Icon( + Icons.timer, + color: Colors.white, + ), + onPressed: () => _showSleepTimerDialog(context), + ); + + final streamInfo = videoStore.streamInfo; + if (streamInfo == null) { + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + backButton, + const Spacer(), + settingsButton, + ], + ), + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, + refreshButton, + if (orientation == Orientation.landscape) fullScreenButton, + ], + ), + ), + ], + ); + } + + final streamerName = regexEnglish.hasMatch(streamInfo.userName) ? streamInfo.userName : '${streamInfo.userName} (${streamInfo.userLogin})'; + final streamer = Row( + children: [ + ProfilePicture( + userLogin: streamInfo.userLogin, + radius: 10.0, + ), + const SizedBox(width: 5.0), + Flexible( + child: Tooltip( + message: streamerName, + preferBelow: false, + child: Text( + streamerName, + style: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + + return Observer( + builder: (context) { + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + backButton, + const Spacer(), + sleepTimerButton, + settingsButton, + ], + ), + + // Add a play button when paused for Android + // When an ad is paused on Android there is no way to unpause, so a play button is necessary. + if (Platform.isAndroid) + Center( + child: IconButton( + tooltip: videoStore.paused ? 'Play' : 'Pause', + iconSize: 50.0, + icon: videoStore.paused + ? const Icon( + Icons.play_arrow, + color: Colors.white, + ) + : const Icon( + Icons.pause, + color: Colors.white, + ), + onPressed: videoStore.handlePausePlay, + ), + ) + else if (!videoStore.paused) + Center( + child: IconButton( + tooltip: 'Pause', + iconSize: 50.0, + icon: const Icon( + Icons.pause, + color: Colors.white, + ), + onPressed: videoStore.handlePausePlay, + ), + ), + Align( + alignment: Alignment.bottomLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: GestureDetector( + onTap: videoStore.handleExpand, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: videoStore.settingsStore.expandInfo + ? Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + streamer, + const SizedBox(height: 5.0), + Tooltip( + message: videoStore.streamInfo!.title.trim(), + preferBelow: false, + padding: const EdgeInsets.all(10.0), + child: Text( + videoStore.streamInfo!.title.trim(), + maxLines: orientation == Orientation.portrait ? 1 : 5, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 5.0), + Text( + '${videoStore.streamInfo?.gameName} \u2022 ${NumberFormat().format(videoStore.streamInfo?.viewerCount)} viewers', + style: const TextStyle( + color: Colors.white, + ), + ), + ], + ) + : streamer, + ), + ), + ), + if (Platform.isIOS && videoStore.settingsStore.pictureInPicture) + IconButton( + tooltip: 'Picture-in-Picture', + icon: const Icon( + Icons.picture_in_picture_alt_rounded, + color: Colors.white, + ), + onPressed: videoStore.requestPictureInPicture, + ), + if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, + refreshButton, + if (orientation == Orientation.landscape) fullScreenButton + ], + ), + ) + ], + ); + }, + ); + } +}