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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user