diff --git a/lib/api/irc_api.dart b/lib/api/irc_api.dart index 753f7c24..c32b46c7 100644 --- a/lib/api/irc_api.dart +++ b/lib/api/irc_api.dart @@ -56,13 +56,14 @@ class IRC { required Map emoteToObject, required Map badgeToObject, bool hideMessage = false, + bool zeroWidthEnabled = false, }) { // The span list that will be used to render the chat message final span = []; // The map containing emotes from the user's tags to their URL. // This may include sub emotes that they can access but other users cannot. - final localEmoteToUrl = {}; + final localEmoteToObject = {}; final emoteTags = ircMessage.tags['emotes']; if (emoteTags != null) { @@ -91,7 +92,13 @@ class IRC { final emoteWord = ircMessage.message!.substring(startIndex, endIndex + 1); // Store the emote word and its associated URL for later use. - localEmoteToUrl[emoteWord] = 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0'; + localEmoteToObject[emoteWord] = Emote( + id: emoteId, + name: emoteWord, + zeroWidth: false, + url: 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0', + type: EmoteType.bttvChannel, + ); } } @@ -148,41 +155,114 @@ class IRC { // This character is used by some clients to bypass restrictions on repeating message. final words = message.trim().replaceAll('\u{E0000}', '').split(' '); - // Use a string buffer to minimize TextSpan widgets. - // Instead of one TextSpan widget per word, we can have one TextSpan widget across multiple. - final buffer = StringBuffer(); + if (zeroWidthEnabled) { + final localSpan = []; - for (final word in words) { - buffer.write(' '); + var index = words.length - 1; + while (index != -1) { + final word = words[index]; + final emote = emoteToObject[word] ?? localEmoteToObject[word]; - final emoteUrl = emoteToObject[word]?.url ?? localEmoteToUrl[word]; - if (emoteUrl != null) { - span.add(TextSpan(text: buffer.toString())); + if (emote != null) { + // Handle zero width emotes + if (emote.zeroWidth && index != 0) { + final emoteStack = []; - buffer.clear(); + var nextEmote = emoteToObject[word]; + while (nextEmote != null && nextEmote.zeroWidth && index != 0) { + emoteStack.add(nextEmote); + index--; + nextEmote = emoteToObject[words[index]]; + } - span.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Tooltip( - message: word, - preferBelow: false, - child: CachedNetworkImage( - imageUrl: emoteUrl, - placeholder: (context, url) => const SizedBox(), - fadeInDuration: const Duration(seconds: 0), - height: 25, + if (nextEmote != null) emoteStack.add(nextEmote); + + localSpan.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Tooltip( + message: emoteStack.reversed.map((emote) => emote.name).join(', '), + preferBelow: false, + child: Stack( + alignment: AlignmentDirectional.center, + children: emoteStack.reversed + .map((emote) => CachedNetworkImage( + imageUrl: emote.url, + placeholder: (context, url) => const SizedBox(), + fadeInDuration: const Duration(seconds: 0), + height: 25, + )) + .toList(), + ), + ), + ), + ); + + if (nextEmote == null) { + localSpan.add(const TextSpan(text: ' ')); + localSpan.add(TextSpan(text: words[index])); + } + } else { + localSpan.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Tooltip( + message: emote.name, + preferBelow: false, + child: CachedNetworkImage( + imageUrl: emote.url, + placeholder: (context, url) => const SizedBox(), + fadeInDuration: const Duration(seconds: 0), + height: 25, + ), + ), + ), + ); + } + } else { + localSpan.add(TextSpan(text: word)); + } + localSpan.add(const TextSpan(text: ' ')); + index--; + } + span.addAll(localSpan.reversed); + } else { + // Use a string buffer to minimize TextSpan widgets. + // Instead of one TextSpan widget per word, we can have one TextSpan widget across multiple. + final buffer = StringBuffer(); + + for (final word in words) { + buffer.write(' '); + + final emote = emoteToObject[word] ?? localEmoteToObject[word]; + if (emote != null) { + span.add(TextSpan(text: buffer.toString())); + + buffer.clear(); + + span.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Tooltip( + message: emote.name, + preferBelow: false, + child: CachedNetworkImage( + imageUrl: emote.url, + placeholder: (context, url) => const SizedBox(), + fadeInDuration: const Duration(seconds: 0), + height: 25, + ), ), ), - ), - ); - } else { - buffer.write(word); + ); + } else { + buffer.write(word); + } } - } - if (buffer.isNotEmpty) { - span.add(TextSpan(text: buffer.toString())); + if (buffer.isNotEmpty) { + span.add(TextSpan(text: buffer.toString())); + } } } } diff --git a/lib/constants.dart b/lib/constants.dart index dfbe7eb0..2d575af5 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -3,3 +3,15 @@ const clientId = String.fromEnvironment('CLIENT_ID'); /// Twitch API client secret. const secret = String.fromEnvironment('SECRET'); + +/// BTTV emotes with zero width to allow for overlaying other emotes. +const zeroWidthEmotes = [ + "SoSnowy", + "IceCold", + "SantaHat", + "TopHat", + "ReinDeer", + "CandyCane", + "cvMask", + "cvHazmat", +]; diff --git a/lib/core/settings/settings.dart b/lib/core/settings/settings.dart index ffb80483..76f51a47 100644 --- a/lib/core/settings/settings.dart +++ b/lib/core/settings/settings.dart @@ -50,6 +50,11 @@ class Settings extends StatelessWidget { value: settingsStore.hideBannedMessages, onChanged: (newValue) => settingsStore.hideBannedMessages = newValue, ), + SwitchListTile.adaptive( + title: const Text('Enable Zero-Width Emotes'), + value: settingsStore.zeroWidthEnabled, + onChanged: (newValue) => settingsStore.zeroWidthEnabled = newValue, + ), ], ); }, diff --git a/lib/core/settings/settings_store.dart b/lib/core/settings/settings_store.dart index 957d01ed..7ba656d0 100644 --- a/lib/core/settings/settings_store.dart +++ b/lib/core/settings/settings_store.dart @@ -16,6 +16,9 @@ abstract class _SettingsStoreBase with Store { @observable var hideBannedMessages = false; + @observable + var zeroWidthEnabled = false; + Future init() async { // Retrieve the instance that will allow us to store and persist settings. final prefs = await SharedPreferences.getInstance(); @@ -24,6 +27,7 @@ abstract class _SettingsStoreBase with Store { videoEnabled = prefs.getBool('video_enabled') ?? videoEnabled; messageLimit = prefs.getDouble('message_limit') ?? messageLimit; hideBannedMessages = prefs.getBool('hide_banned_messages') ?? hideBannedMessages; + zeroWidthEnabled = prefs.getBool('zero_width_enabled') ?? zeroWidthEnabled; // Set up autorun to store setting anytime they're changed. // The ReactionDisposer will not be needed since settings will always exist. @@ -32,6 +36,7 @@ abstract class _SettingsStoreBase with Store { prefs.setBool('video_enabled', videoEnabled); prefs.setDouble('message_limit', messageLimit); prefs.setBool('hide_banned_messages', hideBannedMessages); + prefs.setBool('zero_width_enabled', zeroWidthEnabled); }); } } diff --git a/lib/core/settings/settings_store.g.dart b/lib/core/settings/settings_store.g.dart index aaa7ed78..b18dac01 100644 --- a/lib/core/settings/settings_store.g.dart +++ b/lib/core/settings/settings_store.g.dart @@ -55,12 +55,29 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + final _$zeroWidthEnabledAtom = + Atom(name: '_SettingsStoreBase.zeroWidthEnabled'); + + @override + bool get zeroWidthEnabled { + _$zeroWidthEnabledAtom.reportRead(); + return super.zeroWidthEnabled; + } + + @override + set zeroWidthEnabled(bool value) { + _$zeroWidthEnabledAtom.reportWrite(value, super.zeroWidthEnabled, () { + super.zeroWidthEnabled = value; + }); + } + @override String toString() { return ''' videoEnabled: ${videoEnabled}, messageLimit: ${messageLimit}, -hideBannedMessages: ${hideBannedMessages} +hideBannedMessages: ${hideBannedMessages}, +zeroWidthEnabled: ${zeroWidthEnabled} '''; } } diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index ee09176c..88251712 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -1,3 +1,4 @@ +import 'package:frosty/constants.dart'; import 'package:json_annotation/json_annotation.dart'; part 'emotes.g.dart'; @@ -237,52 +238,52 @@ class Emote { final String name; final int? width; final int? height; + final bool zeroWidth; final String url; final EmoteType type; - const Emote( - this.id, - this.name, + const Emote({ + required this.id, + required this.name, this.width, this.height, - this.url, - this.type, - ); + required this.zeroWidth, + required this.url, + required this.type, + }); factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote( - emote.id, - emote.name, - null, - null, - 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', - type, + id: emote.id, + name: emote.name, + zeroWidth: false, + url: 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0', + type: type, ); factory Emote.fromBTTV(EmoteBTTV emote, EmoteType type) => Emote( - emote.id, - emote.code, - null, - null, - 'https://cdn.betterttv.net/emote/${emote.id}/3x', - type, + id: emote.id, + name: emote.code, + zeroWidth: zeroWidthEmotes.contains(emote.code), + url: 'https://cdn.betterttv.net/emote/${emote.id}/3x', + type: type, ); factory Emote.fromFFZ(EmoteFFZ emote, EmoteType type) => Emote( - emote.id.toString(), - emote.code, - null, - null, - emote.images.url4x ?? emote.images.url1x, - type, + id: emote.id.toString(), + name: emote.code, + zeroWidth: false, + url: emote.images.url4x ?? emote.images.url1x, + type: type, ); factory Emote.from7TV(Emote7TV emote, EmoteType type) => Emote( - emote.id, - emote.name, - emote.width.first, - emote.height.first, - emote.urls[3][1], - type, + id: emote.id, + name: emote.name, + width: emote.width.first, + height: emote.height.first, + zeroWidth: emote.visibilitySimple.isNotEmpty ? emote.visibilitySimple.first == "ZERO_WIDTH" : false, + url: emote.urls[3][1], + type: type, ); } diff --git a/lib/screens/search/search.dart b/lib/screens/search/search.dart index ebdfe885..ac8b0a6c 100644 --- a/lib/screens/search/search.dart +++ b/lib/screens/search/search.dart @@ -23,7 +23,6 @@ class _SearchState extends State { padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), child: TextField( controller: searchStore.textController, - autocorrect: false, decoration: InputDecoration( suffixIcon: IconButton( onPressed: searchStore.clearSearch,