From e91f231173000d303af868118e8179c571571fc9 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 25 Jul 2022 16:29:02 -0400 Subject: [PATCH 01/28] Add `floating` package --- pubspec.lock | 7 +++++++ pubspec.yaml | 1 + 2 files changed, 8 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 25a53881..4d796533 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -260,6 +260,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + floating: + dependency: "direct main" + description: + name: floating + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 42cc6609..3bd686cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: collection: ^1.16.0 flutter_cache_manager: ^3.3.0 device_info_plus: ^4.0.0 + floating: ^1.1.0 dev_dependencies: flutter_test: From 402d75cc2105fa42c23a0b186777b55465167673 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 25 Jul 2022 16:31:14 -0400 Subject: [PATCH 02/28] Add initial support for PiP on Android (#154) --- android/app/src/main/AndroidManifest.xml | 1 + .../channel/chat/details/chat_modes.dart | 5 - lib/screens/channel/stores/video_store.dart | 14 +- lib/screens/channel/video_chat.dart | 163 +++++++++--------- .../settings/sections/video_settings.dart | 17 +- 5 files changed, 105 insertions(+), 95 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index addb3541..2284638f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" + android:supportsPictureInPicture="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> diff --git a/lib/screens/channel/chat/details/chat_modes.dart b/lib/screens/channel/chat/details/chat_modes.dart index 3c4d2c6a..dd98f6cc 100644 --- a/lib/screens/channel/chat/details/chat_modes.dart +++ b/lib/screens/channel/chat/details/chat_modes.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:frosty/models/irc.dart'; -import 'package:intl/intl.dart'; class ChatModes extends StatelessWidget { final ROOMSTATE roomState; @@ -11,10 +10,6 @@ class ChatModes extends StatelessWidget { @override Widget build(BuildContext context) { - final test = Intl.plural(2, other: 'test'); - - debugPrint(test); - return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index fb093333..ceb4c3c0 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:floating/floating.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:frosty/api/twitch_api.dart'; @@ -24,6 +25,8 @@ abstract class VideoStoreBase with Store { Timer? sleepTimer; + final floating = Floating(); + @observable var sleepHours = 0; @@ -184,7 +187,11 @@ abstract class VideoStoreBase with Store { void requestPictureInPicture() { try { - controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();'); + if (Platform.isAndroid) { + floating.enable(); + } else if (Platform.isIOS) { + controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();'); + } } catch (e) { debugPrint(e.toString()); } @@ -228,4 +235,9 @@ abstract class VideoStoreBase with Store { sleepTimer?.cancel(); timeRemaining = const Duration(); } + + void dispose() { + floating.dispose(); + sleepTimer?.cancel(); + } } diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index 7de5bd1a..9dcf9652 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -160,90 +161,94 @@ class _VideoChatState extends State { ], ); - return Scaffold( - body: OrientationBuilder( - builder: (context, orientation) { - // Scroll to bottom when summoning keyboard or rotating. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(_chatStore.scrollController.position.maxScrollExtent); - }); + return PiPSwitcher( + floating: _videoStore.floating, + childWhenEnabled: player, + childWhenDisabled: Scaffold( + body: OrientationBuilder( + builder: (context, orientation) { + // Scroll to bottom when summoning keyboard or rotating. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(_chatStore.scrollController.position.maxScrollExtent); + }); - if (orientation == Orientation.landscape) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + if (orientation == Orientation.landscape) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - return Observer( - builder: (context) { - final landscapeChat = AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, - curve: Curves.ease, - color: _chatStore.settings.fullScreen - ? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity) - : Theme.of(context).scaffoldBackgroundColor, - child: chat, - ); + return Observer( + builder: (context) { + final landscapeChat = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, + curve: Curves.ease, + 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, + 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( - bottom: false, - child: settingsStore.showVideo - ? settingsStore.fullScreen - ? Stack( - children: [ - 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], - ) - : Column( - children: [appBar, Expanded(child: chat)], - ), - ), - ); - }, + return ColoredBox( + color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor, + child: SafeArea( + bottom: false, + child: settingsStore.showVideo + ? settingsStore.fullScreen + ? Stack( + children: [ + 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], + ) + : Column( + children: [appBar, Expanded(child: chat)], + ), + ), + ); + }, + ); + } + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, ); - } + return SafeArea( + child: Column( + children: [ + Observer( + builder: (_) { + if (!settingsStore.showVideo) return appBar; - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - return SafeArea( - child: Column( - children: [ - Observer( - builder: (_) { - if (!settingsStore.showVideo) return appBar; - - return AspectRatio(aspectRatio: 16 / 9, child: video); - }, - ), - Expanded(child: chat), - ], - ), - ); - }, + return AspectRatio(aspectRatio: 16 / 9, child: video); + }, + ), + Expanded(child: chat), + ], + ), + ); + }, + ), ), ); } @@ -252,7 +257,7 @@ class _VideoChatState extends State { void dispose() { _chatStore.dispose(); - _videoStore.cancelSleepTimer(); + _videoStore.dispose(); SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, @@ -614,7 +619,7 @@ class _VideoOverlay extends StatelessWidget { ), ), ), - if (Platform.isIOS && videoStore.settingsStore.pictureInPicture) + if (videoStore.settingsStore.pictureInPicture) IconButton( tooltip: 'Picture-in-picture', icon: const Icon( diff --git a/lib/screens/settings/sections/video_settings.dart b/lib/screens/settings/sections/video_settings.dart index e71635d7..f5872bea 100644 --- a/lib/screens/settings/sections/video_settings.dart +++ b/lib/screens/settings/sections/video_settings.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; @@ -34,14 +32,13 @@ class VideoSettings extends StatelessWidget { value: settingsStore.showOverlay, onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null, ), - if (Platform.isIOS) - SwitchListTile.adaptive( - isThreeLine: true, - title: const Text('Picture-in-picture Button (experimental)'), - subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay (may cause freezes/crashes).'), - value: settingsStore.pictureInPicture, - onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null, - ), + SwitchListTile.adaptive( + isThreeLine: true, + title: const Text('Picture-in-picture Button (experimental)'), + subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay (may cause freezes/crashes).'), + value: settingsStore.pictureInPicture, + onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null, + ), SwitchListTile.adaptive( isThreeLine: true, title: const Text('Long-press player to toggle overlay'), From 932412b75ffb9eb1a646842750635b05237d2eed Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Tue, 26 Jul 2022 23:23:53 -0400 Subject: [PATCH 03/28] Optimize chat (#154) Optimized the chat so that resuming PiP does not cause stutters. This new implementation makes it so that only the latest (100) messages are rendered at any given time, rather than every single one. Once autoscroll is disabled and we scroll up in chat, the most recent messages will be added to a buffer to prevent shifting. Essentially, I've made it so that the default chat shows only the (100) most recent messages. When you want to scroll back, the chat will silently switch to the version with the last (10000) messages, giving the best of both worlds in performance and scrollback history. --- lib/models/emotes.g.dart | 2 +- lib/models/irc.dart | 6 +- lib/screens/channel/chat/chat.dart | 8 +- lib/screens/channel/stores/chat_store.dart | 83 ++++++++++++------- lib/screens/channel/stores/chat_store.g.dart | 19 ++++- lib/screens/channel/video_chat.dart | 2 +- .../settings/stores/settings_store.g.dart | 4 +- 7 files changed, 81 insertions(+), 43 deletions(-) diff --git a/lib/models/emotes.g.dart b/lib/models/emotes.g.dart index 9cc8e12b..f2f330dc 100644 --- a/lib/models/emotes.g.dart +++ b/lib/models/emotes.g.dart @@ -140,7 +140,7 @@ Map _$EmoteToJson(Emote instance) => { 'height': instance.height, 'zeroWidth': instance.zeroWidth, 'url': instance.url, - 'type': _$EmoteTypeEnumMap[instance.type], + 'type': _$EmoteTypeEnumMap[instance.type]!, 'ownerId': instance.ownerId, }; diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 4c0c8299..e38dc045 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -35,7 +35,7 @@ class IRCMessage { this.mention, }); - /// Returns a list of messages where the gievn CLEARCHAT message is applied. + /// Returns a list of messages where the given CLEARCHAT message is applied. static List clearChat({required List messages, required IRCMessage ircMessage}) { // If there is no message, it means that entire chat was cleared. if (ircMessage.message == null) { @@ -367,9 +367,7 @@ class IRCMessage { Column( children: [ Text( - nextWordIsEmoji - ? '${words[index]} - Emoji' - : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}', + nextWordIsEmoji ? '${words[index]} - Emoji' : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}', style: const TextStyle(color: Colors.black), ), Text( diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index ffaa0650..0797022e 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -36,13 +36,13 @@ class Chat extends StatelessWidget { child: Observer( builder: (context) { return ListView.builder( + reverse: true, padding: EdgeInsets.zero, addAutomaticKeepAlives: false, - addRepaintBoundaries: false, - itemCount: chatStore.messages.length, controller: chatStore.scrollController, + itemCount: chatStore.renderMessages.length, itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.messages[index], + ircMessage: chatStore.renderMessages.reversed.toList()[index], chatStore: chatStore, ), ); @@ -83,7 +83,7 @@ class Chat extends StatelessWidget { duration: const Duration(milliseconds: 200), child: EmoteMenu(chatStore: chatStore), ), - onEnd: () => chatStore.scrollController.jumpTo(chatStore.scrollController.position.maxScrollExtent), + onEnd: () => chatStore.scrollController.jumpTo(0), ), ], ); diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 31c36b54..2d7f27c0 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -18,8 +18,9 @@ part 'chat_store.g.dart'; class ChatStore = ChatStoreBase with _$ChatStore; abstract class ChatStoreBase with Store { - static const _messageUpperLimit = 7500; - static const _messageLimit = 5000; + static const _messageUpperLimit = 15000; + static const _messageLimit = 10000; + static const _renderMessageLimit = 100; /// The provided auth store to determine login status, get the token, and use the headers for requests. final AuthStore auth; @@ -63,10 +64,29 @@ abstract class ChatStoreBase with Store { /// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command. IRCMessage? toSend; + /// The list of chat messages to add once autoscroll is resumed. + /// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling. + final _messageBuffer = []; + /// The list of chat messages to render and display. @readonly var _messages = ObservableList(); + @computed + List get renderMessages { + // If autoscroll is disabled, render ALL messages in chat. + // The second condition is to prevent an out of index error with sublist. + // + // Else, when autoscroll is enabled, only show the first [_renderMessageLimit] messages. + // This will improve performance by only rendering a limited amount of messages + // instead of the entire history at all times. + if (!_autoScroll || _messages.length < _renderMessageLimit) { + return _messages; + } else { + return _messages.sublist(_messages.length - _renderMessageLimit); + } + } + /// If the chat should automatically scroll/jump to the latest message. @readonly var _autoScroll = true; @@ -105,6 +125,18 @@ abstract class ChatStoreBase with Store { (_) => _channel?.sink.close(1001), )); + // Create a reaction that will add buffered messages to the chat + // when autoscroll is changed. + reactions.add(reaction( + (_) => _autoScroll, + (_) { + if (_messageBuffer.isNotEmpty) { + _messages.addAll(_messageBuffer); + _messageBuffer.clear(); + } + }, + )); + assetsStore.init(); chatDetailsStore.updateChatters(); @@ -119,12 +151,12 @@ abstract class ChatStoreBase with Store { // Tell the scrollController to determine when auto-scroll should be enabled or disabled. scrollController.addListener(() { - // If the user scrolls up, auto-scroll will stop, allowing them to freely scroll back to previous messages. - // Else if the user scrolls back to the bottom edge (latest message), auto-scroll will resume. - if (scrollController.position.pixels < scrollController.position.maxScrollExtent) { - if (_autoScroll == true) _autoScroll = false; - } else if (scrollController.position.atEdge || scrollController.position.pixels > scrollController.position.maxScrollExtent) { - if (_autoScroll == false) _autoScroll = true; + // If the scroll position is at the latest message (maximum possible), enable autoscroll. + // Else if the position is before the latest message (not at the edge), disable autoscroll. + if (scrollController.position.pixels <= 0) { + _autoScroll = true; + } else if (scrollController.position.pixels > 0) { + _autoScroll = false; } }); @@ -172,7 +204,14 @@ abstract class ChatStoreBase with Store { switch (parsedIRCMessage.command) { case Command.privateMessage: - _messages.add(parsedIRCMessage); + case Command.notice: + case Command.userNotice: + if (_autoScroll) { + _messages.add(parsedIRCMessage); + } else { + // If audoscroll is disabled, buffer the message to prevent the chat from shifting. + _messageBuffer.add(parsedIRCMessage); + } break; case Command.clearChat: _messages = IRCMessage.clearChat(messages: _messages, ircMessage: parsedIRCMessage).asObservable(); @@ -180,10 +219,6 @@ abstract class ChatStoreBase with Store { case Command.clearMessage: _messages = IRCMessage.clearMessage(messages: _messages, ircMessage: parsedIRCMessage).asObservable(); break; - case Command.notice: - case Command.userNotice: - _messages.add(parsedIRCMessage); - break; case Command.roomState: chatDetailsStore.roomState = chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage); continue; @@ -212,16 +247,11 @@ abstract class ChatStoreBase with Store { continue; } - if (_autoScroll) { - if (_messages.length >= _messageLimit) _messages.removeAt(0); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent); - }); - } + // If the message limit is reached, remove the oldest message. + if (_messages.length >= _messageLimit) _messages.removeAt(0); // Remove messages when an upper limit is reached. - // This will prevent an infinite amount of messages when autoscroll is off. + // This will prevent an infinite amount of messages building up when autoscroll is off. if (_messages.length >= _messageUpperLimit) _messages.removeRange(0, 500); // Hard upper-limit of 5000 messages to prevent infinite messages being added when scrolling. @@ -258,15 +288,10 @@ abstract class ChatStoreBase with Store { /// Re-enables [_autoScroll] and jumps to the latest message. @action void resumeScroll() { - if (_messages.length >= _messageLimit) _messages.removeRange(0, _messages.length - _messageLimit); - _autoScroll = true; // Jump to the latest message (bottom of the list/chat). - scrollController.jumpTo(scrollController.position.maxScrollExtent); - - // Schedule a postFrameCallback in the event a new message is added at the same time. - WidgetsBinding.instance.addPostFrameCallback((_) => scrollController.jumpTo(scrollController.position.maxScrollExtent)); + scrollController.jumpTo(0); } @action @@ -353,9 +378,7 @@ abstract class ChatStoreBase with Store { } // Scroll to the latest message after sending. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) scrollController.jumpTo(scrollController.position.maxScrollExtent); - }); + if (scrollController.hasClients) scrollController.jumpTo(0); } /// Adds the given [emote] to the chat textfield. diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index a2f3f2b1..d3257900 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -9,6 +9,14 @@ part of 'chat_store.dart'; // ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers mixin _$ChatStore on ChatStoreBase, Store { + Computed>? _$renderMessagesComputed; + + @override + List get renderMessages => (_$renderMessagesComputed ??= + Computed>(() => super.renderMessages, + name: 'ChatStoreBase.renderMessages')) + .value; + late final _$_messagesAtom = Atom(name: 'ChatStoreBase._messages', context: context); @@ -135,6 +143,14 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } + late final _$getAssetsAsyncAction = + AsyncAction('ChatStoreBase.getAssets', context: context); + + @override + Future getAssets() { + return _$getAssetsAsyncAction.run(() => super.getAssets()); + } + late final _$ChatStoreBaseActionController = ActionController(name: 'ChatStoreBase', context: context); @@ -196,7 +212,8 @@ mixin _$ChatStore on ChatStoreBase, Store { @override String toString() { return ''' -expandChat: ${expandChat} +expandChat: ${expandChat}, +renderMessages: ${renderMessages} '''; } } diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index 9dcf9652..8f4f74bb 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -169,7 +169,7 @@ class _VideoChatState extends State { builder: (context, orientation) { // Scroll to bottom when summoning keyboard or rotating. WidgetsBinding.instance.addPostFrameCallback((_) { - if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(_chatStore.scrollController.position.maxScrollExtent); + if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(0); }); if (orientation == Orientation.landscape) { diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index c92b38db..54e4b839 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -48,7 +48,7 @@ SettingsStore _$SettingsStoreFromJson(Map json) => Map _$SettingsStoreToJson(SettingsStore instance) => { - 'themeType': _$ThemeTypeEnumMap[instance.themeType], + 'themeType': _$ThemeTypeEnumMap[instance.themeType]!, 'showThumbnails': instance.showThumbnails, 'largeStreamCard': instance.largeStreamCard, 'showThumbnailUptime': instance.showThumbnailUptime, @@ -68,7 +68,7 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'useReadableColors': instance.useReadableColors, 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, - 'timestampType': _$TimestampTypeEnumMap[instance.timestampType], + 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, 'badgeScale': instance.badgeScale, 'emoteScale': instance.emoteScale, 'messageScale': instance.messageScale, From 98fa3a68fa0c89f2e54c9e49cd0fb4442b6cc898 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Tue, 26 Jul 2022 23:30:16 -0400 Subject: [PATCH 04/28] Update chatters when opening chat details --- .../channel/chat/details/chat_details.dart | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index dd9f5efb..f177e84e 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -6,7 +6,7 @@ import 'package:frosty/screens/channel/stores/chat_details_store.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; import 'package:frosty/widgets/modal.dart'; -class ChatDetails extends StatelessWidget { +class ChatDetails extends StatefulWidget { final ChatDetailsStore chatDetailsStore; final ChatStore chatStore; final String userLogin; @@ -18,6 +18,17 @@ class ChatDetails extends StatelessWidget { required this.userLogin, }) : super(key: key); + @override + State createState() => _ChatDetailsState(); +} + +class _ChatDetailsState extends State { + @override + void initState() { + super.initState(); + widget.chatDetailsStore.updateChatters(); + } + @override Widget build(BuildContext context) { return FrostyModal( @@ -30,13 +41,13 @@ class ChatDetails extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 15.0), - child: ChatModes(roomState: chatDetailsStore.roomState), + child: ChatModes(roomState: widget.chatDetailsStore.roomState), ), Expanded( child: ChattersList( - chatDetailsStore: chatDetailsStore, - chatStore: chatStore, - userLogin: userLogin, + chatDetailsStore: widget.chatDetailsStore, + chatStore: widget.chatStore, + userLogin: widget.userLogin, ), ), ], From b2fe05a4c4a97cc5db9f329667a61c0d97dc9f8d Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 01:19:25 -0400 Subject: [PATCH 05/28] Fix stream info not refreshing --- lib/models/emotes.g.dart | 2 +- lib/screens/channel/stores/chat_store.g.dart | 8 ++++++++ lib/screens/channel/stores/video_store.dart | 2 ++ lib/screens/channel/stores/video_store.g.dart | 11 +++++++++++ lib/screens/settings/stores/settings_store.g.dart | 4 ++-- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/models/emotes.g.dart b/lib/models/emotes.g.dart index 9cc8e12b..f2f330dc 100644 --- a/lib/models/emotes.g.dart +++ b/lib/models/emotes.g.dart @@ -140,7 +140,7 @@ Map _$EmoteToJson(Emote instance) => { 'height': instance.height, 'zeroWidth': instance.zeroWidth, 'url': instance.url, - 'type': _$EmoteTypeEnumMap[instance.type], + 'type': _$EmoteTypeEnumMap[instance.type]!, 'ownerId': instance.ownerId, }; diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index a2f3f2b1..dcf97348 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -135,6 +135,14 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } + late final _$getAssetsAsyncAction = + AsyncAction('ChatStoreBase.getAssets', context: context); + + @override + Future getAssets() { + return _$getAssetsAsyncAction.run(() => super.getAssets()); + } + late final _$ChatStoreBaseActionController = ActionController(name: 'ChatStoreBase', context: context); diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index fb093333..c6b349a9 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -147,9 +147,11 @@ abstract class VideoStoreBase with Store { } } + @action void handleRefresh() { HapticFeedback.lightImpact(); controller?.reload(); + updateStreamInfo(); } FutureOr handleNavigation(NavigationRequest navigation) { diff --git a/lib/screens/channel/stores/video_store.g.dart b/lib/screens/channel/stores/video_store.g.dart index 914038ce..bcd5bebd 100644 --- a/lib/screens/channel/stores/video_store.g.dart +++ b/lib/screens/channel/stores/video_store.g.dart @@ -198,6 +198,17 @@ mixin _$VideoStore on VideoStoreBase, Store { } } + @override + void handleRefresh() { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase.handleRefresh'); + try { + return super.handleRefresh(); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index c92b38db..54e4b839 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -48,7 +48,7 @@ SettingsStore _$SettingsStoreFromJson(Map json) => Map _$SettingsStoreToJson(SettingsStore instance) => { - 'themeType': _$ThemeTypeEnumMap[instance.themeType], + 'themeType': _$ThemeTypeEnumMap[instance.themeType]!, 'showThumbnails': instance.showThumbnails, 'largeStreamCard': instance.largeStreamCard, 'showThumbnailUptime': instance.showThumbnailUptime, @@ -68,7 +68,7 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'useReadableColors': instance.useReadableColors, 'showDeletedMessages': instance.showDeletedMessages, 'showChatMessageDividers': instance.showChatMessageDividers, - 'timestampType': _$TimestampTypeEnumMap[instance.timestampType], + 'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!, 'badgeScale': instance.badgeScale, 'emoteScale': instance.emoteScale, 'messageScale': instance.messageScale, From 9911ed49e6a936910a3d9c938b43859c368e8bc1 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 01:57:10 -0400 Subject: [PATCH 06/28] Fix `floating` throwing exception on iOS --- lib/screens/channel/chat/chat.dart | 1 - lib/screens/channel/video_chat.dart | 168 ++++++++++++++-------------- 2 files changed, 85 insertions(+), 84 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 0797022e..91d9542e 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -83,7 +83,6 @@ class Chat extends StatelessWidget { duration: const Duration(milliseconds: 200), child: EmoteMenu(chatStore: chatStore), ), - onEnd: () => chatStore.scrollController.jumpTo(0), ), ], ); diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index 8f4f74bb..0b554288 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -161,96 +161,98 @@ class _VideoChatState extends State { ], ); - return PiPSwitcher( - floating: _videoStore.floating, - childWhenEnabled: player, - childWhenDisabled: Scaffold( - body: OrientationBuilder( - builder: (context, orientation) { - // Scroll to bottom when summoning keyboard or rotating. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_chatStore.scrollController.hasClients) _chatStore.scrollController.jumpTo(0); - }); + final videoChat = Scaffold( + body: OrientationBuilder( + builder: (context, orientation) { + if (orientation == Orientation.landscape) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - if (orientation == Orientation.landscape) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + return Observer( + builder: (context) { + final landscapeChat = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, + curve: Curves.ease, + color: _chatStore.settings.fullScreen + ? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity) + : Theme.of(context).scaffoldBackgroundColor, + child: chat, + ); - return Observer( - builder: (context) { - final landscapeChat = AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: _chatStore.expandChat ? MediaQuery.of(context).size.width / 2 : MediaQuery.of(context).size.width * _chatStore.settings.chatWidth, - curve: Curves.ease, - 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, - ), + 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( - bottom: false, - child: settingsStore.showVideo - ? settingsStore.fullScreen - ? Stack( - children: [ - 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], - ) - : Column( - children: [appBar, Expanded(child: chat)], - ), - ), - ); - }, - ); - } - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - return SafeArea( - child: Column( - children: [ - Observer( - builder: (_) { - if (!settingsStore.showVideo) return appBar; - - return AspectRatio(aspectRatio: 16 / 9, child: video); - }, ), - Expanded(child: chat), - ], - ), + ); + + return ColoredBox( + color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor, + child: SafeArea( + bottom: false, + child: settingsStore.showVideo + ? settingsStore.fullScreen + ? Stack( + children: [ + 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], + ) + : Column( + children: [appBar, Expanded(child: chat)], + ), + ), + ); + }, ); - }, - ), + } + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + return SafeArea( + child: Column( + children: [ + Observer( + builder: (_) { + if (!settingsStore.showVideo) return appBar; + + return AspectRatio(aspectRatio: 16 / 9, child: video); + }, + ), + Expanded(child: chat), + ], + ), + ); + }, ), ); + + // If on Android, use PiPSwitcher to enable PiP functionality. + if (Platform.isAndroid) { + return PiPSwitcher( + floating: _videoStore.floating, + childWhenEnabled: player, + childWhenDisabled: videoChat, + ); + } + + return videoChat; } @override From 997e6dfe3ff63412843b9c6b3983026bc761080e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 01:57:58 -0400 Subject: [PATCH 07/28] Fix general settings not resetting --- lib/screens/settings/stores/settings_store.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 2e9360ae..c31d58cc 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -228,6 +228,7 @@ abstract class _SettingsStoreBase with Store { @action void resetAllSettings() { + resetGeneralSettings(); resetVideoSettings(); resetChatSettings(); resetOtherSettings(); From 717d989954b21cffb03ef7de46c915f36b624faf Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 16:17:15 -0400 Subject: [PATCH 08/28] Convert chat to from builder to `ListView` (#154) Since widgets are built bottom up in the chat, most of the benefits of the `ListView.builder` are likely null. --- lib/screens/channel/chat/chat.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 91d9542e..35aa32a2 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -35,16 +35,17 @@ class Chat extends StatelessWidget { style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize), child: Observer( builder: (context) { - return ListView.builder( + return ListView( reverse: true, padding: EdgeInsets.zero, addAutomaticKeepAlives: false, controller: chatStore.scrollController, - itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages.reversed.toList()[index], - chatStore: chatStore, - ), + children: chatStore.renderMessages.reversed + .map((e) => ChatMessage( + ircMessage: e, + chatStore: chatStore, + )) + .toList(), ); }, ), From 47894f4746221f33ade6b0cbb0eb37adec81433e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 16:25:34 -0400 Subject: [PATCH 09/28] Fix message buffer behavior (#154) --- lib/models/irc.dart | 36 ++++++++++-- lib/screens/channel/stores/chat_store.dart | 62 ++++++++++---------- lib/screens/channel/stores/chat_store.g.dart | 18 ------ 3 files changed, 63 insertions(+), 53 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index e38dc045..28b85bac 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -36,12 +36,17 @@ class IRCMessage { }); /// Returns a list of messages where the given CLEARCHAT message is applied. - static List clearChat({required List messages, required IRCMessage ircMessage}) { + static void clearChat({ + required List messages, + required List bufferedMessages, + required IRCMessage ircMessage, + }) { // If there is no message, it means that entire chat was cleared. if (ircMessage.message == null) { messages.clear(); + bufferedMessages.clear(); messages.add(IRCMessage.createNotice(message: 'Chat was cleared by a moderator')); - return messages; + return; } final bannedUser = ircMessage.message; @@ -60,11 +65,26 @@ class IRCMessage { } }); - return messages; + // Search the buffered messages for the banned/timed-out user. + bufferedMessages.asMap().forEach((i, bufferedMessage) { + if (bufferedMessage.user == bannedUser) { + // Mark the message for removal. + bufferedMessages[i].command = Command.clearChat; + + // If timed-out, indicate the duration. + if (banDuration != null) { + bufferedMessages[i].tags['ban-duration'] = banDuration; + } + } + }); } /// Returns a list of messages where the gievn CLEARMSG message is applied. - static List clearMessage({required List messages, required IRCMessage ircMessage}) { + static void clearMessage({ + required List messages, + required List bufferedMessages, + required IRCMessage ircMessage, + }) { final targetId = ircMessage.tags['target-msg-id']; // Search for the message associated with the ID and mark the message for deletion. @@ -75,7 +95,13 @@ class IRCMessage { } } - return messages; + // Search for the buffered message associated with the ID and mark the message for deletion. + for (var i = 0; i < bufferedMessages.length; i++) { + if (bufferedMessages[i].tags['id'] == targetId) { + bufferedMessages[i].command = Command.clearMessage; + break; + } + } } /// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage]. diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 2d7f27c0..d0cbdcbb 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -18,9 +18,8 @@ part 'chat_store.g.dart'; class ChatStore = ChatStoreBase with _$ChatStore; abstract class ChatStoreBase with Store { - static const _messageUpperLimit = 15000; - static const _messageLimit = 10000; - static const _renderMessageLimit = 100; + static const _messageLimit = 5000; + static const _renderMessageLimit = 88; /// The provided auth store to determine login status, get the token, and use the headers for requests. final AuthStore auth; @@ -69,8 +68,7 @@ abstract class ChatStoreBase with Store { final _messageBuffer = []; /// The list of chat messages to render and display. - @readonly - var _messages = ObservableList(); + final messages = ObservableList(); @computed List get renderMessages { @@ -80,10 +78,10 @@ abstract class ChatStoreBase with Store { // Else, when autoscroll is enabled, only show the first [_renderMessageLimit] messages. // This will improve performance by only rendering a limited amount of messages // instead of the entire history at all times. - if (!_autoScroll || _messages.length < _renderMessageLimit) { - return _messages; + if (!_autoScroll || messages.length < _renderMessageLimit) { + return messages; } else { - return _messages.sublist(_messages.length - _renderMessageLimit); + return messages.sublist(messages.length - _renderMessageLimit); } } @@ -131,7 +129,7 @@ abstract class ChatStoreBase with Store { (_) => _autoScroll, (_) { if (_messageBuffer.isNotEmpty) { - _messages.addAll(_messageBuffer); + messages.addAll(_messageBuffer); _messageBuffer.clear(); } }, @@ -140,10 +138,10 @@ abstract class ChatStoreBase with Store { assetsStore.init(); chatDetailsStore.updateChatters(); - _messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); + messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); if (settings.chatDelay > 0) { - _messages.add(IRCMessage.createNotice( + messages.add(IRCMessage.createNotice( message: 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...')); } @@ -193,7 +191,7 @@ abstract class ChatStoreBase with Store { // The IRC data can contain more than one message separated by CRLF. // To account for this, split by CRLF, then loop and process each message. for (final message in data.trimRight().split('\r\n')) { - // debugPrint(message); + debugPrint('$message\n'); if (message.startsWith('@')) { final parsedIRCMessage = IRCMessage.fromString(message, userLogin: auth.user.details?.login); @@ -207,17 +205,25 @@ abstract class ChatStoreBase with Store { case Command.notice: case Command.userNotice: if (_autoScroll) { - _messages.add(parsedIRCMessage); + messages.add(parsedIRCMessage); } else { // If audoscroll is disabled, buffer the message to prevent the chat from shifting. _messageBuffer.add(parsedIRCMessage); } break; case Command.clearChat: - _messages = IRCMessage.clearChat(messages: _messages, ircMessage: parsedIRCMessage).asObservable(); + IRCMessage.clearChat( + messages: messages, + bufferedMessages: _messageBuffer, + ircMessage: parsedIRCMessage, + ); break; case Command.clearMessage: - _messages = IRCMessage.clearMessage(messages: _messages, ircMessage: parsedIRCMessage).asObservable(); + IRCMessage.clearMessage( + messages: messages, + bufferedMessages: _messageBuffer, + ircMessage: parsedIRCMessage, + ); break; case Command.roomState: chatDetailsStore.roomState = chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage); @@ -225,7 +231,12 @@ abstract class ChatStoreBase with Store { case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); if (toSend != null) { - _messages.add(toSend!); + if (_autoScroll) { + messages.add(toSend!); + } else { + // If audoscroll is disabled, buffer the sent message to prevent the chat from shifting. + _messageBuffer.add(toSend!); + } toSend = null; } break; @@ -248,18 +259,12 @@ abstract class ChatStoreBase with Store { } // If the message limit is reached, remove the oldest message. - if (_messages.length >= _messageLimit) _messages.removeAt(0); - - // Remove messages when an upper limit is reached. - // This will prevent an infinite amount of messages building up when autoscroll is off. - if (_messages.length >= _messageUpperLimit) _messages.removeRange(0, 500); - - // Hard upper-limit of 5000 messages to prevent infinite messages being added when scrolling. + if (_autoScroll && messages.length >= _messageLimit) messages.removeRange(0, 500); } else if (message == 'PING :tmi.twitch.tv') { _channel?.sink.add('PONG :tmi.twitch.tv'); return; } else if (message.contains('Welcome, GLHF!')) { - _messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); + messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); getAssets(); @@ -309,7 +314,7 @@ abstract class ChatStoreBase with Store { if (_backoffTime > 0) { // Add notice that chat was disconnected and then wait the backoff time before reconnecting. final notice = 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; - _messages.add(IRCMessage.createNotice(message: notice)); + messages.add(IRCMessage.createNotice(message: notice)); } await Future.delayed(Duration(seconds: _backoffTime)); @@ -319,7 +324,7 @@ abstract class ChatStoreBase with Store { // Increment the retry count and attempt the reconnect. _retries++; - _messages.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...')); + messages.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...')); connectToChat(); }, ); @@ -353,7 +358,7 @@ abstract class ChatStoreBase with Store { if (message.isEmpty) return; if (_channel == null || _channel?.closeCode != null) { - _messages.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.')); + messages.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.')); } else { // Send the message to the IRC chat room. _channel?.sink.add('PRIVMSG #$channelName :$message'); @@ -376,9 +381,6 @@ abstract class ChatStoreBase with Store { // Clear the previous input in the TextField. textController.clear(); } - - // Scroll to the latest message after sending. - if (scrollController.hasClients) scrollController.jumpTo(0); } /// Adds the given [emote] to the chat textfield. diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index d3257900..5d38cf6c 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -17,24 +17,6 @@ mixin _$ChatStore on ChatStoreBase, Store { name: 'ChatStoreBase.renderMessages')) .value; - late final _$_messagesAtom = - Atom(name: 'ChatStoreBase._messages', context: context); - - ObservableList get messages { - _$_messagesAtom.reportRead(); - return super._messages; - } - - @override - ObservableList get _messages => messages; - - @override - set _messages(ObservableList value) { - _$_messagesAtom.reportWrite(value, super._messages, () { - super._messages = value; - }); - } - late final _$_autoScrollAtom = Atom(name: 'ChatStoreBase._autoScroll', context: context); From f87a140e36f3ab51b464054725e3daf539975fde Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 18:07:17 -0400 Subject: [PATCH 10/28] Fix chat resume scroll When messages are added at the same time as the resume scroll button is pressed, the scroll offset won't be at the edge. This commit adds an additional post frame callback so that we will also scroll to the bottom after the build is completed. --- lib/models/irc.dart | 4 ++-- lib/screens/channel/stores/chat_store.dart | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 28b85bac..d33292f8 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -35,7 +35,7 @@ class IRCMessage { this.mention, }); - /// Returns a list of messages where the given CLEARCHAT message is applied. + /// Applies the given CLEARCHAT message to the provided messages and buffer. static void clearChat({ required List messages, required List bufferedMessages, @@ -79,7 +79,7 @@ class IRCMessage { }); } - /// Returns a list of messages where the gievn CLEARMSG message is applied. + /// Applies the given CLEARMSG message to the provided messages and buffer. static void clearMessage({ required List messages, required List bufferedMessages, diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index d0cbdcbb..adfcf2eb 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -297,6 +297,11 @@ abstract class ChatStoreBase with Store { // Jump to the latest message (bottom of the list/chat). scrollController.jumpTo(0); + + // Add a post frame callback in the event a messages is added at the same time. + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.jumpTo(0); + }); } @action From c2320c845e8f661b00d4454e0a873ececa6971bd Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 20:03:42 -0400 Subject: [PATCH 11/28] Add `wakelock` package (#157) --- pubspec.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3bd686cb..ecf855a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,7 @@ environment: dependencies: flutter: sdk: flutter - json_annotation: ^4.5.0 + json_annotation: ^4.6.0 # The following adds the Cupertino Icons font to your application. @@ -55,6 +55,7 @@ dependencies: flutter_cache_manager: ^3.3.0 device_info_plus: ^4.0.0 floating: ^1.1.0 + wakelock: ^0.6.2 dev_dependencies: flutter_test: From 0028c960992d3f0a286c5203427dc3eaa6413a3f Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 20:04:02 -0400 Subject: [PATCH 12/28] Prevent sleep in chat only mode (#157) --- lib/screens/channel/stores/chat_store.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index adfcf2eb..2d45a287 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -10,6 +10,7 @@ import 'package:frosty/screens/channel/stores/chat_assets_store.dart'; import 'package:frosty/screens/channel/stores/chat_details_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:mobx/mobx.dart'; +import 'package:wakelock/wakelock.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; part 'chat_store.g.dart'; @@ -116,6 +117,9 @@ abstract class ChatStoreBase with Store { required this.channelId, required this.displayName, }) { + // Enable wakelock to prevent the chat from sleeping. + Wakelock.enable(); + // Create a reaction that will reconnect to chat when logging in or out. // Closing the channel will trigger a reconnect with the new credentials. reactions.add(reaction( @@ -425,5 +429,8 @@ abstract class ChatStoreBase with Store { assetsStore.dispose(); chatDetailsStore.dispose(); + + // Disable wakelock so that the sleep timer will function properly. + Wakelock.disable(); } } From 8984efa4ed465145de16227cc6e8d3cc8651f07c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 21:03:28 -0400 Subject: [PATCH 13/28] Add option to prevent chat sleep (#157) --- lib/screens/channel/stores/chat_store.dart | 2 +- .../settings/sections/chat_settings.dart | 22 ++++--- .../settings/stores/settings_store.dart | 18 ++++-- .../settings/stores/settings_store.g.dart | 58 +++++++++++++------ 4 files changed, 66 insertions(+), 34 deletions(-) diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 2d45a287..80f4a961 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -118,7 +118,7 @@ abstract class ChatStoreBase with Store { required this.displayName, }) { // Enable wakelock to prevent the chat from sleeping. - Wakelock.enable(); + if (settings.chatOnlyPreventSleep) Wakelock.enable(); // Create a reaction that will reconnect to chat when logging in or out. // Closing the channel will trigger a reconnect with the new credentials. diff --git a/lib/screens/settings/sections/chat_settings.dart b/lib/screens/settings/sections/chat_settings.dart index 9827a778..e9f7bcc3 100644 --- a/lib/screens/settings/sections/chat_settings.dart +++ b/lib/screens/settings/sections/chat_settings.dart @@ -40,14 +40,6 @@ 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 while typing.'), - value: settingsStore.autocomplete, - onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null, - ), - const SizedBox(height: 15.0), ListTile( title: Row( children: [ @@ -71,6 +63,20 @@ class _ChatSettingsState extends State { ], ), ), + SwitchListTile.adaptive( + isThreeLine: true, + title: const Text('Prevent sleep in chat-only mode'), + subtitle: const Text('Requires restarting the chat in order to take effect.'), + value: settingsStore.chatOnlyPreventSleep, + onChanged: !settingsStore.showVideo ? (newValue) => settingsStore.chatOnlyPreventSleep = newValue : null, + ), + SwitchListTile.adaptive( + isThreeLine: true, + title: const Text('Autocomplete'), + subtitle: const Text('Shows a bar that suggests matching emotes and mentions while typing.'), + value: settingsStore.autocomplete, + onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.autocomplete = newValue : null, + ), const SectionHeader( 'Layout', padding: sectionPadding, diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index c31d58cc..7a0cb183 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -85,8 +85,9 @@ abstract class _SettingsStoreBase with Store { } // * Chat Settings - static const defaultAutocomplete = true; static const defaultChatDelay = 0.0; + static const defaultChatOnlyPreventSleep = true; + static const defaultAutocomplete = true; static const defaultShowBottomBar = true; static const defaultLandscapeChatLeftSide = false; @@ -106,14 +107,18 @@ abstract class _SettingsStoreBase with Store { static const defaultMessageSpacing = 10.0; static const defaultFontSize = 12.0; - @JsonKey(defaultValue: defaultAutocomplete) - @observable - var autocomplete = defaultAutocomplete; - @JsonKey(defaultValue: defaultChatDelay) @observable var chatDelay = defaultChatDelay; + @JsonKey(defaultValue: defaultChatOnlyPreventSleep) + @observable + var chatOnlyPreventSleep = defaultChatOnlyPreventSleep; + + @JsonKey(defaultValue: defaultAutocomplete) + @observable + var autocomplete = defaultAutocomplete; + @JsonKey(defaultValue: defaultShowBottomBar) @observable var showBottomBar = defaultShowBottomBar; @@ -172,8 +177,9 @@ abstract class _SettingsStoreBase with Store { @action void resetChatSettings() { - autocomplete = defaultAutocomplete; chatDelay = defaultChatDelay; + chatOnlyPreventSleep = defaultChatOnlyPreventSleep; + autocomplete = defaultAutocomplete; showBottomBar = defaultShowBottomBar; landscapeChatLeftSide = defaultLandscapeChatLeftSide; chatWidth = defaultChatWidth; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 54e4b839..40304e30 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -20,8 +20,9 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..pictureInPicture = json['pictureInPicture'] as bool? ?? false ..overlayOpacity = (json['overlayOpacity'] as num?)?.toDouble() ?? 0.5 - ..autocomplete = json['autocomplete'] as bool? ?? true ..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0 + ..chatOnlyPreventSleep = json['chatOnlyPreventSleep'] as bool? ?? true + ..autocomplete = json['autocomplete'] as bool? ?? true ..showBottomBar = json['showBottomBar'] as bool? ?? true ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3 @@ -58,8 +59,9 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'toggleableOverlay': instance.toggleableOverlay, 'pictureInPicture': instance.pictureInPicture, 'overlayOpacity': instance.overlayOpacity, - 'autocomplete': instance.autocomplete, 'chatDelay': instance.chatDelay, + 'chatOnlyPreventSleep': instance.chatOnlyPreventSleep, + 'autocomplete': instance.autocomplete, 'showBottomBar': instance.showBottomBar, 'landscapeChatLeftSide': instance.landscapeChatLeftSide, 'chatWidth': instance.chatWidth, @@ -260,22 +262,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } - late final _$autocompleteAtom = - Atom(name: '_SettingsStoreBase.autocomplete', context: context); - - @override - bool get autocomplete { - _$autocompleteAtom.reportRead(); - return super.autocomplete; - } - - @override - set autocomplete(bool value) { - _$autocompleteAtom.reportWrite(value, super.autocomplete, () { - super.autocomplete = value; - }); - } - late final _$chatDelayAtom = Atom(name: '_SettingsStoreBase.chatDelay', context: context); @@ -292,6 +278,39 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$chatOnlyPreventSleepAtom = + Atom(name: '_SettingsStoreBase.chatOnlyPreventSleep', context: context); + + @override + bool get chatOnlyPreventSleep { + _$chatOnlyPreventSleepAtom.reportRead(); + return super.chatOnlyPreventSleep; + } + + @override + set chatOnlyPreventSleep(bool value) { + _$chatOnlyPreventSleepAtom.reportWrite(value, super.chatOnlyPreventSleep, + () { + super.chatOnlyPreventSleep = value; + }); + } + + late final _$autocompleteAtom = + Atom(name: '_SettingsStoreBase.autocomplete', context: context); + + @override + bool get autocomplete { + _$autocompleteAtom.reportRead(); + return super.autocomplete; + } + + @override + set autocomplete(bool value) { + _$autocompleteAtom.reportWrite(value, super.autocomplete, () { + super.autocomplete = value; + }); + } + late final _$showBottomBarAtom = Atom(name: '_SettingsStoreBase.showBottomBar', context: context); @@ -667,8 +686,9 @@ showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, pictureInPicture: ${pictureInPicture}, overlayOpacity: ${overlayOpacity}, -autocomplete: ${autocomplete}, chatDelay: ${chatDelay}, +chatOnlyPreventSleep: ${chatOnlyPreventSleep}, +autocomplete: ${autocomplete}, showBottomBar: ${showBottomBar}, landscapeChatLeftSide: ${landscapeChatLeftSide}, chatWidth: ${chatWidth}, From cec8323df6dfeb2413ac7a61deed9dfacf0954ac Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Wed, 27 Jul 2022 22:54:42 -0400 Subject: [PATCH 14/28] Add options to override landscape cutout (#162) --- lib/screens/channel/video_chat.dart | 5 ++++ .../settings/sections/chat_settings.dart | 17 +++++++++++ .../settings/stores/settings_store.dart | 12 ++++++++ .../settings/stores/settings_store.g.dart | 29 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index 0b554288..0ccf3b5b 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -195,6 +195,11 @@ class _VideoChatState extends State { color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor, child: SafeArea( bottom: false, + left: + (settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.left) ? false : true, + right: (settingsStore.landscapeCutout == LandscapeCutoutType.both || settingsStore.landscapeCutout == LandscapeCutoutType.right) + ? false + : true, child: settingsStore.showVideo ? settingsStore.fullScreen ? Stack( diff --git a/lib/screens/settings/sections/chat_settings.dart b/lib/screens/settings/sections/chat_settings.dart index e9f7bcc3..52026702 100644 --- a/lib/screens/settings/sections/chat_settings.dart +++ b/lib/screens/settings/sections/chat_settings.dart @@ -21,6 +21,8 @@ class _ChatSettingsState extends State { @override Widget build(BuildContext context) { + const landscapeCutouts = ['None', 'Left', 'Right', 'Both']; + const timestamps = ['Disabled', '12-hour', '24-hour']; const sectionPadding = EdgeInsets.only(left: 16.0, bottom: 5.0, top: 30.0); @@ -92,6 +94,21 @@ class _ChatSettingsState extends State { value: settingsStore.landscapeChatLeftSide, onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue, ), + ListTile( + isThreeLine: true, + title: const Text('Landscape fill cutout side'), + subtitle: const Text('Overrides and fills the available space in the display cutout/notch.'), + trailing: DropdownButton( + value: settingsStore.landscapeCutout, + onChanged: (LandscapeCutoutType? newValue) => settingsStore.landscapeCutout = newValue!, + items: LandscapeCutoutType.values + .map((LandscapeCutoutType value) => DropdownMenuItem( + value: value, + child: Text(landscapeCutouts[value.index]), + )) + .toList(), + ), + ), const SizedBox(height: 15.0), ListTile( title: Row( diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 7a0cb183..81f87159 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -88,6 +88,7 @@ abstract class _SettingsStoreBase with Store { static const defaultChatDelay = 0.0; static const defaultChatOnlyPreventSleep = true; static const defaultAutocomplete = true; + static const defaultLandscapeCutout = LandscapeCutoutType.none; static const defaultShowBottomBar = true; static const defaultLandscapeChatLeftSide = false; @@ -127,6 +128,10 @@ abstract class _SettingsStoreBase with Store { @observable var landscapeChatLeftSide = defaultLandscapeChatLeftSide; + @JsonKey(defaultValue: defaultLandscapeCutout) + @observable + var landscapeCutout = defaultLandscapeCutout; + @JsonKey(defaultValue: defaultChatWidth) @observable var chatWidth = defaultChatWidth; @@ -254,3 +259,10 @@ enum TimestampType { twelve, twentyFour, } + +enum LandscapeCutoutType { + none, + left, + right, + both, +} diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 40304e30..3b8f015e 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -25,6 +25,9 @@ SettingsStore _$SettingsStoreFromJson(Map json) => ..autocomplete = json['autocomplete'] as bool? ?? true ..showBottomBar = json['showBottomBar'] as bool? ?? true ..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false + ..landscapeCutout = $enumDecodeNullable( + _$LandscapeCutoutTypeEnumMap, json['landscapeCutout']) ?? + LandscapeCutoutType.none ..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.3 ..fullScreenChatOverlayOpacity = (json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5 @@ -64,6 +67,8 @@ Map _$SettingsStoreToJson(SettingsStore instance) => 'autocomplete': instance.autocomplete, 'showBottomBar': instance.showBottomBar, 'landscapeChatLeftSide': instance.landscapeChatLeftSide, + 'landscapeCutout': + _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!, 'chatWidth': instance.chatWidth, 'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity, 'showZeroWidth': instance.showZeroWidth, @@ -89,6 +94,13 @@ const _$ThemeTypeEnumMap = { ThemeType.black: 'black', }; +const _$LandscapeCutoutTypeEnumMap = { + LandscapeCutoutType.none: 'none', + LandscapeCutoutType.left: 'left', + LandscapeCutoutType.right: 'right', + LandscapeCutoutType.both: 'both', +}; + const _$TimestampTypeEnumMap = { TimestampType.disabled: 'disabled', TimestampType.twelve: 'twelve', @@ -344,6 +356,22 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$landscapeCutoutAtom = + Atom(name: '_SettingsStoreBase.landscapeCutout', context: context); + + @override + LandscapeCutoutType get landscapeCutout { + _$landscapeCutoutAtom.reportRead(); + return super.landscapeCutout; + } + + @override + set landscapeCutout(LandscapeCutoutType value) { + _$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () { + super.landscapeCutout = value; + }); + } + late final _$chatWidthAtom = Atom(name: '_SettingsStoreBase.chatWidth', context: context); @@ -691,6 +719,7 @@ chatOnlyPreventSleep: ${chatOnlyPreventSleep}, autocomplete: ${autocomplete}, showBottomBar: ${showBottomBar}, landscapeChatLeftSide: ${landscapeChatLeftSide}, +landscapeCutout: ${landscapeCutout}, chatWidth: ${chatWidth}, fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity}, showZeroWidth: ${showZeroWidth}, From 84c814d1f9df20474359556377c195fc9799c3bc Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 01:25:56 -0400 Subject: [PATCH 15/28] Organize/document video store Also fixed webview not refershing when toggling overlay in settings --- lib/screens/channel/stores/video_store.dart | 289 ++++++++++-------- lib/screens/channel/stores/video_store.g.dart | 69 +++-- 2 files changed, 222 insertions(+), 136 deletions(-) diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index 7233c858..ee5ea23b 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -19,40 +19,29 @@ class VideoStore = VideoStoreBase with _$VideoStore; abstract class VideoStoreBase with Store { final TwitchApi twitchApi; - WebViewController? controller; + /// The userlogin of the current channel. + final String userLogin; - late Timer _overlayTimer; + final AuthStore authStore; - Timer? sleepTimer; + final SettingsStore settingsStore; + /// The [Floating] instance used for initating PiP on Android. final floating = Floating(); - @observable - var sleepHours = 0; + /// The webview controller used for injecting JavaScript to control the webview and video player. + WebViewController? controller; - @observable - var sleepMinutes = 0; + /// The current timer for the sleep timer if active. + Timer? sleepTimer; - @observable - var timeRemaining = const Duration(); + /// The timer that handles hiding the overlay automatically + late Timer _overlayTimer; - @readonly - var _paused = true; - - @readonly - var _overlayVisible = true; - - @readonly - var _isIPad = false; - - @readonly - StreamTwitch? _streamInfo; - - @computed - String get videoUrl => settingsStore.showOverlay - ? 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty' - : 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; + /// Disposes the overlay reactions. + late final ReactionDisposer _disposeOverlayReaction; + /// The JavaScript channels used to communicate play/pause from the webview to Flutter. late final javascriptChannels = { JavascriptChannel( name: 'Pause', @@ -68,95 +57,7 @@ abstract class VideoStoreBase with Store { ), }; - final String userLogin; - final AuthStore authStore; - final SettingsStore settingsStore; - - VideoStoreBase({ - required this.userLogin, - required this.twitchApi, - required this.authStore, - required this.settingsStore, - }) { - _overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false); - updateStreamInfo(); - } - - @action - void handlePausePlay() { - try { - if (_paused) { - controller?.runJavascript('document.getElementsByTagName("video")[0].play();'); - } else { - controller?.runJavascript('document.getElementsByTagName("video")[0].pause();'); - } - - _paused = !_paused; - } catch (e) { - debugPrint(e.toString()); - } - } - - @action - void handleVideoTap() { - _overlayTimer.cancel(); - - if (_overlayVisible) { - _overlayVisible = false; - } else { - updateStreamInfo(); - - _overlayVisible = true; - _overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); - } - } - - @action - Future updateStreamInfo() async { - try { - _streamInfo = await twitchApi.getStream(userLogin: userLogin, headers: authStore.headersTwitch); - } catch (e) { - debugPrint(e.toString()); - - _overlayTimer.cancel(); - _streamInfo = null; - _paused = true; - } - } - - @action - void handleExpand() { - settingsStore.expandInfo = !settingsStore.expandInfo; - - _overlayTimer.cancel(); - _overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); - } - - @action - Future handleToggleOverlay() async { - if (settingsStore.toggleableOverlay) { - HapticFeedback.mediumImpact(); - - settingsStore.showOverlay = !settingsStore.showOverlay; - - await controller?.loadUrl(videoUrl); - - if (settingsStore.showOverlay) { - _overlayVisible = true; - - _overlayTimer.cancel(); - _overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false); - } - } - } - - @action - void handleRefresh() { - HapticFeedback.lightImpact(); - controller?.reload(); - updateStreamInfo(); - } - + /// Used for preventing accidental navigation in the webview. FutureOr handleNavigation(NavigationRequest navigation) { if (navigation.url.startsWith('https://player.twitch.tv')) { return NavigationDecision.navigate; @@ -164,8 +65,64 @@ abstract class VideoStoreBase with Store { return NavigationDecision.prevent; } + /// The amount of hours the sleep timer is set to. + @observable + var sleepHours = 0; + + /// The amount of minutes the sleep timer is set to. + @observable + var sleepMinutes = 0; + + /// The time remaining for the sleep timer. + @observable + var timeRemaining = const Duration(); + + /// If the video is currently paused. + /// + /// Does not pause or play the video, only used for rendering state of the overlay. + @readonly + var _paused = true; + + /// If the overlay is should be visible. + @readonly + var _overlayVisible = true; + + /// If the current device is iPad. + @readonly + var _isIPad = false; + + /// The current stream info, used for displaying relevant info on the overlay. + @readonly + StreamTwitch? _streamInfo; + + /// The video URL to use for the webview. Controls will be disabled when custom overlay is enabled. + @computed + String get videoUrl => settingsStore.showOverlay + ? 'https://player.twitch.tv/?channel=$userLogin&controls=false&muted=false&parent=frosty' + : 'https://player.twitch.tv/?channel=$userLogin&muted=false&parent=frosty'; + + VideoStoreBase({ + required this.userLogin, + required this.twitchApi, + required this.authStore, + required this.settingsStore, + }) { + // Initialize tthe [_overlayTimer] to hide the overlay automatically after 3 seconds. + _overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false); + + // Initialize a reaction that will reload the webview whenever the overlay is toggled. + _disposeOverlayReaction = reaction( + (_) => settingsStore.showOverlay, + (_) => controller?.loadUrl(videoUrl), + ); + + updateStreamInfo(); + } + + /// Initializes the video webview. @action Future initVideo() async { + // Add event listeners to notify the JavaScript channels when the video plays and pauses. 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"));'); @@ -187,20 +144,94 @@ abstract class VideoStoreBase with Store { } } - void requestPictureInPicture() { + /// Play or pause the video depending on the current state of [_paused]. + @action + void handlePausePlay() { try { - if (Platform.isAndroid) { - floating.enable(); - } else if (Platform.isIOS) { - controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();'); + if (_paused) { + controller?.runJavascript('document.getElementsByTagName("video")[0].play();'); + } else { + controller?.runJavascript('document.getElementsByTagName("video")[0].pause();'); } + + _paused = !_paused; } catch (e) { debugPrint(e.toString()); } } + /// Called whenever the video/overlay is tapped. + @action + void handleVideoTap() { + _overlayTimer.cancel(); + + if (_overlayVisible) { + _overlayVisible = false; + } else { + updateStreamInfo(); + + _overlayVisible = true; + _overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); + } + } + + /// Updates the stream info from the Twitch API. + /// + /// If the stream is offline, disables the overlay. + @action + Future updateStreamInfo() async { + try { + _streamInfo = await twitchApi.getStream(userLogin: userLogin, headers: authStore.headersTwitch); + } catch (e) { + debugPrint(e.toString()); + + _overlayTimer.cancel(); + _streamInfo = null; + _paused = true; + } + } + + /// Handles toggling "minimal mode" on the overlay. + /// + /// "Minimal mode" is when only the channel name is visible on the bottom-left of the overlay. + @action + void handleExpand() { + settingsStore.expandInfo = !settingsStore.expandInfo; + + _overlayTimer.cancel(); + _overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); + } + + /// Handles the toggle overlay options. + /// + /// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay. + @action + void handleToggleOverlay() { + if (settingsStore.toggleableOverlay) { + HapticFeedback.mediumImpact(); + + settingsStore.showOverlay = !settingsStore.showOverlay; + + if (settingsStore.showOverlay) { + _overlayVisible = true; + + _overlayTimer.cancel(); + _overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false); + } + } + } + + /// Refreshes the stream webview and updates the stream info. + @action + void handleRefresh() { + HapticFeedback.lightImpact(); + controller?.reload(); + updateStreamInfo(); + } + /// Updates the sleep timer with [sleepHours] and [sleepMinutes]. /// Calls [onTimerFinished] when the sleep timer completes. + @action void updateSleepTimer({required void Function() onTimerFinished}) { // If hours and minutes are 0, do nothing. if (sleepHours == 0 && sleepMinutes == 0) return; @@ -233,12 +264,32 @@ abstract class VideoStoreBase with Store { } /// Cancels the sleep timer and resets the time remaining. + @action void cancelSleepTimer() { sleepTimer?.cancel(); timeRemaining = const Duration(); } + /// Initiate picture in picture if available. + /// + /// On iOS, this will utilize the web picture-in-picture API. + /// On Android, this will utilize the native Android PiP API. + void requestPictureInPicture() { + try { + if (Platform.isAndroid) { + floating.enable(); + } else if (Platform.isIOS) { + controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();'); + } + } catch (e) { + debugPrint(e.toString()); + } + } + + @action void dispose() { + controller?.runJavascript('document.getElementsByTagName("video")[0].pause();'); + _disposeOverlayReaction(); floating.dispose(); sleepTimer?.cancel(); } diff --git a/lib/screens/channel/stores/video_store.g.dart b/lib/screens/channel/stores/video_store.g.dart index bcd5bebd..43d75aaf 100644 --- a/lib/screens/channel/stores/video_store.g.dart +++ b/lib/screens/channel/stores/video_store.g.dart @@ -137,23 +137,6 @@ mixin _$VideoStore on VideoStoreBase, Store { }); } - late final _$updateStreamInfoAsyncAction = - AsyncAction('VideoStoreBase.updateStreamInfo', context: context); - - @override - Future updateStreamInfo() { - return _$updateStreamInfoAsyncAction.run(() => super.updateStreamInfo()); - } - - late final _$handleToggleOverlayAsyncAction = - AsyncAction('VideoStoreBase.handleToggleOverlay', context: context); - - @override - Future handleToggleOverlay() { - return _$handleToggleOverlayAsyncAction - .run(() => super.handleToggleOverlay()); - } - late final _$initVideoAsyncAction = AsyncAction('VideoStoreBase.initVideo', context: context); @@ -162,6 +145,14 @@ mixin _$VideoStore on VideoStoreBase, Store { return _$initVideoAsyncAction.run(() => super.initVideo()); } + late final _$updateStreamInfoAsyncAction = + AsyncAction('VideoStoreBase.updateStreamInfo', context: context); + + @override + Future updateStreamInfo() { + return _$updateStreamInfoAsyncAction.run(() => super.updateStreamInfo()); + } + late final _$VideoStoreBaseActionController = ActionController(name: 'VideoStoreBase', context: context); @@ -198,6 +189,17 @@ mixin _$VideoStore on VideoStoreBase, Store { } } + @override + void handleToggleOverlay() { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase.handleToggleOverlay'); + try { + return super.handleToggleOverlay(); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void handleRefresh() { final _$actionInfo = _$VideoStoreBaseActionController.startAction( @@ -209,6 +211,39 @@ mixin _$VideoStore on VideoStoreBase, Store { } } + @override + void updateSleepTimer({required void Function() onTimerFinished}) { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase.updateSleepTimer'); + try { + return super.updateSleepTimer(onTimerFinished: onTimerFinished); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void cancelSleepTimer() { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase.cancelSleepTimer'); + try { + return super.cancelSleepTimer(); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void dispose() { + final _$actionInfo = _$VideoStoreBaseActionController.startAction( + name: 'VideoStoreBase.dispose'); + try { + return super.dispose(); + } finally { + _$VideoStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' From 2ab0d631b197247fc3054065e5fb09d05385f93e Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 01:33:53 -0400 Subject: [PATCH 16/28] Restire MobX actions on lists I previously had to remove the `@action` annotation on some methods due to an update in the MobX package. The change has since been reverted so I've decided to add back the annotations. --- lib/screens/home/stores/categories_store.dart | 1 + lib/screens/home/stores/categories_store.g.dart | 8 ++++++++ lib/screens/home/stores/list_store.dart | 1 + lib/screens/home/stores/list_store.g.dart | 8 ++++++++ 4 files changed, 18 insertions(+) diff --git a/lib/screens/home/stores/categories_store.dart b/lib/screens/home/stores/categories_store.dart index 734a57a2..8125c11b 100644 --- a/lib/screens/home/stores/categories_store.dart +++ b/lib/screens/home/stores/categories_store.dart @@ -40,6 +40,7 @@ abstract class CategoriesStoreBase with Store { } // Fetches the top categories based on the current cursor. + @action Future getCategories() async { _isLoading = true; diff --git a/lib/screens/home/stores/categories_store.g.dart b/lib/screens/home/stores/categories_store.g.dart index f54a3dc4..30e0a904 100644 --- a/lib/screens/home/stores/categories_store.g.dart +++ b/lib/screens/home/stores/categories_store.g.dart @@ -70,6 +70,14 @@ mixin _$CategoriesStore on CategoriesStoreBase, Store { }); } + late final _$getCategoriesAsyncAction = + AsyncAction('CategoriesStoreBase.getCategories', context: context); + + @override + Future getCategories() { + return _$getCategoriesAsyncAction.run(() => super.getCategories()); + } + late final _$CategoriesStoreBaseActionController = ActionController(name: 'CategoriesStoreBase', context: context); diff --git a/lib/screens/home/stores/list_store.dart b/lib/screens/home/stores/list_store.dart index 826ad023..12aacff8 100644 --- a/lib/screens/home/stores/list_store.dart +++ b/lib/screens/home/stores/list_store.dart @@ -78,6 +78,7 @@ abstract class ListStoreBase with Store { } /// Fetches the streams based on the type and current cursor. + @action Future getStreams() async { _isLoading = true; diff --git a/lib/screens/home/stores/list_store.g.dart b/lib/screens/home/stores/list_store.g.dart index c4e0db7e..cf486ad0 100644 --- a/lib/screens/home/stores/list_store.g.dart +++ b/lib/screens/home/stores/list_store.g.dart @@ -93,6 +93,14 @@ mixin _$ListStore on ListStoreBase, Store { }); } + late final _$getStreamsAsyncAction = + AsyncAction('ListStoreBase.getStreams', context: context); + + @override + Future getStreams() { + return _$getStreamsAsyncAction.run(() => super.getStreams()); + } + late final _$ListStoreBaseActionController = ActionController(name: 'ListStoreBase', context: context); From 334f353af16b7a2820e9c18ee957cb6cd28988eb Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 15:30:23 -0400 Subject: [PATCH 17/28] Batch rendering in chat Instead of re-rendering the chat every single time a message comes in, I've made it so that messages are batche and then rendered all at once every 200ms. As a result, the amount of renders are cut from hundreds per second to just 5. The same technique is used in native Twitch chat. A side effect is that chat is now a little more readable. Instead of every single message flying in, they're now grouped with much less jumps. Message limit has also been increased to 10000 to allow for greater chat with minimal performance cost. Also reverts 717d989. --- lib/screens/channel/chat/chat.dart | 13 ++-- lib/screens/channel/stores/chat_store.dart | 77 +++++++++----------- lib/screens/channel/stores/chat_store.g.dart | 11 +++ 3 files changed, 53 insertions(+), 48 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 35aa32a2..91d9542e 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -35,17 +35,16 @@ class Chat extends StatelessWidget { style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize), child: Observer( builder: (context) { - return ListView( + return ListView.builder( reverse: true, padding: EdgeInsets.zero, addAutomaticKeepAlives: false, controller: chatStore.scrollController, - children: chatStore.renderMessages.reversed - .map((e) => ChatMessage( - ircMessage: e, - chatStore: chatStore, - )) - .toList(), + itemCount: chatStore.renderMessages.length, + itemBuilder: (context, index) => ChatMessage( + ircMessage: chatStore.renderMessages.reversed.toList()[index], + chatStore: chatStore, + ), ); }, ), diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 80f4a961..5a55e20e 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -19,8 +19,11 @@ part 'chat_store.g.dart'; class ChatStore = ChatStoreBase with _$ChatStore; abstract class ChatStoreBase with Store { - static const _messageLimit = 5000; - static const _renderMessageLimit = 88; + /// The total maximum amount of messages in chat. + static const _messageLimit = 10000; + + /// The maximum ammount of messages to render when autoscroll is enabled. + static const _renderMessageLimit = 100; /// The provided auth store to determine login status, get the token, and use the headers for requests. final AuthStore auth; @@ -71,19 +74,22 @@ abstract class ChatStoreBase with Store { /// The list of chat messages to render and display. final messages = ObservableList(); + /// The list of reaction disposer functions that will be used later when disposing. + final reactions = []; + + /// The periodic timer used for batching chat message re-renders. + late final Timer _messageBufferTimer; + @computed List get renderMessages { // If autoscroll is disabled, render ALL messages in chat. // The second condition is to prevent an out of index error with sublist. - // - // Else, when autoscroll is enabled, only show the first [_renderMessageLimit] messages. + if (!_autoScroll || messages.length < _renderMessageLimit) return messages; + + // When autoscroll is enabled, only show the first [_renderMessageLimit] messages. // This will improve performance by only rendering a limited amount of messages // instead of the entire history at all times. - if (!_autoScroll || messages.length < _renderMessageLimit) { - return messages; - } else { - return messages.sublist(messages.length - _renderMessageLimit); - } + return messages.sublist(messages.length - _renderMessageLimit); } /// If the chat should automatically scroll/jump to the latest message. @@ -106,8 +112,6 @@ abstract class ChatStoreBase with Store { @observable var expandChat = false; - final reactions = []; - ChatStoreBase({ required this.auth, required this.chatDetailsStore, @@ -127,25 +131,16 @@ abstract class ChatStoreBase with Store { (_) => _channel?.sink.close(1001), )); - // Create a reaction that will add buffered messages to the chat - // when autoscroll is changed. - reactions.add(reaction( - (_) => _autoScroll, - (_) { - if (_messageBuffer.isNotEmpty) { - messages.addAll(_messageBuffer); - _messageBuffer.clear(); - } - }, - )); + // Create a timer that will add messages from the buffer every 200 milliseconds. + _messageBufferTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) => addMessages()); assetsStore.init(); chatDetailsStore.updateChatters(); - messages.add(IRCMessage.createNotice(message: 'Connecting to chat...')); + _messageBuffer.add(IRCMessage.createNotice(message: 'Connecting to chat...')); if (settings.chatDelay > 0) { - messages.add(IRCMessage.createNotice( + _messageBuffer.add(IRCMessage.createNotice( message: 'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...')); } @@ -195,7 +190,7 @@ abstract class ChatStoreBase with Store { // The IRC data can contain more than one message separated by CRLF. // To account for this, split by CRLF, then loop and process each message. for (final message in data.trimRight().split('\r\n')) { - debugPrint('$message\n'); + // debugPrint('$message\n'); if (message.startsWith('@')) { final parsedIRCMessage = IRCMessage.fromString(message, userLogin: auth.user.details?.login); @@ -208,12 +203,7 @@ abstract class ChatStoreBase with Store { case Command.privateMessage: case Command.notice: case Command.userNotice: - if (_autoScroll) { - messages.add(parsedIRCMessage); - } else { - // If audoscroll is disabled, buffer the message to prevent the chat from shifting. - _messageBuffer.add(parsedIRCMessage); - } + _messageBuffer.add(parsedIRCMessage); break; case Command.clearChat: IRCMessage.clearChat( @@ -235,12 +225,7 @@ abstract class ChatStoreBase with Store { case Command.userState: _userState = _userState.fromIRCMessage(parsedIRCMessage); if (toSend != null) { - if (_autoScroll) { - messages.add(toSend!); - } else { - // If audoscroll is disabled, buffer the sent message to prevent the chat from shifting. - _messageBuffer.add(toSend!); - } + _messageBuffer.add(toSend!); toSend = null; } break; @@ -268,7 +253,7 @@ abstract class ChatStoreBase with Store { _channel?.sink.add('PONG :tmi.twitch.tv'); return; } else if (message.contains('Welcome, GLHF!')) { - messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); + _messageBuffer.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); getAssets(); @@ -323,7 +308,7 @@ abstract class ChatStoreBase with Store { if (_backoffTime > 0) { // Add notice that chat was disconnected and then wait the backoff time before reconnecting. final notice = 'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; - messages.add(IRCMessage.createNotice(message: notice)); + _messageBuffer.add(IRCMessage.createNotice(message: notice)); } await Future.delayed(Duration(seconds: _backoffTime)); @@ -333,7 +318,7 @@ abstract class ChatStoreBase with Store { // Increment the retry count and attempt the reconnect. _retries++; - messages.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...')); + _messageBuffer.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...')); connectToChat(); }, ); @@ -360,6 +345,14 @@ abstract class ChatStoreBase with Store { } } + @action + void addMessages() { + if (!_autoScroll || _messageBuffer.isEmpty) return; + + messages.addAll(_messageBuffer); + _messageBuffer.clear(); + } + /// Sends the given string message by the logged-in user and adds it to [_messages]. @action void sendMessage(String message) { @@ -367,7 +360,7 @@ abstract class ChatStoreBase with Store { if (message.isEmpty) return; if (_channel == null || _channel?.closeCode != null) { - messages.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.')); + _messageBuffer.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.')); } else { // Send the message to the IRC chat room. _channel?.sink.add('PRIVMSG #$channelName :$message'); @@ -416,6 +409,8 @@ abstract class ChatStoreBase with Store { /// Closes and disposes all the channels and controllers used by the store. void dispose() { + _messageBufferTimer.cancel(); + _channel?.sink.close(1001); _channel = null; diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index 5d38cf6c..81e4d79b 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -169,6 +169,17 @@ mixin _$ChatStore on ChatStoreBase, Store { } } + @override + void addMessages() { + final _$actionInfo = _$ChatStoreBaseActionController.startAction( + name: 'ChatStoreBase.addMessages'); + try { + return super.addMessages(); + } finally { + _$ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void sendMessage(String message) { final _$actionInfo = _$ChatStoreBaseActionController.startAction( From ffd3d9c5eadeff2db8e2ad7476b0d95361a07f43 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 18:06:24 -0400 Subject: [PATCH 18/28] Add toggle fullscreen with double tap (#163) --- lib/screens/channel/video_chat.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/screens/channel/video_chat.dart b/lib/screens/channel/video_chat.dart index 0ccf3b5b..beddac06 100644 --- a/lib/screens/channel/video_chat.dart +++ b/lib/screens/channel/video_chat.dart @@ -89,6 +89,12 @@ class _VideoChatState extends State { final overlay = GestureDetector( onLongPress: _videoStore.handleToggleOverlay, + onDoubleTap: () { + if (MediaQuery.of(context).orientation == Orientation.portrait) return; + + // Double tap to toggle fullscreen in landscape mode. + settingsStore.fullScreen = !settingsStore.fullScreen; + }, onTap: () { if (_chatStore.assetsStore.showEmoteMenu) { _chatStore.assetsStore.showEmoteMenu = false; From 1e557e0001649d371a2319b9fd84c7c70a0e0437 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 18:08:03 -0400 Subject: [PATCH 19/28] Fix video overlay not showing on first start (iOS) --- lib/screens/channel/stores/video_store.dart | 43 +++++++++---------- lib/screens/channel/stores/video_store.g.dart | 11 ----- 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/lib/screens/channel/stores/video_store.dart b/lib/screens/channel/stores/video_store.dart index ee5ea23b..fbaa74cd 100644 --- a/lib/screens/channel/stores/video_store.dart +++ b/lib/screens/channel/stores/video_store.dart @@ -26,7 +26,7 @@ abstract class VideoStoreBase with Store { final SettingsStore settingsStore; - /// The [Floating] instance used for initating PiP on Android. + /// The [Floating] instance used for initiating PiP on Android. final floating = Floating(); /// The webview controller used for injecting JavaScript to control the webview and video player. @@ -44,11 +44,11 @@ abstract class VideoStoreBase with Store { /// The JavaScript channels used to communicate play/pause from the webview to Flutter. late final javascriptChannels = { JavascriptChannel( - name: 'Pause', + name: 'VideoPause', onMessageReceived: (message) => _paused = true, ), JavascriptChannel( - name: 'Play', + name: 'VideoPlaying', onMessageReceived: (message) { _paused = false; controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;'); @@ -107,8 +107,8 @@ abstract class VideoStoreBase with Store { required this.authStore, required this.settingsStore, }) { - // Initialize tthe [_overlayTimer] to hide the overlay automatically after 3 seconds. - _overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false); + // Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds. + _overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false); // Initialize a reaction that will reload the webview whenever the overlay is toggled. _disposeOverlayReaction = reaction( @@ -124,8 +124,8 @@ abstract class VideoStoreBase with Store { Future initVideo() async { // Add event listeners to notify the JavaScript channels when the video plays and pauses. 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"));'); + controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));'); + controller?.runJavascript('document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));'); } catch (e) { debugPrint(e.toString()); } @@ -144,22 +144,6 @@ abstract class VideoStoreBase with Store { } } - /// Play or pause the video depending on the current state of [_paused]. - @action - void handlePausePlay() { - try { - if (_paused) { - controller?.runJavascript('document.getElementsByTagName("video")[0].play();'); - } else { - controller?.runJavascript('document.getElementsByTagName("video")[0].pause();'); - } - - _paused = !_paused; - } catch (e) { - debugPrint(e.toString()); - } - } - /// Called whenever the video/overlay is tapped. @action void handleVideoTap() { @@ -270,6 +254,19 @@ abstract class VideoStoreBase with Store { timeRemaining = const Duration(); } + /// Play or pause the video depending on the current state of [_paused]. + void handlePausePlay() { + try { + if (_paused) { + controller?.runJavascript('document.getElementsByTagName("video")[0].play();'); + } else { + controller?.runJavascript('document.getElementsByTagName("video")[0].pause();'); + } + } catch (e) { + debugPrint(e.toString()); + } + } + /// Initiate picture in picture if available. /// /// On iOS, this will utilize the web picture-in-picture API. diff --git a/lib/screens/channel/stores/video_store.g.dart b/lib/screens/channel/stores/video_store.g.dart index 43d75aaf..8a8a0df8 100644 --- a/lib/screens/channel/stores/video_store.g.dart +++ b/lib/screens/channel/stores/video_store.g.dart @@ -156,17 +156,6 @@ mixin _$VideoStore on VideoStoreBase, Store { late final _$VideoStoreBaseActionController = ActionController(name: 'VideoStoreBase', context: context); - @override - void handlePausePlay() { - final _$actionInfo = _$VideoStoreBaseActionController.startAction( - name: 'VideoStoreBase.handlePausePlay'); - try { - return super.handlePausePlay(); - } finally { - _$VideoStoreBaseActionController.endAction(_$actionInfo); - } - } - @override void handleVideoTap() { final _$actionInfo = _$VideoStoreBaseActionController.startAction( From 2e52bbebdaf36a2e5235360e4b0792c8862ac74b Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 19:51:04 -0400 Subject: [PATCH 20/28] Improve chat message modal behavior Modal is now summoned by long-pressing the username. Message is now copied by long-pressing the message. --- lib/models/irc.dart | 16 +++-- .../channel/chat/widgets/chat_message.dart | 67 ++++++++++--------- 2 files changed, 48 insertions(+), 35 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index d33292f8..a246c99e 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -112,6 +112,7 @@ class IRCMessage { required double emoteScale, required bool isLightTheme, required bool launchExternal, + required void Function()? onLongPressName, bool showMessage = true, bool useZeroWidth = false, bool useReadableColors = false, @@ -289,11 +290,16 @@ class IRCMessage { // debugPrint('NEW - NAME: ${tags['display-name']!}, HUE: ${hsl.hue}, SATURATION: ${hsl.saturation}, LIGHNTESS: ${hsl.lightness}'); span.add( - TextSpan( - text: tags['display-name']!, - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, + WidgetSpan( + child: InkWell( + onLongPress: onLongPressName, + child: Text( + tags['display-name']!, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + ), + ), ), ), ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index 8db4612e..a52e71dc 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -20,6 +20,39 @@ class ChatMessage extends StatelessWidget { @override Widget build(BuildContext context) { + void onLongPressName() { + if (isModal) return; + + if (ircMessage.user == null && ircMessage.user == chatStore.auth.user.details?.login) return; + + HapticFeedback.lightImpact(); + + showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: chatStore, + username: ircMessage.user!, + userId: ircMessage.tags['user-id']!, + displayName: ircMessage.tags['display-name']!, + ), + ); + } + + void onLongPressMessage() { + HapticFeedback.lightImpact(); + + Clipboard.setData(ClipboardData(text: ircMessage.message)); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: AlertMessage(message: 'Message copied to clipboard'), + behavior: SnackBarBehavior.floating, + ), + ); + } + return Observer( builder: (context) { Color? color; @@ -33,6 +66,7 @@ class ChatMessage extends StatelessWidget { renderMessage = Text.rich( TextSpan( children: ircMessage.generateSpan( + onLongPressName: onLongPressName, style: DefaultTextStyle.of(context).style, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, @@ -58,6 +92,7 @@ class ChatMessage extends StatelessWidget { Text.rich( TextSpan( children: ircMessage.generateSpan( + onLongPressName: onLongPressName, style: DefaultTextStyle.of(context).style, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, @@ -125,6 +160,7 @@ class ChatMessage extends StatelessWidget { Text.rich( TextSpan( children: ircMessage.generateSpan( + onLongPressName: onLongPressName, style: DefaultTextStyle.of(context).style, assetsStore: chatStore.assetsStore, emoteScale: chatStore.settings.emoteScale, @@ -171,36 +207,7 @@ class ChatMessage extends StatelessWidget { FocusScope.of(context).unfocus(); if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false; }, - onLongPress: isModal - ? () { - HapticFeedback.lightImpact(); - - Clipboard.setData(ClipboardData(text: ircMessage.message)); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: AlertMessage(message: 'Message copied to clipboard'), - behavior: SnackBarBehavior.floating, - ), - ); - } - : ircMessage.user != null && ircMessage.user != chatStore.auth.user.details?.login - ? () { - HapticFeedback.lightImpact(); - - showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => ChatUserModal( - chatStore: chatStore, - username: ircMessage.user!, - userId: ircMessage.tags['user-id']!, - displayName: ircMessage.tags['display-name']!, - ), - ); - } - : null, + onLongPress: onLongPressMessage, child: coloredMessage, ); }, From 870e8ae9164f34b4115572ccd58fbef3e9ce9bcf Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Thu, 28 Jul 2022 21:44:28 -0400 Subject: [PATCH 21/28] Implement custom copy notification When copying a message in chat, a notification will now show under the video rather than the block the textfield. --- lib/screens/channel/chat/chat.dart | 148 ++++++++++-------- .../channel/chat/widgets/chat_message.dart | 8 +- lib/screens/channel/stores/chat_store.dart | 27 +++- lib/screens/channel/stores/chat_store.g.dart | 17 ++ lib/widgets/alert_message.dart | 8 +- lib/widgets/button.dart | 2 +- 6 files changed, 134 insertions(+), 76 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 91d9542e..ae2ae82d 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -4,6 +4,7 @@ import 'package:frosty/screens/channel/chat/emote_menu/emote_menu.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/button.dart'; class Chat extends StatelessWidget { @@ -15,74 +16,99 @@ class Chat extends StatelessWidget { Widget build(BuildContext context) { return Observer( builder: (context) { - return Column( + return Stack( children: [ - Expanded( - child: GestureDetector( - onTap: () { - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - } else if (chatStore.textFieldFocusNode.hasFocus) { - chatStore.textFieldFocusNode.unfocus(); - } - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: chatStore.settings.messageScale), - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize), - child: Observer( - builder: (context) { - return ListView.builder( - reverse: true, - padding: EdgeInsets.zero, - addAutomaticKeepAlives: false, - controller: chatStore.scrollController, - itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages.reversed.toList()[index], - chatStore: chatStore, - ), - ); - }, + Column( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + if (chatStore.assetsStore.showEmoteMenu) { + chatStore.assetsStore.showEmoteMenu = false; + } else if (chatStore.textFieldFocusNode.hasFocus) { + chatStore.textFieldFocusNode.unfocus(); + } + }, + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: chatStore.settings.messageScale), + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize), + child: Observer( + builder: (context) { + return ListView.builder( + reverse: true, + padding: EdgeInsets.zero, + addAutomaticKeepAlives: false, + controller: chatStore.scrollController, + itemCount: chatStore.renderMessages.length, + itemBuilder: (context, index) => ChatMessage( + ircMessage: chatStore.renderMessages.reversed.toList()[index], + chatStore: chatStore, + ), + ); + }, + ), + ), ), - ), - ), - Padding( - padding: const EdgeInsets.all(5.0), - child: Observer( - builder: (_) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: chatStore.autoScroll - ? null - : Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), - onPressed: chatStore.resumeScroll, - icon: const Icon(Icons.arrow_circle_down), - child: const Text('Resume Scroll'), - ), + Padding( + padding: const EdgeInsets.all(5.0), + child: Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: chatStore.autoScroll + ? null + : Button( + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0), + onPressed: chatStore.resumeScroll, + icon: const Icon(Icons.arrow_circle_down), + child: const Text('Resume Scroll'), + ), + ), + ), ), - ), + ], ), - ], + ), ), - ), + if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore), + AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0, + child: AnimatedOpacity( + curve: Curves.ease, + opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: EmoteMenu(chatStore: chatStore), + ), + ), + ], ), - if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore), - AnimatedContainer( - curve: Curves.ease, + AnimatedSwitcher( duration: const Duration(milliseconds: 200), - height: chatStore.assetsStore.showEmoteMenu ? MediaQuery.of(context).size.height / 3 : 0, - child: AnimatedOpacity( - curve: Curves.ease, - opacity: chatStore.assetsStore.showEmoteMenu ? 1 : 0, - duration: const Duration(milliseconds: 200), - child: EmoteMenu(chatStore: chatStore), - ), + child: chatStore.notification != null + ? Align( + alignment: Alignment.topCenter, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.8), + borderRadius: BorderRadius.circular(30.0), + ), + width: double.infinity, + padding: const EdgeInsets.all(10.0), + margin: const EdgeInsets.all(10.0), + child: AlertMessage( + message: chatStore.notification!, + color: Colors.white, + ), + ), + ) + : null, ), ], ); diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index a52e71dc..d15540b4 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -4,7 +4,6 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/models/irc.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; -import 'package:frosty/widgets/alert_message.dart'; class ChatMessage extends StatelessWidget { final IRCMessage ircMessage; @@ -45,12 +44,7 @@ class ChatMessage extends StatelessWidget { Clipboard.setData(ClipboardData(text: ircMessage.message)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: AlertMessage(message: 'Message copied to clipboard'), - behavior: SnackBarBehavior.floating, - ), - ); + chatStore.notification = 'Message copied'; } return Observer( diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index 5a55e20e..a4cb8051 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -112,6 +112,13 @@ abstract class ChatStoreBase with Store { @observable var expandChat = false; + /// A notification message to display above the chat. + @observable + String? notification; + + /// Timer used for dismissing the notification. + Timer? _notificationTimer; + ChatStoreBase({ required this.auth, required this.chatDetailsStore, @@ -126,10 +133,22 @@ abstract class ChatStoreBase with Store { // Create a reaction that will reconnect to chat when logging in or out. // Closing the channel will trigger a reconnect with the new credentials. - reactions.add(reaction( - (_) => auth.isLoggedIn, - (_) => _channel?.sink.close(1001), - )); + reactions.add( + reaction( + (_) => auth.isLoggedIn, + (_) => _channel?.sink.close(1001), + ), + ); + + reactions.add( + reaction( + (_) => notification, + (_) { + if (_notificationTimer != null) _notificationTimer?.cancel(); + _notificationTimer = Timer(const Duration(seconds: 2), () => notification = null); + }, + ), + ); // Create a timer that will add messages from the buffer every 200 milliseconds. _messageBufferTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) => addMessages()); diff --git a/lib/screens/channel/stores/chat_store.g.dart b/lib/screens/channel/stores/chat_store.g.dart index 81e4d79b..f5d16aad 100644 --- a/lib/screens/channel/stores/chat_store.g.dart +++ b/lib/screens/channel/stores/chat_store.g.dart @@ -125,6 +125,22 @@ mixin _$ChatStore on ChatStoreBase, Store { }); } + late final _$notificationAtom = + Atom(name: 'ChatStoreBase.notification', context: context); + + @override + String? get notification { + _$notificationAtom.reportRead(); + return super.notification; + } + + @override + set notification(String? value) { + _$notificationAtom.reportWrite(value, super.notification, () { + super.notification = value; + }); + } + late final _$getAssetsAsyncAction = AsyncAction('ChatStoreBase.getAssets', context: context); @@ -206,6 +222,7 @@ mixin _$ChatStore on ChatStoreBase, Store { String toString() { return ''' expandChat: ${expandChat}, +notification: ${notification}, renderMessages: ${renderMessages} '''; } diff --git a/lib/widgets/alert_message.dart b/lib/widgets/alert_message.dart index 34b661f0..613ed9c2 100644 --- a/lib/widgets/alert_message.dart +++ b/lib/widgets/alert_message.dart @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; class AlertMessage extends StatelessWidget { final String message; final IconData icon; + final Color? color; const AlertMessage({ Key? key, required this.message, final this.icon = Icons.info, + this.color, }) : super(key: key); @override @@ -18,15 +20,15 @@ class AlertMessage extends StatelessWidget { children: [ Icon( icon, - color: Colors.grey, + color: color ?? Colors.grey, ), const SizedBox(width: 5.0), Flexible( child: Text( message, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w500, - color: Colors.grey, + color: color ?? Colors.grey, ), ), ), diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 1dddf96c..31cd17cf 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -40,7 +40,7 @@ class _ButtonState extends State