diff --git a/android/app/build.gradle b/android/app/build.gradle index 5a4f0c89..400cd68d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -49,7 +49,7 @@ android { defaultConfig { applicationId "com.tommychow.frosty" - minSdkVersion 19 + minSdkVersion 26 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/lib/constants/constants.dart b/lib/constants/constants.dart index 0f852750..88600eaa 100644 --- a/lib/constants/constants.dart +++ b/lib/constants/constants.dart @@ -5,7 +5,7 @@ const clientId = String.fromEnvironment('CLIENT_ID'); const secret = String.fromEnvironment('SECRET'); /// The tracesSampleRate for sentry crash reporting. -const sampleRate = 1.0; +const sampleRate = 0.2; /// BTTV emotes with zero width to allow for overlaying other emotes. const zeroWidthEmotes = [ diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index 546bebb2..027eb6cc 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -264,6 +264,7 @@ class Emote { final bool zeroWidth; final String url; final EmoteType type; + final String? ownerId; const Emote({ required this.name, @@ -272,6 +273,7 @@ class Emote { required this.zeroWidth, required this.url, required this.type, + this.ownerId, }); factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote( @@ -279,6 +281,7 @@ class Emote { zeroWidth: false, url: 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', type: type, + ownerId: emote.ownerId, ); factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote( diff --git a/lib/models/emotes.g.dart b/lib/models/emotes.g.dart index 07e936f3..9cc8e12b 100644 --- a/lib/models/emotes.g.dart +++ b/lib/models/emotes.g.dart @@ -131,6 +131,7 @@ Emote _$EmoteFromJson(Map json) => Emote( zeroWidth: json['zeroWidth'] as bool, url: json['url'] as String, type: $enumDecode(_$EmoteTypeEnumMap, json['type']), + ownerId: json['ownerId'] as String?, ); Map _$EmoteToJson(Emote instance) => { @@ -140,6 +141,7 @@ Map _$EmoteToJson(Emote instance) => { 'zeroWidth': instance.zeroWidth, 'url': instance.url, 'type': _$EmoteTypeEnumMap[instance.type], + 'ownerId': instance.ownerId, }; const _$EmoteTypeEnumMap = { diff --git a/lib/screens/channel/chat/emote_menu/emote_menu.dart b/lib/screens/channel/chat/emote_menu/emote_menu.dart index 6f9a591c..de8f8011 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu.dart @@ -4,6 +4,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart'; import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'; import 'package:frosty/screens/channel/stores/chat_store.dart'; +import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/button.dart'; class EmoteMenu extends StatefulWidget { @@ -41,33 +42,55 @@ class _EmoteMenuState extends State { SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: sections - .mapIndexed( - (index, section) => Padding( - padding: const EdgeInsets.only(right: 5.0), - child: Observer( - builder: (context) => Button( - onPressed: () { - _pageContoller.animateToPage( - index, - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); - widget.chatStore.assetsStore.emoteMenuIndex = index; - }, - color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey, - child: Text( - section, - style: const TextStyle( - letterSpacing: 0.8, - fontWeight: FontWeight.w600, - ), + children: [ + ...sections.mapIndexed( + (index, section) => Padding( + padding: const EdgeInsets.only(right: 5.0), + child: Observer( + builder: (context) => Button( + onPressed: () { + _pageContoller.animateToPage( + index, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + widget.chatStore.assetsStore.emoteMenuIndex = index; + }, + color: index == widget.chatStore.assetsStore.emoteMenuIndex ? Theme.of(context).colorScheme.secondary : Colors.grey, + child: Text( + section, + style: const TextStyle( + letterSpacing: 0.8, + fontWeight: FontWeight.w600, ), ), ), ), - ) - .toList(), + ), + ), + Button( + onPressed: () async { + await widget.chatStore.getAssets(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: AlertMessage(message: 'Emotes refreshed'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + color: Theme.of(context).colorScheme.secondary, + child: const Text( + 'REFRESH EMOTES', + style: TextStyle( + letterSpacing: 0.8, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ), Expanded( @@ -81,7 +104,7 @@ class _EmoteMenuState extends State { Observer( builder: (_) => EmoteMenuPanel( chatStore: widget.chatStore, - emotes: widget.chatStore.assetsStore.userEmoteToObject.values.toList(), + twitchEmotes: widget.chatStore.assetsStore.userEmoteSectionToEmotes, ), ), Observer( diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index 2b4625cb..a4dd9096 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -6,78 +6,72 @@ import 'package:frosty/widgets/section_header.dart'; class EmoteMenuPanel extends StatelessWidget { final ChatStore chatStore; - final List emotes; + final List? emotes; + final Map>? twitchEmotes; const EmoteMenuPanel({ Key? key, required this.chatStore, - required this.emotes, + this.emotes, + this.twitchEmotes, }) : super(key: key); @override Widget build(BuildContext context) { - final globalEmotes = emotes - .where((emote) => - emote.type == EmoteType.twitchGlobal || - emote.type == EmoteType.bttvGlobal || - emote.type == EmoteType.ffzGlobal || - emote.type == EmoteType.sevenTVGlobal) - .toList(); + if (emotes != null) { + final globalEmotes = + emotes!.where((emote) => emote.type == EmoteType.bttvGlobal || emote.type == EmoteType.ffzGlobal || emote.type == EmoteType.sevenTVGlobal).toList(); - final channelEmotes = emotes - .where((emote) => - emote.type == EmoteType.twitchChannel || - emote.type == EmoteType.bttvChannel || - emote.type == EmoteType.bttvShared || - emote.type == EmoteType.ffzChannel || - emote.type == EmoteType.sevenTVChannel) - .toList(); + final channelEmotes = emotes! + .where((emote) => + emote.type == EmoteType.bttvChannel || + emote.type == EmoteType.bttvShared || + emote.type == EmoteType.ffzChannel || + emote.type == EmoteType.sevenTVChannel) + .toList(); - final subEmotes = emotes.where((emote) => emote.type == EmoteType.twitchSub).toList(); - final miscEmotes = emotes.where((emote) => emote.type == EmoteType.twitchUnlocked).toList(); - - return CustomScrollView( - slivers: [ - if (globalEmotes.isNotEmpty) ...[ - const SliverToBoxAdapter( - child: SectionHeader( - 'Global Emotes', - padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), + return CustomScrollView( + slivers: [ + if (globalEmotes.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SectionHeader( + 'Global Emotes', + padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), + ), ), - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: globalEmotes, - ), + EmoteMenuSection( + chatStore: chatStore, + emotes: globalEmotes, + ), + ], + if (channelEmotes.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SectionHeader('Channel Emotes'), + ), + EmoteMenuSection( + chatStore: chatStore, + emotes: channelEmotes, + ), + ], ], - if (channelEmotes.isNotEmpty) ...[ - const SliverToBoxAdapter( - child: SectionHeader('Channel Emotes'), - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: channelEmotes, - ), + ); + } else { + return CustomScrollView( + slivers: [ + for (final entry in twitchEmotes!.entries) ...[ + SliverToBoxAdapter( + child: SectionHeader( + entry.key, + padding: entry.key == 'Global Emotes' ? const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0) : null, + ), + ), + EmoteMenuSection( + chatStore: chatStore, + emotes: entry.value, + ), + ] ], - if (subEmotes.isNotEmpty) ...[ - const SliverToBoxAdapter( - child: SectionHeader('Subscribed Emotes'), - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: subEmotes, - ), - ], - if (miscEmotes.isNotEmpty) ...[ - const SliverToBoxAdapter( - child: SectionHeader('Unlocked Emotes'), - ), - EmoteMenuSection( - chatStore: chatStore, - emotes: miscEmotes, - ), - ], - ], - ); + ); + } } } diff --git a/lib/screens/channel/stores/chat_assets_store.dart b/lib/screens/channel/stores/chat_assets_store.dart index fe51ba1b..43610631 100644 --- a/lib/screens/channel/stores/chat_assets_store.dart +++ b/lib/screens/channel/stores/chat_assets_store.dart @@ -43,8 +43,14 @@ abstract class ChatAssetsStoreBase with Store { /// The emotes that are "owned" and may be used by the current user. @readonly + // ignore: prefer_final_fields var _userEmoteToObject = {}; + /// The Twitch emote types mapped to their emotes (i.e., global, specific channels, unlocked). + @readonly + // ignore: prefer_final_fields + var _userEmoteSectionToEmotes = >{}; + /// The map of user IDs to their FFZ badges. @readonly var _userToFFZBadges = >{}; @@ -91,8 +97,8 @@ abstract class ChatAssetsStoreBase with Store { // Retrieve the instance that will allow us to retrieve local search history. final prefs = await SharedPreferences.getInstance(); - _recentEmotes = prefs.getStringList('recent_emotes')?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))).toList().asObservable() ?? - ObservableList(); + _recentEmotes = + prefs.getStringList('recent_emotes')?.map((emoteJson) => Emote.fromJson(jsonDecode(emoteJson))).toList().asObservable() ?? ObservableList(); _disposeReaction = autorun((_) { if (_recentEmotes.length > 48) _recentEmotes.removeLast(); @@ -153,10 +159,8 @@ abstract class ChatAssetsStoreBase with Store { required Function onError, }) => Future.wait([ - twitchApi - .getBadgesGlobal() - .then((badges) => twitchBadgesToObject.addAll(badges)) - .then((_) => twitchApi.getBadgesChannel(id: channelId).then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError)), + twitchApi.getBadgesGlobal().then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError), + twitchApi.getBadgesChannel(id: channelId).then((badges) => twitchBadgesToObject.addAll(badges)).catchError(onError), ffzApi.getBadges().then((badges) => _userToFFZBadges = badges).catchError(onError), sevenTVApi.getBadges().then((badges) => _userTo7TVBadges = badges).catchError(onError), bttvApi.getBadges().then((badges) => _userToBTTVBadges = badges).catchError(onError), @@ -167,10 +171,38 @@ abstract class ChatAssetsStoreBase with Store { required List emoteSets, required Map headers, required Function onError, - }) => - Future.wait(emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError))) - .then((emotes) => emotes.expand((list) => list).toList()) - .then((userEmotes) => _userEmoteToObject = {for (final emote in userEmotes) emote.name: emote}.asObservable()); + }) async { + final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError))); + + for (final emoteSet in userEmotes) { + if (emoteSet.isNotEmpty) { + if (emoteSet.first.type == EmoteType.twitchSub) { + final owner = await twitchApi.getUser(id: emoteSet.first.ownerId, headers: headers); + _userEmoteSectionToEmotes.update( + owner.displayName, + (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], + ifAbsent: () => emoteSet, + ); + } else if (emoteSet.first.type == EmoteType.twitchGlobal) { + _userEmoteSectionToEmotes.update( + 'Global Emotes', + (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], + ifAbsent: () => emoteSet, + ); + } else if (emoteSet.first.type == EmoteType.twitchUnlocked) { + _userEmoteSectionToEmotes.update( + 'Unlocked Emotes', + (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], + ifAbsent: () => emoteSet, + ); + } + + for (final emote in emoteSet) { + _userEmoteToObject[emote.name] = emote; + } + } + } + } void dispose() => _disposeReaction(); } diff --git a/lib/screens/channel/stores/chat_assets_store.g.dart b/lib/screens/channel/stores/chat_assets_store.g.dart index ae1c2b4a..9c9f7ab8 100644 --- a/lib/screens/channel/stores/chat_assets_store.g.dart +++ b/lib/screens/channel/stores/chat_assets_store.g.dart @@ -85,6 +85,26 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { }); } + late final _$_userEmoteSectionToEmotesAtom = Atom( + name: 'ChatAssetsStoreBase._userEmoteSectionToEmotes', context: context); + + Map> get userEmoteSectionToEmotes { + _$_userEmoteSectionToEmotesAtom.reportRead(); + return super._userEmoteSectionToEmotes; + } + + @override + Map> get _userEmoteSectionToEmotes => + userEmoteSectionToEmotes; + + @override + set _userEmoteSectionToEmotes(Map> value) { + _$_userEmoteSectionToEmotesAtom + .reportWrite(value, super._userEmoteSectionToEmotes, () { + super._userEmoteSectionToEmotes = value; + }); + } + late final _$_userToFFZBadgesAtom = Atom(name: 'ChatAssetsStoreBase._userToFFZBadges', context: context); @@ -179,6 +199,18 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } + late final _$userEmotesFutureAsyncAction = + AsyncAction('ChatAssetsStoreBase.userEmotesFuture', context: context); + + @override + Future userEmotesFuture( + {required List emoteSets, + required Map headers, + required Function onError}) { + return _$userEmotesFutureAsyncAction.run(() => super.userEmotesFuture( + emoteSets: emoteSets, headers: headers, onError: onError)); + } + late final _$ChatAssetsStoreBaseActionController = ActionController(name: 'ChatAssetsStoreBase', context: context); @@ -231,21 +263,6 @@ mixin _$ChatAssetsStore on ChatAssetsStoreBase, Store { } } - @override - Future userEmotesFuture( - {required List emoteSets, - required Map headers, - required Function onError}) { - final _$actionInfo = _$ChatAssetsStoreBaseActionController.startAction( - name: 'ChatAssetsStoreBase.userEmotesFuture'); - try { - return super.userEmotesFuture( - emoteSets: emoteSets, headers: headers, onError: onError); - } finally { - _$ChatAssetsStoreBaseActionController.endAction(_$actionInfo); - } - } - @override String toString() { return ''' diff --git a/lib/screens/channel/stores/chat_store.dart b/lib/screens/channel/stores/chat_store.dart index b8d1d4e9..31c36b54 100644 --- a/lib/screens/channel/stores/chat_store.dart +++ b/lib/screens/channel/stores/chat_store.dart @@ -231,19 +231,8 @@ abstract class ChatStoreBase with Store { } else if (message.contains('Welcome, GLHF!')) { _messages.add(IRCMessage.createNotice(message: "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); - // Fetch the assets used in chat including badges and emotes. - assetsStore.assetsFuture( - channelId: channelId, - headers: auth.headersTwitch, - onEmoteError: (error) { - debugPrint(error.toString()); - return []; - }, - onBadgeError: (error) { - debugPrint(error.toString()); - return []; - }, - ); + getAssets(); + // Reset exponential backoff if successfully connected. _retries = 0; _backoffTime = 0; @@ -251,6 +240,21 @@ abstract class ChatStoreBase with Store { } } + // Fetch the assets used in chat including badges and emotes. + @action + Future getAssets() async => assetsStore.assetsFuture( + channelId: channelId, + headers: auth.headersTwitch, + onEmoteError: (error) { + debugPrint(error.toString()); + return []; + }, + onBadgeError: (error) { + debugPrint(error.toString()); + return []; + }, + ); + /// Re-enables [_autoScroll] and jumps to the latest message. @action void resumeScroll() { diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index e2d2efd4..4a8ebde3 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/core/auth/auth_store.dart'; @@ -42,20 +40,15 @@ class _HomeState extends State { content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( + children: const [ + Text( 'Thank you so much for downloading and trying out Frosty! Hopefully it will make your mobile Twitch viewing experience a little more enjoyable.'), - const SizedBox(height: 20.0), - const Text( - 'You can see the full changelog on the app store listing or the "Releases" section on the GitHub repo (link on the top right of settings).'), - const SizedBox(height: 20.0), - const Text('You can also find the FAQ on the GitHub repo (link in Settings -> Other).'), - const SizedBox(height: 20.0), - const Text('If you have any issues or feature requests for the app, please open an issue on GitHub.'), - if (Platform.isAndroid) ...[ - const SizedBox(height: 20.0), - const Text('NOTE: Due to limitations with the Twitch web player, video streams may not work on Android versions below 7.1.1.'), - ] + SizedBox(height: 20.0), + Text('You can see the full changelog on the app store listing or the "Releases" section on the GitHub repo (link on the top right of settings).'), + SizedBox(height: 20.0), + Text('You can also find the FAQ on the GitHub repo (link in Settings -> Other).'), + SizedBox(height: 20.0), + Text('If you have any issues or feature requests for the app, please open an issue on GitHub.'), ], ), actions: [ diff --git a/lib/widgets/section_header.dart b/lib/widgets/section_header.dart index b2c6baef..f47c54d9 100644 --- a/lib/widgets/section_header.dart +++ b/lib/widgets/section_header.dart @@ -2,14 +2,14 @@ import 'package:flutter/material.dart'; class SectionHeader extends StatelessWidget { final String text; - final EdgeInsets padding; + final EdgeInsets? padding; final double? fontSize; final bool showDivider; const SectionHeader( this.text, { Key? key, - this.padding = const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0), + this.padding, this.fontSize, this.showDivider = false, }) : super(key: key); @@ -17,7 +17,7 @@ class SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: padding, + padding: padding ?? const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [