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: 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: Added estimation for image sizes to avoid layout shifts. (#5192)
## 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::mutex mutex;
@ -329,7 +329,7 @@ ImagePtr Image::fromUrl(const Url &url, qreal scale)
if (!shared)
{
cache[url] = shared = ImagePtr(new Image(url, scale));
cache[url] = shared = ImagePtr(new Image(url, scale, expectedSize));
}
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)
, scale_(scale)
, expectedSize_(expectedSize.isValid() ? expectedSize
: (QSize(16, 16) * scale))
, shouldLoad_(true)
, frames_(std::make_unique<detail::Frames>())
{
@ -477,11 +479,11 @@ int Image::width() const
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
return 16;
// No frames loaded, use the expected size
return static_cast<int>(this->expectedSize_.width() * this->scale_);
}
int Image::height() const
@ -490,11 +492,11 @@ int Image::height() const
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
return 16;
// No frames loaded, use the expected size
return static_cast<int>(this->expectedSize_.height() * this->scale_);
}
void Image::actuallyLoad()

View file

@ -73,7 +73,8 @@ public:
Image(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 getEmpty();
@ -93,7 +94,7 @@ public:
private:
Image();
Image(const Url &url, qreal scale);
Image(const Url &url, qreal scale, QSize expectedSize);
Image(qreal scale);
void setPixmap(const QPixmap &pixmap);
@ -102,12 +103,18 @@ private:
const Url url_{};
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};
mutable std::chrono::time_point<std::chrono::steady_clock> lastUsed_;
bool shouldLoad_{false};
mutable std::chrono::time_point<std::chrono::steady_clock> lastUsed_;
// gui thread only
std::unique_ptr<detail::Frames> frames_{};

View file

@ -22,6 +22,8 @@ namespace {
"This channel has no BetterTTV channel emotes.");
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 {
EmoteId id;
@ -65,9 +67,12 @@ namespace {
auto emote = Emote({
name,
ImageSet{Image::fromUrl(getEmoteLinkV3(id, "1x"), 1),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25)},
ImageSet{Image::fromUrl(getEmoteLinkV3(id, "1x"), 1,
EMOTE_BASE_SIZE),
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"},
Url{emoteLinkFormat.arg(id.string)},
});
@ -90,9 +95,11 @@ namespace {
auto emote = Emote({
name,
ImageSet{
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25),
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1, EMOTE_BASE_SIZE),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5,
EMOTE_BASE_SIZE * 2),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25,
EMOTE_BASE_SIZE * 4),
},
Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")

View file

@ -3,6 +3,7 @@
#include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp"
#include "messages/Emote.hpp"
#include "messages/Image.hpp"
#include <QJsonArray>
#include <QJsonObject>
@ -44,12 +45,23 @@ void ChatterinoBadges::loadChatterinoBadges()
jsonRoot.value("badges").toArray())
{
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{
.name = EmoteName{},
.images =
ImageSet{Url{jsonBadge.value("image1").toString()},
Url{jsonBadge.value("image2").toString()},
Url{jsonBadge.value("image3").toString()}},
ImageSet{
Image::fromUrl(
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()},
.homePage = Url{},
};

View file

@ -285,7 +285,8 @@ void Emojis::loadEmojiSet()
}
QString url = urlPrefix + code + ".png";
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{}});
}
});

View file

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

View file

@ -20,6 +20,10 @@ const auto &LOG = chatterinoFfzemotes;
const QString CHANNEL_HAS_NO_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)
{
auto emote = urls[emoteScale];
@ -33,20 +37,23 @@ Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale)
return parseFfzUrl(emote.toString());
}
void fillInEmoteData(const QJsonObject &urls, const EmoteName &name,
const QString &tooltip, Emote &emoteData)
void fillInEmoteData(const QJsonObject &emote, const QJsonObject &urls,
const EmoteName &name, const QString &tooltip,
Emote &emoteData)
{
auto url1x = getEmoteLink(urls, "1");
auto url2x = getEmoteLink(urls, "2");
auto url3x = getEmoteLink(urls, "4");
QSize baseSize(emote["width"].toInt(28), emote["height"].toInt(28));
//, code, tooltip
emoteData.name = name;
emoteData.images = ImageSet{
Image::fromUrl(url1x, 1),
url2x.string.isEmpty() ? Image::getEmpty() : Image::fromUrl(url2x, 0.5),
Image::fromUrl(url1x, 1, baseSize),
url2x.string.isEmpty() ? Image::getEmpty()
: Image::fromUrl(url2x, 0.5, baseSize * 2),
url3x.string.isEmpty() ? Image::getEmpty()
: Image::fromUrl(url3x, 0.25)};
: Image::fromUrl(url3x, 0.25, baseSize * 4)};
emoteData.tooltip = {tooltip};
}
@ -78,7 +85,7 @@ void parseEmoteSetInto(const QJsonObject &emoteSet, const QString &kind,
}
Emote emote;
fillInEmoteData(urls, name,
fillInEmoteData(emoteJson, urls, name,
QString("%1<br>%2 FFZ Emote<br>By: %3")
.arg(name.string, kind, author.string),
emote);
@ -132,13 +139,13 @@ std::optional<EmotePtr> parseAuthorityBadge(const QJsonObject &badgeUrls,
auto authorityBadge3x = getEmoteLink(badgeUrls, "4");
auto authorityBadgeImageSet = ImageSet{
Image::fromUrl(authorityBadge1x, 1),
Image::fromUrl(authorityBadge1x, 1, BASE_BADGE_SIZE),
authorityBadge2x.string.isEmpty()
? Image::getEmpty()
: Image::fromUrl(authorityBadge2x, 0.5),
: Image::fromUrl(authorityBadge2x, 0.5, BASE_BADGE_SIZE * 2),
authorityBadge3x.string.isEmpty()
? Image::getEmpty()
: Image::fromUrl(authorityBadge3x, 0.25),
: Image::fromUrl(authorityBadge3x, 0.25, BASE_BADGE_SIZE * 4),
};
authorityBadge = std::make_shared<Emote>(Emote{

View file

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

View file

@ -27,22 +27,31 @@ ChannelPointReward::ChannelPointReward(const QJsonObject &redemption)
}
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())
{
auto imageObject = imageValue.toObject();
this->image = ImageSet{
Image::fromUrl({imageObject.value("url_1x").toString()}, 1),
Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5),
Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25),
Image::fromUrl({imageObject.value("url_1x").toString()}, 1,
baseSize),
Image::fromUrl({imageObject.value("url_2x").toString()}, 0.5,
baseSize * 2),
Image::fromUrl({imageObject.value("url_4x").toString()}, 0.25,
baseSize * 4),
};
}
else
{
static const ImageSet defaultImage{
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1),
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("2.png")}, 0.5),
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("4.png")}, 0.25)};
Image::fromUrl({TWITCH_CHANNEL_POINT_REWARD_URL("1.png")}, 1,
baseSize),
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;
}
}

View file

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

View file

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

View file

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