feat(image-picker): play animated thumbnails on hover

Hovering a GIF/WebP thumbnail in the picker now previews its animation:

- The picker's Thumb gains a lazily-loaded frame sequence (via LoadAnimatedRGBA)
  fetched the first time an animatable (.gif/.webp) thumbnail is hovered; still
  images and other formats keep their single static thumbnail (no re-decode).
- On hover the current frame is drawn on the ImGui clock; leaving the thumbnail
  returns it to the still frame-0 preview.
- A clear-on-read flag (ImagePicker::consumeAnimationActive) is OR'd into
  ConsumeContactsAvatarAnimation so the render loop keeps drawing while a hover
  preview plays and idles otherwise. clearThumbs frees all frame textures too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:22:57 -05:00
parent 4b8d80b2fc
commit fd0cab41f0
2 changed files with 70 additions and 7 deletions

View File

@@ -254,7 +254,13 @@ static void deleteAvatarLibraryFile(const std::string& path) {
// UI-loop accessor: true if an animated avatar frame was drawn since the last call (clear-on-read), so
// the main render loop keeps producing frames while an animation plays and idles once it stops / the
// contacts view is hidden. Declared in contacts_tab.h.
bool ConsumeContactsAvatarAnimation() { bool v = s_avatarAnimatedThisFrame; s_avatarAnimatedThisFrame = false; return v; }
bool ConsumeContactsAvatarAnimation() {
// Also consume the image picker's hover-preview animation flag (the picker renders within this tab).
bool picker = ImagePicker::consumeAnimationActive();
bool v = s_avatarAnimatedThisFrame || picker;
s_avatarAnimatedThisFrame = false;
return v;
}
void RenderContactsTab(App* app)
{

View File

@@ -14,6 +14,7 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <functional>
@@ -229,12 +230,28 @@ public:
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp);
// Only decode thumbnails for cells actually on-screen, and only a few per frame.
const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
if (t && t->tex) {
ImTextureID tex = t ? t->tex : 0;
int tw = t ? t->w : 0, thh = t ? t->h : 0;
// Hovering an animatable image (GIF/WebP) loads its full sequence (once) and plays it.
if (t && hov && isAnimatableName(s_images[i])) {
Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists
loadThumbAnim(mt, abs);
if (mt.frames.size() > 1 && mt.totalDur > 0.0f) {
s_animatingThisFrame = true;
double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
double acc = 0.0;
for (size_t k = 0; k < mt.delays.size() && k < mt.frames.size(); ++k) {
acc += mt.delays[k];
if (phase < acc) { tex = mt.frames[k]; tw = mt.aw; thh = mt.ah; break; }
}
}
}
if (tex) {
float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square
if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; }
else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; }
if (tw > thh) { float m=(tw-thh)*0.5f/tw; u0=m; u1=1-m; }
else if (thh > tw) { float m=(thh-tw)*0.5f/thh; v0=m; v1=1-m; }
const float ins = 3.0f * dp;
dl->AddImageRounded(t->tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
dl->AddImageRounded(tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
} else {
ImVec2 cc(mn.x + cell*0.5f, mn.y + cell*0.5f);
@@ -291,8 +308,35 @@ public:
if (!s_open) clearThumbs(); // release GL textures the frame the picker dismisses
}
public:
// Clear-on-read: true if an animated thumbnail was playing this frame (mouse over it), so the render
// loop keeps drawing while a hover-preview animates. Consumed by ConsumeContactsAvatarAnimation.
static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }
private:
struct Thumb { ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; };
struct Thumb {
ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail
std::vector<ImTextureID> frames; // full sequence (loaded on hover)
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f; bool animTried = false;
};
// Load the full frame sequence for an animated image on first hover (still images stay single-frame).
static void loadThumbAnim(Thumb& th, const std::string& path) {
if (th.animTried) return;
th.animTried = true;
util::AnimFrames af;
if (util::LoadAnimatedRGBA(path.c_str(), kThumbMaxFrames, af) && af.frames.size() > 1) {
th.aw = af.w; th.ah = af.h;
for (size_t i = 0; i < af.frames.size(); ++i) {
ImTextureID t = 0;
if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) {
th.frames.push_back(t);
th.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.1f);
}
}
for (float d : th.delays) th.totalDur += d;
}
}
static bool isImageName(const std::string& name) {
auto lower = name;
@@ -307,6 +351,14 @@ private:
return false;
}
// Only these can hold multiple frames in our decoders; skip the hover re-decode for other formats.
static bool isAnimatableName(const std::string& name) {
auto lo = name;
for (char& c : lo) c = (char)std::tolower((unsigned char)c);
auto ends = [&](const char* e){ std::string s(e); return lo.size()>s.size() && lo.compare(lo.size()-s.size(), s.size(), s)==0; };
return ends(".gif") || ends(".webp");
}
static std::string defaultStartDir() {
namespace fs = std::filesystem;
std::error_code ec;
@@ -356,7 +408,10 @@ private:
}
static void clearThumbs() {
for (auto& kv : s_thumbs) if (kv.second.tex) util::DestroyTexture(kv.second.tex);
for (auto& kv : s_thumbs) {
if (kv.second.tex) util::DestroyTexture(kv.second.tex);
for (ImTextureID f : kv.second.frames) if (f) util::DestroyTexture(f);
}
s_thumbs.clear();
}
@@ -389,8 +444,10 @@ private:
static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled)
static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
static inline bool s_open = false;
static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing
static inline int s_loadedThisFrame = 0;
static inline std::string s_dir;
static inline std::string s_selected;