Add experimental support for zero-width emotes

This commit is contained in:
tommyxchow 2021-12-15 01:49:25 -05:00
parent db75828173
commit 8337a454fc
7 changed files with 180 additions and 61 deletions

View file

@ -56,13 +56,14 @@ class IRC {
required Map<String, Emote> emoteToObject,
required Map<String, BadgeInfoTwitch> badgeToObject,
bool hideMessage = false,
bool zeroWidthEnabled = false,
}) {
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
// 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 = <String, String>{};
final localEmoteToObject = <String, Emote>{};
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 = <InlineSpan>[];
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 = <Emote>[];
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()));
}
}
}
}

View file

@ -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",
];

View file

@ -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,
),
],
);
},

View file

@ -16,6 +16,9 @@ abstract class _SettingsStoreBase with Store {
@observable
var hideBannedMessages = false;
@observable
var zeroWidthEnabled = false;
Future<void> 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);
});
}
}

View file

@ -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}
''';
}
}

View file

@ -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,
);
}

View file

@ -23,7 +23,6 @@ class _SearchState extends State<Search> {
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
controller: searchStore.textController,
autocorrect: false,
decoration: InputDecoration(
suffixIcon: IconButton(
onPressed: searchStore.clearSearch,