diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4d74199f..f93771fc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,8 +14,8 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v2 - - uses: subosito/flutter-action@v1 + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 - run: flutter doctor -v - run: flutter pub get diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png deleted file mode 100644 index a8ba2bab..00000000 Binary files a/android/app/src/main/res/drawable-night-v21/background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png deleted file mode 100644 index a8ba2bab..00000000 Binary files a/android/app/src/main/res/drawable-night/background.png and /dev/null differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2de1b00c..b90e2d34 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - device_info_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_native_splash (0.0.1): - Flutter @@ -31,6 +33,7 @@ PODS: - Flutter DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) @@ -49,6 +52,8 @@ SPEC REPOS: - Sentry EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_native_splash: @@ -73,6 +78,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: + device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png deleted file mode 100644 index a8ba2bab..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index f3387d4a..00cabce8 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -5,48 +5,15 @@ "idiom" : "universal", "scale" : "1x" }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "LaunchImageDark.png", - "idiom" : "universal", - "scale" : "1x" - }, { "filename" : "LaunchImage@2x.png", "idiom" : "universal", "scale" : "2x" }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "LaunchImageDark@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, { "filename" : "LaunchImage@3x.png", "idiom" : "universal", "scale" : "3x" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "LaunchImageDark@3x.png", - "idiom" : "universal", - "scale" : "3x" } ], "info" : { diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png deleted file mode 100644 index 65e45c48..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png deleted file mode 100644 index b45507c1..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png deleted file mode 100644 index d56cc8fd..00000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png and /dev/null differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index 15efa880..0740e086 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -18,7 +18,6 @@ - @@ -41,6 +40,5 @@ - \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6abd887e..084a1491 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -75,86 +75,86 @@ void main() async { // Navigator key for sleep timer. Allows navigation popping without context. final navigatorKey = GlobalKey(); +const gray = Color.fromRGBO(22, 22, 22, 1.0); +const purple = Color(0xff9146ff); + +final lightTheme = ThemeData( + canvasColor: Colors.white, + splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, + fontFamily: 'Inter', + appBarTheme: const AppBarTheme( + color: Colors.white, + elevation: 0.0, + titleTextStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + iconTheme: IconThemeData(color: Colors.black), + ), + colorScheme: ColorScheme.fromSwatch( + primarySwatch: Colors.deepPurple, + accentColor: purple, + ), + toggleableActiveColor: purple, + tabBarTheme: const TabBarTheme( + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + ), +); + +final darkTheme = ThemeData( + canvasColor: gray, + brightness: Brightness.dark, + splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, + fontFamily: 'Inter', + appBarTheme: const AppBarTheme( + color: gray, + elevation: 0.0, + titleTextStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData(backgroundColor: gray), + colorScheme: ColorScheme.fromSwatch( + brightness: Brightness.dark, + primarySwatch: Colors.deepPurple, + accentColor: purple, + ), + dialogBackgroundColor: gray, + toggleableActiveColor: purple, +); + +final oledTheme = ThemeData( + canvasColor: Colors.black, + splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, + fontFamily: 'Inter', + appBarTheme: const AppBarTheme( + color: Colors.black, + elevation: 0.0, + titleTextStyle: TextStyle( + fontFamily: 'Inter', + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + colorScheme: ColorScheme.fromSwatch( + brightness: Brightness.dark, + primarySwatch: Colors.deepPurple, + accentColor: purple, + ), + dialogBackgroundColor: Colors.black, + toggleableActiveColor: purple, +); + class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - const gray = Color.fromRGBO(22, 22, 22, 1.0); - const purple = Color(0xff9146ff); - - final lightTheme = ThemeData( - canvasColor: Colors.white, - splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - fontFamily: 'Inter', - appBarTheme: const AppBarTheme( - color: Colors.white, - elevation: 0.0, - titleTextStyle: TextStyle( - fontFamily: 'Inter', - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - iconTheme: IconThemeData(color: Colors.black), - ), - colorScheme: ColorScheme.fromSwatch( - primarySwatch: Colors.deepPurple, - accentColor: purple, - ), - toggleableActiveColor: purple, - tabBarTheme: const TabBarTheme( - labelColor: Colors.black, - unselectedLabelColor: Colors.grey, - ), - ); - - final darkTheme = ThemeData( - canvasColor: gray, - brightness: Brightness.dark, - splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - fontFamily: 'Inter', - appBarTheme: const AppBarTheme( - color: gray, - elevation: 0.0, - titleTextStyle: TextStyle( - fontFamily: 'Inter', - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData(backgroundColor: gray), - colorScheme: ColorScheme.fromSwatch( - brightness: Brightness.dark, - primarySwatch: Colors.deepPurple, - accentColor: purple, - ), - dialogBackgroundColor: gray, - toggleableActiveColor: purple, - ); - - final oledTheme = ThemeData( - canvasColor: Colors.black, - splashFactory: Platform.isIOS ? NoSplash.splashFactory : null, - fontFamily: 'Inter', - appBarTheme: const AppBarTheme( - color: Colors.black, - elevation: 0.0, - titleTextStyle: TextStyle( - fontFamily: 'Inter', - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - colorScheme: ColorScheme.fromSwatch( - brightness: Brightness.dark, - primarySwatch: Colors.deepPurple, - accentColor: purple, - ), - dialogBackgroundColor: Colors.black, - toggleableActiveColor: purple, - ); - return Observer( builder: (context) { final settingsStore = context.read(); diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index b1145686..b8f7765e 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -108,8 +108,7 @@ class Chat extends StatelessWidget { ), ), ), - if (chatStore.settings.showBottomBar && (!chatStore.settings.fullScreen || MediaQuery.of(context).orientation == Orientation.portrait)) - ChatBottomBar(chatStore: chatStore), + if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore), if (chatStore.assetsStore.showEmoteMenu) SizedBox( height: MediaQuery.of(context).size.height / 3, diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 6c652881..13c85fda 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -43,6 +43,8 @@ class _ChattersListState extends State { widget.chatDetails.showJumpButton = true; } }); + + widget.chatDetails.updateChatters(widget.userLogin); } @override @@ -58,7 +60,6 @@ class _ChattersListState extends State { ]; final chatDetailsStore = widget.chatDetails; - chatDetailsStore.updateChatters(widget.userLogin); return Column( children: [ @@ -116,6 +117,7 @@ class _ChattersListState extends State { } return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, slivers: [ SliverPadding( diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 7b8235b1..c942727f 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -14,16 +14,20 @@ class ChatBottomBar extends StatelessWidget { Widget build(BuildContext context) { return Observer( builder: (context) { - final emotes = [ + final matchingEmotes = [ ...chatStore.assetsStore.userEmoteToObject.values, ...chatStore.assetsStore.bttvEmotes, ...chatStore.assetsStore.ffzEmotes, ...chatStore.assetsStore.sevenTVEmotes ].where((emote) => emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase())).toList(); + final matchingChatters = chatStore.chatDetailsStore.allChatters + .where((chatter) => chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase())) + .toList(); + return Column( children: [ - if (chatStore.settings.emoteAutocomplete && chatStore.showAutocomplete && emotes.isNotEmpty) ...[ + if (chatStore.settings.autocomplete && chatStore.showEmoteAutocomplete && matchingEmotes.isNotEmpty) ...[ const Divider( height: 1.0, thickness: 1.0, @@ -32,27 +36,52 @@ class ChatBottomBar extends StatelessWidget { height: 50, child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 5.0), - itemCount: emotes.length, + itemCount: matchingEmotes.length, scrollDirection: Axis.horizontal, itemBuilder: (context, index) => InkWell( - onTap: () => chatStore.addEmote(emotes[index], autocompleteMode: true), + onTap: () => chatStore.addEmote(matchingEmotes[index], autocompleteMode: true), child: Tooltip( - message: emotes[index].name, + message: matchingEmotes[index].name, preferBelow: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 5.0), child: Center( child: CachedNetworkImage( - imageUrl: emotes[index].url, + imageUrl: matchingEmotes[index].url, fadeInDuration: const Duration(), - height: emotes[index].height?.toDouble() ?? defaultEmoteSize, - width: emotes[index].width?.toDouble(), + height: matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize, + width: matchingEmotes[index].width?.toDouble(), ), ), ), ), ), ), + ), + ], + if (chatStore.settings.autocomplete && chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[ + const Divider( + height: 1.0, + thickness: 1.0, + ), + SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + itemCount: matchingChatters.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => TextButton( + onPressed: () { + final split = chatStore.textController.text.split(' ') + ..removeLast() + ..add('@${matchingChatters[index]} '); + + chatStore.textController.text = split.join(' '); + chatStore.textController.selection = TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length)); + }, + child: Text(matchingChatters[index]), + ), + ), ) ], Row( @@ -73,9 +102,7 @@ class ChatBottomBar extends StatelessWidget { ), ), ), - if (!chatStore.expandChat && - chatStore.settings.landscapeChatWidth < 0.3 && - MediaQuery.of(context).orientation == Orientation.landscape) + if (!chatStore.expandChat && chatStore.settings.chatWidth < 0.3 && MediaQuery.of(context).orientation == Orientation.landscape) IconButton( tooltip: 'Send a Message', onPressed: () { @@ -115,7 +142,7 @@ class ChatBottomBar extends StatelessWidget { ), ), ), - if (chatStore.settings.landscapeChatWidth > 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait) + if (chatStore.settings.chatWidth > 0.3 || chatStore.expandChat || MediaQuery.of(context).orientation == Orientation.portrait) IconButton( tooltip: 'Send', icon: const Icon(Icons.send), diff --git a/lib/screens/channel/stores/chat_details_store.dart b/lib/screens/channel/stores/chat_details_store.dart index 6178f9d4..a89f277d 100644 --- a/lib/screens/channel/stores/chat_details_store.dart +++ b/lib/screens/channel/stores/chat_details_store.dart @@ -23,6 +23,10 @@ abstract class ChatDetailsStoreBase with Store { @observable var filterText = ''; + /// The list and types of chatters in the chat room. + @readonly + ChatUsers? _chatUsers; + @computed Iterable> get filteredUsers => [ _chatUsers!.chatters.broadcaster, @@ -34,9 +38,16 @@ abstract class ChatDetailsStoreBase with Store { _chatUsers!.chatters.viewers, ].map((e) => e.where((user) => user.contains(filterText)).toList()); - /// The list and types of chatters in the chat room. - @readonly - ChatUsers? _chatUsers; + @computed + List get allChatters => [ + ...?_chatUsers?.chatters.broadcaster, + ...?_chatUsers?.chatters.staff, + ...?_chatUsers?.chatters.admins, + ...?_chatUsers?.chatters.globalMods, + ...?_chatUsers?.chatters.moderators, + ...?_chatUsers?.chatters.vips, + ...?_chatUsers?.chatters.viewers, + ]; @readonly String? _error; diff --git a/lib/screens/channel/stores/chat_details_store.g.dart b/lib/screens/channel/stores/chat_details_store.g.dart index e443227d..dd6fa147 100644 --- a/lib/screens/channel/stores/chat_details_store.g.dart +++ b/lib/screens/channel/stores/chat_details_store.g.dart @@ -16,6 +16,13 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { Computed>>(() => super.filteredUsers, name: 'ChatDetailsStoreBase.filteredUsers')) .value; + Computed>? _$allChattersComputed; + + @override + List get allChatters => + (_$allChattersComputed ??= Computed>(() => super.allChatters, + name: 'ChatDetailsStoreBase.allChatters')) + .value; late final _$roomStateAtom = Atom(name: 'ChatDetailsStoreBase.roomState', context: context); @@ -116,7 +123,8 @@ mixin _$ChatDetailsStore on ChatDetailsStoreBase, Store { roomState: ${roomState}, showJumpButton: ${showJumpButton}, filterText: ${filterText}, -filteredUsers: ${filteredUsers} +filteredUsers: ${filteredUsers}, +allChatters: ${allChatters} '''; } } diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 1d822d9e..1a57128f 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -72,7 +72,10 @@ abstract class ChatStoreBase with Store { var _autoScroll = true; @readonly - var _showAutocomplete = false; + var _showEmoteAutocomplete = false; + + @readonly + var _showMentionAutocomplete = false; /// The logged-in user's appearance in chat. @readonly @@ -100,6 +103,7 @@ abstract class ChatStoreBase with Store { )); assetsStore.init(); + chatDetailsStore.updateChatters(channelName); _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); @@ -121,17 +125,25 @@ abstract class ChatStoreBase with Store { } }); - // Add a listener to the textfield focus that will hide the emote menu if it is currently shown. textFieldFocusNode.addListener(() { - if (textFieldFocusNode.hasFocus && assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false; + if (textFieldFocusNode.hasFocus) { + // Hide the emote menu if it is currently shown. + if (assetsStore.showEmoteMenu) assetsStore.showEmoteMenu = false; + // Refresh the chatters list to keep autocomplete mentions updated. + if (settings.autocomplete) chatDetailsStore.updateChatters(channelName); + } + + // Un-expand the chat when unfocusing. if (!textFieldFocusNode.hasFocus) expandChat = false; }); // Add a listener to the textfield that will show/hide the autocomplete bar if focused. // Will also rebuild the autocomplete bar when typing, refreshing the results as the user types. - textController - .addListener(() => _showAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty ? true : false); + textController.addListener( + () => _showEmoteAutocomplete = !_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty); + + textController.addListener(() => _showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@')); } /// Handle and process the provided string-representation of the IRC data. @@ -342,7 +354,7 @@ abstract class ChatStoreBase with Store { void addEmote(Emote emote, {bool autocompleteMode = false}) { if (textController.text.isEmpty || textController.text.endsWith(' ')) { textController.text += '${emote.name} '; - } else if (autocompleteMode && _showAutocomplete && textController.text.endsWith('')) { + } else if (autocompleteMode && _showEmoteAutocomplete && textController.text.endsWith('')) { final split = textController.text.split(' ') ..removeLast() ..add('${emote.name} '); diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index f363ce6d..4d4189c8 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -45,21 +45,41 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } - late final _$_showAutocompleteAtom = - Atom(name: 'ChatStoreBase._showAutocomplete', context: context); + late final _$_showEmoteAutocompleteAtom = + Atom(name: 'ChatStoreBase._showEmoteAutocomplete', context: context); - bool get showAutocomplete { - _$_showAutocompleteAtom.reportRead(); - return super._showAutocomplete; + bool get showEmoteAutocomplete { + _$_showEmoteAutocompleteAtom.reportRead(); + return super._showEmoteAutocomplete; } @override - bool get _showAutocomplete => showAutocomplete; + bool get _showEmoteAutocomplete => showEmoteAutocomplete; @override - set _showAutocomplete(bool value) { - _$_showAutocompleteAtom.reportWrite(value, super._showAutocomplete, () { - super._showAutocomplete = value; + set _showEmoteAutocomplete(bool value) { + _$_showEmoteAutocompleteAtom + .reportWrite(value, super._showEmoteAutocomplete, () { + super._showEmoteAutocomplete = value; + }); + } + + late final _$_showMentionAutocompleteAtom = + Atom(name: 'ChatStoreBase._showMentionAutocomplete', context: context); + + bool get showMentionAutocomplete { + _$_showMentionAutocompleteAtom.reportRead(); + return super._showMentionAutocomplete; + } + + @override + bool get _showMentionAutocomplete => showMentionAutocomplete; + + @override + set _showMentionAutocomplete(bool value) { + _$_showMentionAutocompleteAtom + .reportWrite(value, super._showMentionAutocomplete, () { + super._showMentionAutocomplete = value; }); } diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index 44e2f2c2..c0e20e90 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:frosty/api/twitch_api.dart'; @@ -37,6 +39,9 @@ abstract class VideoStoreBase with Store { @readonly var _overlayVisible = true; + @readonly + var _isIPad = false; + @readonly StreamTwitch? _streamInfo; @@ -152,13 +157,27 @@ abstract class VideoStoreBase with Store { return NavigationDecision.prevent; } - void initVideo() { + @action + Future initVideo() async { try { 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()); } + + // Determine whether the device is an iPad or not. + // Used to show or hide the rotate button on the overlay. + // Flutter doesn't allow programmatic rotation on iPad unless multitasking is disabled. + if (Platform.isIOS) { + final deviceInfo = DeviceInfoPlugin(); + final info = await deviceInfo.iosInfo; + if (info.model?.toLowerCase().contains('ipad') == true) { + _isIPad = true; + } else { + _isIPad = false; + } + } } void requestPictureInPicture() { diff --git a/lib/screens/channel/stores/video_store.g.dart b/lib/screens/channel/stores/video_store.g.dart index 28710c74..914038ce 100644 --- a/lib/screens/channel/stores/video_store.g.dart +++ b/lib/screens/channel/stores/video_store.g.dart @@ -101,6 +101,24 @@ mixin _$VideoStore on VideoStoreBase, Store { }); } + late final _$_isIPadAtom = + Atom(name: 'VideoStoreBase._isIPad', context: context); + + bool get isIPad { + _$_isIPadAtom.reportRead(); + return super._isIPad; + } + + @override + bool get _isIPad => isIPad; + + @override + set _isIPad(bool value) { + _$_isIPadAtom.reportWrite(value, super._isIPad, () { + super._isIPad = value; + }); + } + late final _$_streamInfoAtom = Atom(name: 'VideoStoreBase._streamInfo', context: context); @@ -136,6 +154,14 @@ mixin _$VideoStore on VideoStoreBase, Store { .run(() => super.handleToggleOverlay()); } + late final _$initVideoAsyncAction = + AsyncAction('VideoStoreBase.initVideo', context: context); + + @override + Future initVideo() { + return _$initVideoAsyncAction.run(() => super.initVideo()); + } + late final _$VideoStoreBaseActionController = ActionController(name: 'VideoStoreBase', context: context); 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..daeff269 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; @@ -66,14 +72,17 @@ class _VideoChatState extends State { Widget build(BuildContext context) { final settingsStore = _chatStore.settings; - final player = Video( - key: _videoKey, - videoStore: _videoStore, + final player = GestureDetector( + onLongPress: _videoStore.handleToggleOverlay, + child: _Video( + key: _videoKey, + videoStore: _videoStore, + ), ); - final overlay = VideoOverlay(videoStore: _videoStore); + final videoOverlay = _VideoOverlay(videoStore: _videoStore); - final video = GestureDetector( + final overlay = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, onTap: () { if (_chatStore.assetsStore.showEmoteMenu) { @@ -87,33 +96,37 @@ class _VideoChatState extends State { } }, child: Observer( - builder: (context) { - if (_videoStore.settingsStore.showOverlay) { - return Stack( - children: [ - player, - if (_videoStore.paused || _videoStore.streamInfo == null) - overlay - else - 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: overlay, - ), - ), - ), - ], - ); - } - return player; + builder: (_) { + if (_videoStore.paused || _videoStore.streamInfo == null) return videoOverlay; + + return AnimatedOpacity( + opacity: _videoStore.overlayVisible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: ColoredBox( + color: Colors.black.withOpacity(settingsStore.overlayOpacity), + child: IgnorePointer( + ignoring: !_videoStore.overlayVisible, + child: videoOverlay, + ), + ), + ); }, ), ); + final video = Observer( + builder: (context) { + if (!_videoStore.settingsStore.showOverlay) return player; + + return Stack( + children: [ + player, + overlay, + ], + ); + }, + ); + final chat = Chat( key: _chatKey, chatStore: _chatStore, @@ -157,12 +170,26 @@ class _VideoChatState extends State { duration: const Duration(milliseconds: 200), width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 - : MediaQuery.of(context).size.width * _chatStore.settings.landscapeChatWidth, + : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, curve: Curves.ease, - color: _chatStore.settings.fullScreen ? null : Theme.of(context).scaffoldBackgroundColor, + color: _chatStore.settings.fullScreen + ? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity) + : Theme.of(context).scaffoldBackgroundColor, child: chat, ); + final overlayChat = Visibility( + visible: settingsStore.fullScreenChatOverlay, + maintainState: true, + child: Theme( + data: darkTheme, + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white), + child: landscapeChat, + ), + ), + ); + return ColoredBox( color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor, child: SafeArea( @@ -170,38 +197,23 @@ class _VideoChatState extends State { child: settingsStore.showVideo ? settingsStore.fullScreen ? Stack( - alignment: settingsStore.landscapeChatLeftSide ? Alignment.topLeft : Alignment.topRight, children: [ - video, - IgnorePointer( - ignoring: _videoStore.overlayVisible, - child: Visibility( - visible: settingsStore.fullScreenChatOverlay, - maintainState: true, - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white), - child: landscapeChat, - ), - ), - ), + player, + if (settingsStore.showOverlay) + Row( + children: settingsStore.landscapeChatLeftSide + ? [overlayChat, Expanded(child: overlay)] + : [Expanded(child: overlay), overlayChat], + ) ], ) : Row( children: settingsStore.landscapeChatLeftSide - ? [ - landscapeChat, - Expanded(child: video), - ] - : [ - Expanded(child: video), - landscapeChat, - ], + ? [landscapeChat, Expanded(child: video)] + : [Expanded(child: video), landscapeChat], ) : Column( - children: [ - appBar, - Expanded(child: chat), - ], + children: [appBar, Expanded(child: chat)], ), ), ); @@ -218,13 +230,9 @@ class _VideoChatState extends State { children: [ Observer( builder: (_) { - if (settingsStore.showVideo) { - return AspectRatio( - aspectRatio: 16 / 9, - child: video, - ); - } - return appBar; + if (!settingsStore.showVideo) return appBar; + + return AspectRatio(aspectRatio: 16 / 9, child: video); }, ), Expanded(child: chat), @@ -247,6 +255,376 @@ class _VideoChatState extends State { overlays: SystemUiOverlay.values, ); + SystemChrome.setPreferredOrientations([]); + 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 = Observer( + builder: (_) => IconButton( + tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide Chat Overlay' : 'Show Chat Overlay', + onPressed: () => videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay, + icon: videoStore.settingsStore.fullScreenChatOverlay ? const Icon(Icons.chat_bubble_outline) : const Icon(Icons.chat_bubble), + 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 rotateButton = IconButton( + tooltip: orientation == Orientation.portrait ? 'Enter Landscape Mode' : 'Exit Landscape Mode', + icon: const Icon( + Icons.screen_rotation, + color: Colors.white, + ), + onPressed: () { + if (orientation == Orientation.portrait) { + if (Platform.isIOS) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setPreferredOrientations([]); + } else { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); + } + } else { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + SystemChrome.setPreferredOrientations([]); + } + }, + ); + + final streamInfo = videoStore.streamInfo; + if (streamInfo == null) { + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + backButton, + const Spacer(), + if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, + settingsButton, + ], + ), + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + refreshButton, + if (!videoStore.isIPad) rotateButton, + 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(), + if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton, + 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, + ), + refreshButton, + if (!videoStore.isIPad) rotateButton, + if (orientation == Orientation.landscape) fullScreenButton, + ], + ), + ) + ], + ); + }, + ); + } +} diff --git a/lib/screens/settings/sections/chat_settings.dart b/lib/screens/settings/sections/chat_settings.dart index 59585a76..515a4449 100644 --- a/lib/screens/settings/sections/chat_settings.dart +++ b/lib/screens/settings/sections/chat_settings.dart @@ -38,6 +38,14 @@ class _ChatSettingsState extends State { ), ), children: [ + SwitchListTile.adaptive( + isThreeLine: true, + title: const Text('Autocomplete'), + subtitle: const Text('Shows a bar that suggests matching emotes and mentions when typing.'), + value: settingsStore.autocomplete, + onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null, + ), + const SizedBox(height: 15.0), ListTile( title: Row( children: [ @@ -75,15 +83,32 @@ class _ChatSettingsState extends State { children: [ const Text('Landscape Chat Width'), const Spacer(), - Text('${(settingsStore.landscapeChatWidth * 100).toInt()}%'), + Text('${(settingsStore.chatWidth * 100).toStringAsFixed(0)}%'), ], ), subtitle: Slider.adaptive( - value: settingsStore.landscapeChatWidth, + value: settingsStore.chatWidth, min: 0.2, - max: 0.8, - divisions: 12, - onChanged: (newValue) => settingsStore.landscapeChatWidth = newValue, + max: 0.6, + divisions: 8, + onChanged: (newValue) => settingsStore.chatWidth = newValue, + ), + ), + const SizedBox(height: 15.0), + ListTile( + title: Row( + children: [ + const Text('Landscape Chat Overlay Opacity'), + const Spacer(), + Text('${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%'), + ], + ), + subtitle: Slider.adaptive( + value: settingsStore.fullScreenChatOverlayOpacity, + min: 0.0, + max: 1.0, + divisions: 10, + onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue, ), ), const SectionHeader( @@ -91,13 +116,6 @@ class _ChatSettingsState extends State { fontSize: 12.0, padding: sectionPadding, ), - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Emote Autocomplete'), - subtitle: const Text('Shows a bar that suggests matching emotes when typing.'), - value: settingsStore.emoteAutocomplete, - onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.emoteAutocomplete = newValue : null, - ), SwitchListTile.adaptive( isThreeLine: true, title: const Text('Zero-Width Emotes'), diff --git a/lib/screens/settings/sections/video_settings.dart b/lib/screens/settings/sections/video_settings.dart index a2280088..9b5f2631 100644 --- a/lib/screens/settings/sections/video_settings.dart +++ b/lib/screens/settings/sections/video_settings.dart @@ -49,6 +49,23 @@ class VideoSettings extends StatelessWidget { value: settingsStore.toggleableOverlay, onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null, ), + const SizedBox(height: 15.0), + ListTile( + title: Row( + children: [ + const Text('Custom Overlay Opacity'), + const Spacer(), + Text('${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%'), + ], + ), + subtitle: Slider.adaptive( + value: settingsStore.overlayOpacity, + min: 0.0, + max: 1.0, + divisions: 10, + onChanged: (newValue) => settingsStore.overlayOpacity = newValue, + ), + ), ], ), ); diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 1107345c..69f61d4b 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -46,6 +46,8 @@ abstract class _SettingsStoreBase with Store { static const defaultPictureInPicture = false; + static const defaultOverlayOpacity = 0.5; + @JsonKey(defaultValue: defaultShowVideo) @observable var showVideo = defaultShowVideo; @@ -62,6 +64,10 @@ abstract class _SettingsStoreBase with Store { @observable var pictureInPicture = defaultPictureInPicture; + @JsonKey(defaultValue: defaultOverlayOpacity) + @observable + var overlayOpacity = defaultOverlayOpacity; + // * Chat Settings static const defaultShowBottomBar = true; @@ -85,14 +91,16 @@ abstract class _SettingsStoreBase with Store { static const defaultEmoteScale = 1.0; - static const defaultEmoteAutocomplete = true; + static const defaultAutocomplete = true; - static const defaultLandscapeChatWidth = 0.3; + static const defaultChatWidth = 0.3; static const defaultLandscapeChatLeftSide = false; static const defaultChatDelay = 0.0; + static const defaultFullScreenChatOverlayOpacity = 0.5; + @JsonKey(defaultValue: defaultShowBottomBar) @observable var showBottomBar = defaultShowBottomBar; @@ -137,18 +145,22 @@ abstract class _SettingsStoreBase with Store { @observable var emoteScale = defaultEmoteScale; - @JsonKey(defaultValue: defaultEmoteAutocomplete) + @JsonKey(defaultValue: defaultAutocomplete) @observable - var emoteAutocomplete = defaultEmoteAutocomplete; + var autocomplete = defaultAutocomplete; - @JsonKey(defaultValue: defaultLandscapeChatWidth) + @JsonKey(defaultValue: defaultChatWidth) @observable - var landscapeChatWidth = defaultLandscapeChatWidth; + var chatWidth = defaultChatWidth; @JsonKey(defaultValue: defaultLandscapeChatLeftSide) @observable var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + @JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity) + @observable + var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; + // * Other settings static const defaultSendCrashLogs = true; @@ -192,6 +204,7 @@ abstract class _SettingsStoreBase with Store { showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; pictureInPicture = defaultPictureInPicture; + overlayOpacity = defaultOverlayOpacity; // * Chat Settings showBottomBar = defaultShowBottomBar; @@ -205,10 +218,11 @@ abstract class _SettingsStoreBase with Store { messageSpacing = defaultMessageSpacing; badgeScale = defaultBadgeScale; emoteScale = defaultEmoteScale; - emoteAutocomplete = defaultEmoteAutocomplete; - landscapeChatWidth = defaultLandscapeChatWidth; + autocomplete = defaultAutocomplete; + chatWidth = defaultChatWidth; landscapeChatLeftSide = false; chatDelay = defaultChatDelay; + fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity; // * Other settings sendCrashLogs = defaultSendCrashLogs; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index ce489e1c..7acdbab9 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -18,6 +18,7 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..pictureInPicture = json['pictureInPicture'] as bool? ?? false + ..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5 ..showBottomBar = json['showBottomBar'] as bool? ?? true ..showDeletedMessages = json['showDeletedMessages'] as bool? ?? false ..showZeroWidth = json['showZeroWidth'] as bool? ?? false @@ -33,10 +34,11 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0 ..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0 ..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0 - ..emoteAutocomplete = json['emoteAutocomplete'] as bool? ?? true - ..landscapeChatWidth = - (json['landscapeChatWidth'] as num?)?.toDouble() ?? 0.3 + ..autocomplete = json['autocomplete'] as bool? ?? true + ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3 ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false + ..fullScreenChatOverlayOpacity = + (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 ..sendCrashLogs = json['sendCrashLogs'] as bool? ?? true ..fullScreen = json['fullScreen'] as bool? ?? false ..expandInfo = json['expandInfo'] as bool? ?? true @@ -53,6 +55,7 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, 'pictureInPicture': instance.pictureInPicture, + 'overlayOpacity': instance.overlayOpacity, 'showBottomBar': instance.showBottomBar, 'showDeletedMessages': instance.showDeletedMessages, 'showZeroWidth': instance.showZeroWidth, @@ -64,9 +67,10 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'messageScale': instance.messageScale, 'badgeScale': instance.badgeScale, 'emoteScale': instance.emoteScale, - 'emoteAutocomplete': instance.emoteAutocomplete, - 'landscapeChatWidth': instance.landscapeChatWidth, + 'autocomplete': instance.autocomplete, + 'chatWidth': instance.chatWidth, 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, 'sendCrashLogs': instance.sendCrashLogs, 'fullScreen': instance.fullScreen, 'expandInfo': instance.expandInfo, @@ -222,6 +226,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$overlayOpacityAtom = + Atom(name: '_SettingsStoreBase.overlayOpacity', context: context); + + @override + double get overlayOpacity { + _$overlayOpacityAtom.reportRead(); + return super.overlayOpacity; + } + + @override + set overlayOpacity(double value) { + _$overlayOpacityAtom.reportWrite(value, super.overlayOpacity, () { + super.overlayOpacity = value; + }); + } + late final _$showBottomBarAtom = Atom(name: '_SettingsStoreBase.showBottomBar', context: context); @@ -399,35 +419,35 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$emoteAutocompleteAtom = - Atom(name: '_SettingsStoreBase.emoteAutocomplete', context: context); + late final _$autocompleteAtom = + Atom(name: '_SettingsStoreBase.autocomplete', context: context); @override - bool get emoteAutocomplete { - _$emoteAutocompleteAtom.reportRead(); - return super.emoteAutocomplete; + bool get autocomplete { + _$autocompleteAtom.reportRead(); + return super.autocomplete; } @override - set emoteAutocomplete(bool value) { - _$emoteAutocompleteAtom.reportWrite(value, super.emoteAutocomplete, () { - super.emoteAutocomplete = value; + set autocomplete(bool value) { + _$autocompleteAtom.reportWrite(value, super.autocomplete, () { + super.autocomplete = value; }); } - late final _$landscapeChatWidthAtom = - Atom(name: '_SettingsStoreBase.landscapeChatWidth', context: context); + late final _$chatWidthAtom = + Atom(name: '_SettingsStoreBase.chatWidth', context: context); @override - double get landscapeChatWidth { - _$landscapeChatWidthAtom.reportRead(); - return super.landscapeChatWidth; + double get chatWidth { + _$chatWidthAtom.reportRead(); + return super.chatWidth; } @override - set landscapeChatWidth(double value) { - _$landscapeChatWidthAtom.reportWrite(value, super.landscapeChatWidth, () { - super.landscapeChatWidth = value; + set chatWidth(double value) { + _$chatWidthAtom.reportWrite(value, super.chatWidth, () { + super.chatWidth = value; }); } @@ -448,6 +468,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$fullScreenChatOverlayOpacityAtom = Atom( + name: '_SettingsStoreBase.fullScreenChatOverlayOpacity', + context: context); + + @override + double get fullScreenChatOverlayOpacity { + _$fullScreenChatOverlayOpacityAtom.reportRead(); + return super.fullScreenChatOverlayOpacity; + } + + @override + set fullScreenChatOverlayOpacity(double value) { + _$fullScreenChatOverlayOpacityAtom + .reportWrite(value, super.fullScreenChatOverlayOpacity, () { + super.fullScreenChatOverlayOpacity = value; + }); + } + late final _$sendCrashLogsAtom = Atom(name: '_SettingsStoreBase.sendCrashLogs', context: context); @@ -554,6 +592,7 @@ showVideo: ${showVideo}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, pictureInPicture: ${pictureInPicture}, +overlayOpacity: ${overlayOpacity}, showBottomBar: ${showBottomBar}, showDeletedMessages: ${showDeletedMessages}, showZeroWidth: ${showZeroWidth}, @@ -565,9 +604,10 @@ messageSpacing: ${messageSpacing}, messageScale: ${messageScale}, badgeScale: ${badgeScale}, emoteScale: ${emoteScale}, -emoteAutocomplete: ${emoteAutocomplete}, -landscapeChatWidth: ${landscapeChatWidth}, +autocomplete: ${autocomplete}, +chatWidth: ${chatWidth}, landscapeChatLeftSide: ${landscapeChatLeftSide}, +fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, sendCrashLogs: ${sendCrashLogs}, fullScreen: ${fullScreen}, expandInfo: ${expandInfo}, diff --git a/pubspec.lock b/pubspec.lock index 0f2746d1..af82478a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -168,7 +168,7 @@ packages: name: convert url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.0.2" crypto: dependency: transitive description: @@ -182,7 +182,7 @@ packages: name: cupertino_icons url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "1.0.5" dart_style: dependency: transitive description: @@ -190,6 +190,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.3" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.4" + device_info_plus_linux: + dependency: transitive + description: + name: device_info_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + device_info_plus_macos: + dependency: transitive + description: + name: device_info_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0+1" + device_info_plus_web: + dependency: transitive + description: + name: device_info_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + device_info_plus_windows: + dependency: transitive + description: + name: device_info_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: @@ -243,7 +285,7 @@ packages: name: flutter_launcher_icons url: "https://pub.dartlang.org" source: hosted - version: "0.9.2" + version: "0.9.3" flutter_lints: dependency: "direct dev" description: @@ -264,7 +306,7 @@ packages: name: flutter_native_splash url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "2.2.3" flutter_secure_storage: dependency: "direct main" description: @@ -575,7 +617,7 @@ packages: name: path_provider url: "https://pub.dartlang.org" source: hosted - version: "2.0.10" + version: "2.0.11" path_provider_android: dependency: transitive description: @@ -596,7 +638,7 @@ packages: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.6" + version: "2.1.7" path_provider_macos: dependency: transitive description: @@ -617,7 +659,7 @@ packages: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.6" + version: "2.0.7" pedantic: dependency: transitive description: @@ -694,7 +736,7 @@ packages: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.27.3" + version: "0.27.4" sentry: dependency: transitive description: @@ -902,7 +944,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.1.2" + version: "6.1.3" url_launcher_android: dependency: transitive description: @@ -993,21 +1035,21 @@ packages: name: webview_flutter_android url: "https://pub.dartlang.org" source: hosted - version: "2.8.9" + version: "2.8.11" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.9.0" + version: "1.9.1" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview url: "https://pub.dartlang.org" source: hosted - version: "2.8.0" + version: "2.8.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0f0fbd06..6fa13481 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.4.1+23 +version: 1.5.0+24 environment: sdk: ">=2.15.0 <3.0.0" @@ -54,6 +54,7 @@ dependencies: package_info_plus: ^1.3.0 collection: ^1.16.0 flutter_cache_manager: ^3.3.0 + device_info_plus: ^3.2.3 dev_dependencies: flutter_test: