Add experimental support for zero-width emotes
This commit is contained in:
parent
db75828173
commit
8337a454fc
7 changed files with 180 additions and 61 deletions
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue