Estimate size of images to avoid layout shifts (#5192)

This commit is contained in:
nerix 2024-02-25 18:19:20 +01:00 committed by GitHub
parent 0cfd25ce8e
commit f285ada36c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 145 additions and 70 deletions

View file

@ -162,6 +162,7 @@
- Dev: Specialize `Atomic<std::shared_ptr<T>>` if underlying standard library supports it. (#5133) - Dev: Specialize `Atomic<std::shared_ptr<T>>` if underlying standard library supports it. (#5133)
- Dev: Added the `developer_name` field to the Linux AppData specification. (#5138) - Dev: Added the `developer_name` field to the Linux AppData specification. (#5138)
- Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200) - Dev: Twitch messages can be sent using Twitch's Helix API instead of IRC (disabled by default). (#5200)
- Dev: Added estimation for image sizes to avoid layout shifts. (#5192)
## 2.4.6 ## 2.4.6

View file

@ -318,7 +318,7 @@ Image::~Image()
} }
} }
ImagePtr Image::fromUrl(const Url &url, qreal scale) ImagePtr Image::fromUrl(const Url &url, qreal scale, QSize expectedSize)
{ {
static std::unordered_map<Url, std::weak_ptr<Image>> cache; static std::unordered_map<Url, std::weak_ptr<Image>> cache;
static std::mutex mutex; static std::mutex mutex;
@ -329,7 +329,7 @@ ImagePtr Image::fromUrl(const Url &url, qreal scale)
if (!shared) if (!shared)
{ {
cache[url] = shared = ImagePtr(new Image(url, scale)); cache[url] = shared = ImagePtr(new Image(url, scale, expectedSize));
} }
return shared; return shared;
@ -382,9 +382,11 @@ Image::Image()
{ {
} }
Image::Image(const Url &url, qreal scale) Image::Image(const Url &url, qreal scale, QSize expectedSize)
: url_(url) : url_(url)
, scale_(scale) , scale_(scale)
, expectedSize_(expectedSize.isValid() ? expectedSize
: (QSize(16, 16) * scale))
, shouldLoad_(true) , shouldLoad_(true)
, frames_(std::make_unique<detail::Frames>()) , frames_(std::make_unique<detail::Frames>())
{ {
@ -477,11 +479,11 @@ int Image::width() const
if (auto pixmap = this->frames_->first()) if (auto pixmap = this->frames_->first())
{ {
return int(pixmap->width() * this->scale_); return static_cast<int>(pixmap->width() * this->scale_);
} }
// No frames loaded, use our default magic width 16 // No frames loaded, use the expected size
return 16; return static_cast<int>(this->expectedSize_.width() * this->scale_);
} }
int Image::height() const int Image::height() const
@ -490,11 +492,11 @@ int Image::height() const
if (auto pixmap = this->frames_->first()) if (auto pixmap = this->frames_->first())
{ {
return int(pixmap->height() * this->scale_); return static_cast<int>(pixmap->height() * this->scale_);
} }
// No frames loaded, use our default magic height 16 // No frames loaded, use the expected size
return 16; return static_cast<int>(this->expectedSize_.height() * this->scale_);
} }
void Image::actuallyLoad() void Image::actuallyLoad()

View file

@ -73,7 +73,8 @@ public:
Image(Image &&) = delete; Image(Image &&) = delete;
Image &operator=(Image &&) = delete; Image &operator=(Image &&) = delete;
static ImagePtr fromUrl(const Url &url, qreal scale = 1); static ImagePtr fromUrl(const Url &url, qreal scale = 1,
QSize expectedSize = {});
static ImagePtr fromResourcePixmap(const QPixmap &pixmap, qreal scale = 1); static ImagePtr fromResourcePixmap(const QPixmap &pixmap, qreal scale = 1);
static ImagePtr getEmpty(); static ImagePtr getEmpty();
@ -93,7 +94,7 @@ public:
private: private:
Image(); Image();
Image(const Url &url, qreal scale); Image(const Url &url, qreal scale, QSize expectedSize);
Image(qreal scale); Image(qreal scale);
void setPixmap(const QPixmap &pixmap); void setPixmap(const QPixmap &pixmap);
@ -102,12 +103,18 @@ private:
const Url url_{}; const Url url_{};
const qreal scale_{1}; const qreal scale_{1};
/// @brief The expected size of this image once its loaded.
///
/// This doesn't represent the actual size (it can be different) - it's
/// just an estimation and provided to avoid (large) layout shifts when
/// loading images.
const QSize expectedSize_{16, 16};
std::atomic_bool empty_{false}; std::atomic_bool empty_{false};
mutable std::chrono::time_point<std::chrono::steady_clock> lastUsed_;
bool shouldLoad_{false}; bool shouldLoad_{false};
mutable std::chrono::time_point<std::chrono::steady_clock> lastUsed_;
// gui thread only // gui thread only
std::unique_ptr<detail::Frames> frames_{}; std::unique_ptr<detail::Frames> frames_{};

View file

@ -22,6 +22,8 @@ namespace {
"This channel has no BetterTTV channel emotes."); "This channel has no BetterTTV channel emotes.");
QString emoteLinkFormat("https://betterttv.com/emotes/%1"); QString emoteLinkFormat("https://betterttv.com/emotes/%1");
// BTTV doesn't provide any data on the size, so we assume an emote is 28x28
constexpr QSize EMOTE_BASE_SIZE(28, 28);
struct CreateEmoteResult { struct CreateEmoteResult {
EmoteId id; EmoteId id;
@ -65,9 +67,12 @@ namespace {
auto emote = Emote({ auto emote = Emote({
name, name,
ImageSet{Image::fromUrl(getEmoteLinkV3(id, "1x"), 1), ImageSet{Image::fromUrl(getEmoteLinkV3(id, "1x"), 1,
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), EMOTE_BASE_SIZE),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25)}, Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5,
EMOTE_BASE_SIZE * 2),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25,
EMOTE_BASE_SIZE * 4)},
Tooltip{name.string + "<br>Global BetterTTV Emote"}, Tooltip{name.string + "<br>Global BetterTTV Emote"},
Url{emoteLinkFormat.arg(id.string)}, Url{emoteLinkFormat.arg(id.string)},
}); });
@ -90,9 +95,11 @@ namespace {
auto emote = Emote({ auto emote = Emote({
name, name,
ImageSet{ ImageSet{
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1), Image::fromUrl(getEmoteLinkV3(id, "1x"), 1, EMOTE_BASE_SIZE),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5), Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5,
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25), EMOTE_BASE_SIZE * 2),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25,
EMOTE_BASE_SIZE * 4),
}, },
Tooltip{ Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3") QString("%1<br>%2 BetterTTV Emote<br>By: %3")

View file

@ -3,6 +3,7 @@
#include "common/network/NetworkRequest.hpp" #include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp" #include "common/network/NetworkResult.hpp"
#include "messages/Emote.hpp" #include "messages/Emote.hpp"
#include "messages/Image.hpp"
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
@ -44,12 +45,23 @@ void ChatterinoBadges::loadChatterinoBadges()
jsonRoot.value("badges").toArray()) jsonRoot.value("badges").toArray())
{ {
auto jsonBadge = jsonBadgeValue.toObject(); auto jsonBadge = jsonBadgeValue.toObject();
// The sizes for the images are only an estimation, there might
// be badges with different sizes.
constexpr QSize baseSize(18, 18);
auto emote = Emote{ auto emote = Emote{
.name = EmoteName{}, .name = EmoteName{},
.images = .images =
ImageSet{Url{jsonBadge.value("image1").toString()}, ImageSet{
Url{jsonBadge.value("image2").toString()}, Image::fromUrl(
Url{jsonBadge.value("image3").toString()}}, Url{jsonBadge.value("image1").toString()}, 1.0,
baseSize),
Image::fromUrl(
Url{jsonBadge.value("image2").toString()}, 0.5,
baseSize * 2),
Image::fromUrl(
Url{jsonBadge.value("image3").toString()}, 0.25,
baseSize * 4),
},
.tooltip = Tooltip{jsonBadge.value("tooltip").toString()}, .tooltip = Tooltip{jsonBadge.value("tooltip").toString()},
.homePage = Url{}, .homePage = Url{},
}; };

View file

@ -285,7 +285,8 @@ void Emojis::loadEmojiSet()
} }
QString url = urlPrefix + code + ".png"; QString url = urlPrefix + code + ".png";
emoji->emote = std::make_shared<Emote>(Emote{ emoji->emote = std::make_shared<Emote>(Emote{
EmoteName{emoji->value}, ImageSet{Image::fromUrl({url}, 0.35)}, EmoteName{emoji->value},
ImageSet{Image::fromUrl({url}, 0.35, {64, 64})},
Tooltip{":" + emoji->shortCodes[0] + ":<br/>Emoji"}, Url{}}); Tooltip{":" + emoji->shortCodes[0] + ":<br/>Emoji"}, Url{}});
} }
}); });

View file

@ -3,6 +3,7 @@
#include "common/network/NetworkRequest.hpp" #include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp" #include "common/network/NetworkResult.hpp"
#include "messages/Emote.hpp" #include "messages/Emote.hpp"
#include "messages/Image.hpp"
#include "providers/ffz/FfzUtil.hpp" #include "providers/ffz/FfzUtil.hpp"
#include <QJsonArray> #include <QJsonArray>
@ -68,12 +69,20 @@ void FfzBadges::load()
{ {
auto jsonBadge = jsonBadge_.toObject(); auto jsonBadge = jsonBadge_.toObject();
auto jsonUrls = jsonBadge.value("urls").toObject(); auto jsonUrls = jsonBadge.value("urls").toObject();
QSize baseSize(jsonBadge["width"].toInt(18),
jsonBadge["height"].toInt(18));
auto emote = auto emote = Emote{
Emote{EmoteName{}, EmoteName{},
ImageSet{parseFfzUrl(jsonUrls.value("1").toString()), ImageSet{Image::fromUrl(
parseFfzUrl(jsonUrls.value("1").toString()),
1.0, baseSize),
Image::fromUrl(
parseFfzUrl(jsonUrls.value("2").toString()), parseFfzUrl(jsonUrls.value("2").toString()),
parseFfzUrl(jsonUrls.value("4").toString())}, 0.5, baseSize * 2),
Image::fromUrl(
parseFfzUrl(jsonUrls.value("4").toString()),
0.25, baseSize * 4)},
Tooltip{jsonBadge.value("title").toString()}, Url{}}; Tooltip{jsonBadge.value("title").toString()}, Url{}};
Badge badge; Badge badge;

View file

@ -20,6 +20,10 @@ const auto &LOG = chatterinoFfzemotes;
const QString CHANNEL_HAS_NO_EMOTES( const QString CHANNEL_HAS_NO_EMOTES(
"This channel has no FrankerFaceZ channel emotes."); "This channel has no FrankerFaceZ channel emotes.");
// FFZ doesn't provide any data on the size for room badges,
// so we assume 18x18 (same as a Twitch badge)
constexpr QSize BASE_BADGE_SIZE(18, 18);
Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale) Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale)
{ {
auto emote = urls[emoteScale]; auto emote = urls[emoteScale];
@ -33,20 +37,23 @@ Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale)
return parseFfzUrl(emote.toString()); return parseFfzUrl(emote.toString());
} }
void fillInEmoteData(const QJsonObject &urls, const EmoteName &name, void fillInEmoteData(const QJsonObject &emote, const QJsonObject &urls,
const QString &tooltip, Emote &emoteData) const EmoteName &name, const QString &tooltip,
Emote &emoteData)
{ {
auto url1x = getEmoteLink(urls, "1"); auto url1x = getEmoteLink(urls, "1");
auto url2x = getEmoteLink(urls, "2"); auto url2x = getEmoteLink(urls, "2");
auto url3x = getEmoteLink(urls, "4"); auto url3x = getEmoteLink(urls, "4");
QSize baseSize(emote["width"].toInt(28), emote["height"].toInt(28));
//, code, tooltip //, code, tooltip
emoteData.name = name; emoteData.name = name;
emoteData.images = ImageSet{ emoteData.images = ImageSet{
Image::fromUrl(url1x, 1), Image::fromUrl(url1x, 1, baseSize),
url2x.string.isEmpty() ? Image::getEmpty() : Image::fromUrl(url2x, 0.5), url2x.string.isEmpty() ? Image::getEmpty()
: Image::fromUrl(url2x, 0.5, baseSize * 2),
url3x.string.isEmpty() ? Image::getEmpty() url3x.string.isEmpty() ? Image::getEmpty()
: Image::fromUrl(url3x, 0.25)}; : Image::fromUrl(url3x, 0.25, baseSize * 4)};
emoteData.tooltip = {tooltip}; emoteData.tooltip = {tooltip};
} }
@ -78,7 +85,7 @@ void parseEmoteSetInto(const QJsonObject &emoteSet, const QString &kind,
} }
Emote emote; Emote emote;
fillInEmoteData(urls, name, fillInEmoteData(emoteJson, urls, name,
QString("%1<br>%2 FFZ Emote<br>By: %3") QString("%1<br>%2 FFZ Emote<br>By: %3")
.arg(name.string, kind, author.string), .arg(name.string, kind, author.string),
emote); emote);
@ -132,13 +139,13 @@ std::optional<EmotePtr> parseAuthorityBadge(const QJsonObject &badgeUrls,
auto authorityBadge3x = getEmoteLink(badgeUrls, "4"); auto authorityBadge3x = getEmoteLink(badgeUrls, "4");
auto authorityBadgeImageSet = ImageSet{ auto authorityBadgeImageSet = ImageSet{
Image::fromUrl(authorityBadge1x, 1), Image::fromUrl(authorityBadge1x, 1, BASE_BADGE_SIZE),
authorityBadge2x.string.isEmpty() authorityBadge2x.string.isEmpty()
? Image::getEmpty() ? Image::getEmpty()
: Image::fromUrl(authorityBadge2x, 0.5), : Image::fromUrl(authorityBadge2x, 0.5, BASE_BADGE_SIZE * 2),
authorityBadge3x.string.isEmpty() authorityBadge3x.string.isEmpty()
? Image::getEmpty() ? Image::getEmpty()
: Image::fromUrl(authorityBadge3x, 0.25), : Image::fromUrl(authorityBadge3x, 0.25, BASE_BADGE_SIZE * 4),
}; };
authorityBadge = std::make_shared<Emote>(Emote{ authorityBadge = std::make_shared<Emote>(Emote{

View file

@ -463,7 +463,7 @@ ImageSet SeventvEmotes::createImageSet(const QJsonObject &emoteData)
auto image = Image::fromUrl( auto image = Image::fromUrl(
{QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, {QString("https:%1/%2").arg(baseUrl, file["name"].toString())},
scale); scale, {static_cast<int>(width), file["height"].toInt(16)});
sizes.at(nextSize) = image; sizes.at(nextSize) = image;
nextSize++; nextSize++;

View file

@ -27,22 +27,31 @@ ChannelPointReward::ChannelPointReward(const QJsonObject &redemption)
} }
auto imageValue = reward.value("image"); auto imageValue = reward.value("image");
// From Twitch docs
// The size is only an estimation, the actual size might vary.
constexpr QSize baseSize(28, 28);
if (imageValue.isObject()) if (imageValue.isObject())
{ {
auto imageObject = imageValue.toObject(); auto imageObject = imageValue.toObject();
this->image = ImageSet{ this->image = ImageSet{
Image::fromUrl({imageObject.value("url_1x").toString()}, 1), Image::fromUrl({imageObject.value("url_1x").toString()}, 1,
Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5), baseSize),
Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25), Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5,
baseSize * 2),
Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25,
baseSize * 4),
}; };
} }
else else
{ {
static const ImageSet defaultImage{ static const ImageSet defaultImage{
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1), Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1,
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("2.png")}, 0.5), baseSize),
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("4.png")}, 0.25)}; Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("2.png")}, 0.5,
baseSize * 2),
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("4.png")}, 0.25,
baseSize * 4)};
this->image = defaultImage; this->image = defaultImage;
} }
} }

View file

@ -18,6 +18,13 @@
#include <QThread> #include <QThread>
#include <QUrlQuery> #include <QUrlQuery>
namespace {
// From Twitch docs - expected size for a badge (1x)
constexpr QSize BADGE_BASE_SIZE(18, 18);
} // namespace
namespace chatterino { namespace chatterino {
void TwitchBadges::loadTwitchBadges() void TwitchBadges::loadTwitchBadges()
@ -37,9 +44,12 @@ void TwitchBadges::loadTwitchBadges()
.name = EmoteName{}, .name = EmoteName{},
.images = .images =
ImageSet{ ImageSet{
Image::fromUrl(version.imageURL1x, 1), Image::fromUrl(version.imageURL1x, 1,
Image::fromUrl(version.imageURL2x, .5), BADGE_BASE_SIZE),
Image::fromUrl(version.imageURL4x, .25), Image::fromUrl(version.imageURL2x, .5,
BADGE_BASE_SIZE * 2),
Image::fromUrl(version.imageURL4x, .25,
BADGE_BASE_SIZE * 4),
}, },
.tooltip = Tooltip{version.title}, .tooltip = Tooltip{version.title},
.homePage = version.clickURL, .homePage = version.clickURL,
@ -100,17 +110,19 @@ void TwitchBadges::parseTwitchBadges(QJsonObject root)
for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt) for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt)
{ {
auto versionObj = vIt.value().toObject(); auto versionObj = vIt.value().toObject();
auto emote = Emote{ auto emote = Emote{
.name = {""}, .name = {""},
.images = .images =
ImageSet{ ImageSet{
Image::fromUrl( Image::fromUrl(
{versionObj.value("image_url_1x").toString()}, 1), {versionObj.value("image_url_1x").toString()}, 1,
BADGE_BASE_SIZE),
Image::fromUrl( Image::fromUrl(
{versionObj.value("image_url_2x").toString()}, .5), {versionObj.value("image_url_2x").toString()}, .5,
BADGE_BASE_SIZE * 2),
Image::fromUrl( Image::fromUrl(
{versionObj.value("image_url_4x").toString()}, .25), {versionObj.value("image_url_4x").toString()}, .25,
BADGE_BASE_SIZE * 4),
}, },
.tooltip = Tooltip{versionObj.value("title").toString()}, .tooltip = Tooltip{versionObj.value("title").toString()},
.homePage = Url{versionObj.value("click_url").toString()}, .homePage = Url{versionObj.value("click_url").toString()},

View file

@ -72,6 +72,9 @@ namespace {
// Maximum number of chatters to fetch when refreshing chatters // Maximum number of chatters to fetch when refreshing chatters
constexpr auto MAX_CHATTERS_TO_FETCH = 5000; constexpr auto MAX_CHATTERS_TO_FETCH = 5000;
// From Twitch docs - expected size for a badge (1x)
constexpr QSize BASE_BADGE_SIZE(18, 18);
} // namespace } // namespace
TwitchChannel::TwitchChannel(const QString &name) TwitchChannel::TwitchChannel(const QString &name)
@ -1466,9 +1469,12 @@ void TwitchChannel::refreshBadges()
.name = EmoteName{}, .name = EmoteName{},
.images = .images =
ImageSet{ ImageSet{
Image::fromUrl(version.imageURL1x, 1), Image::fromUrl(version.imageURL1x, 1,
Image::fromUrl(version.imageURL2x, .5), BASE_BADGE_SIZE),
Image::fromUrl(version.imageURL4x, .25), Image::fromUrl(version.imageURL2x, .5,
BASE_BADGE_SIZE * 2),
Image::fromUrl(version.imageURL4x, .25,
BASE_BADGE_SIZE * 4),
}, },
.tooltip = Tooltip{version.title}, .tooltip = Tooltip{version.title},
.homePage = version.clickURL, .homePage = version.clickURL,
@ -1543,25 +1549,25 @@ void TwitchChannel::refreshCheerEmotes()
// Combine the prefix (e.g. BibleThump) with the tier (1, 100 etc.) // Combine the prefix (e.g. BibleThump) with the tier (1, 100 etc.)
auto emoteTooltip = auto emoteTooltip =
set.prefix + tier.id + "<br>Twitch Cheer Emote"; set.prefix + tier.id + "<br>Twitch Cheer Emote";
auto makeImageSet = [](const HelixCheermoteImage &image) {
return ImageSet{
Image::fromUrl(image.imageURL1x, 1.0,
BASE_BADGE_SIZE),
Image::fromUrl(image.imageURL2x, 0.5,
BASE_BADGE_SIZE * 2),
Image::fromUrl(image.imageURL4x, 0.25,
BASE_BADGE_SIZE * 4),
};
};
cheerEmote.animatedEmote = std::make_shared<Emote>(Emote{ cheerEmote.animatedEmote = std::make_shared<Emote>(Emote{
.name = EmoteName{"cheer emote"}, .name = EmoteName{"cheer emote"},
.images = .images = makeImageSet(tier.darkAnimated),
ImageSet{
tier.darkAnimated.imageURL1x,
tier.darkAnimated.imageURL2x,
tier.darkAnimated.imageURL4x,
},
.tooltip = Tooltip{emoteTooltip}, .tooltip = Tooltip{emoteTooltip},
.homePage = Url{}, .homePage = Url{},
}); });
cheerEmote.staticEmote = std::make_shared<Emote>(Emote{ cheerEmote.staticEmote = std::make_shared<Emote>(Emote{
.name = EmoteName{"cheer emote"}, .name = EmoteName{"cheer emote"},
.images = .images = makeImageSet(tier.darkStatic),
ImageSet{
tier.darkStatic.imageURL1x,
tier.darkStatic.imageURL2x,
tier.darkStatic.imageURL4x,
},
.tooltip = Tooltip{emoteTooltip}, .tooltip = Tooltip{emoteTooltip},
.homePage = Url{}, .homePage = Url{},
}); });

View file

@ -44,12 +44,14 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id,
if (!shared) if (!shared)
{ {
// From Twitch docs - expected size for an emote (1x)
constexpr QSize baseSize(28, 28);
(*cache)[id] = shared = std::make_shared<Emote>(Emote{ (*cache)[id] = shared = std::make_shared<Emote>(Emote{
EmoteName{name}, EmoteName{name},
ImageSet{ ImageSet{
Image::fromUrl(getEmoteLink(id, "1.0"), 1), Image::fromUrl(getEmoteLink(id, "1.0"), 1, baseSize),
Image::fromUrl(getEmoteLink(id, "2.0"), 0.5), Image::fromUrl(getEmoteLink(id, "2.0"), 0.5, baseSize * 2),
Image::fromUrl(getEmoteLink(id, "3.0"), 0.25), Image::fromUrl(getEmoteLink(id, "3.0"), 0.25, baseSize * 4),
}, },
Tooltip{name.toHtmlEscaped() + "<br>Twitch Emote"}, Tooltip{name.toHtmlEscaped() + "<br>Twitch Emote"},
}); });