diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index a238fc6..9122891 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index 4719b01..37dd592 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -54,6 +54,7 @@ "amount_details": "BETRAGSDETAILS", "amount_exceeds_balance": "Betrag übersteigt Guthaben", "amount_label": "Betrag:", + "animate_avatars": "Avatare animieren", "appearance": "ERSCHEINUNGSBILD", "auto_shield": "Mining automatisch abschirmen", "av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:", @@ -1329,6 +1330,7 @@ "try_again": "Erneut versuchen", "tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer", "tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten", + "tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild", "tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren", "tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben", "tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen", diff --git a/res/lang/es.json b/res/lang/es.json index 2550623..6d00b90 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -54,6 +54,7 @@ "amount_details": "DETALLES DE CANTIDAD", "amount_exceeds_balance": "La cantidad excede el saldo", "amount_label": "Cantidad:", + "animate_avatars": "Animar avatares", "appearance": "APARIENCIA", "auto_shield": "Auto-proteger minería", "av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:", @@ -1329,6 +1330,7 @@ "try_again": "Reintentar", "tt_addr_url": "URL base para ver direcciones en un explorador de bloques", "tt_address_book": "Administrar direcciones guardadas para envío rápido", + "tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma", "tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad", "tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad", "tt_backup": "Crear una copia de seguridad de su wallet.dat", diff --git a/res/lang/fr.json b/res/lang/fr.json index 78b0f10..af9c069 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -54,6 +54,7 @@ "amount_details": "DÉTAILS DU MONTANT", "amount_exceeds_balance": "Le montant dépasse le solde", "amount_label": "Montant :", + "animate_avatars": "Animer les avatars", "appearance": "APPARENCE", "auto_shield": "Auto-blindage du minage", "av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :", @@ -1329,6 +1330,7 @@ "try_again": "Réessayer", "tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs", "tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide", + "tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image", "tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité", "tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité", "tt_backup": "Créer une sauvegarde de votre wallet.dat", diff --git a/res/lang/ja.json b/res/lang/ja.json index b015d26..7428422 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -54,6 +54,7 @@ "amount_details": "金額の詳細", "amount_exceeds_balance": "金額が残高を超えています", "amount_label": "金額:", + "animate_avatars": "アバターをアニメーション", "appearance": "外観", "auto_shield": "マイニング自動シールド", "av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。", @@ -1329,6 +1330,7 @@ "try_again": "再試行", "tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL", "tt_address_book": "クイック送信用の保存済みアドレスを管理", + "tt_animate_avatars": "アニメーション連絡先アバター(GIF / WebP)を再生。オフでは最初のフレームのみ表示", "tt_auto_lock": "この無操作時間後にウォレットをロック", "tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動", "tt_backup": "wallet.dat のバックアップを作成", diff --git a/res/lang/ko.json b/res/lang/ko.json index c78aeab..3a0fe6d 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -54,6 +54,7 @@ "amount_details": "금액 상세", "amount_exceeds_balance": "금액이 잔액을 초과합니다", "amount_label": "금액:", + "animate_avatars": "아바타 애니메이션", "appearance": "외관", "auto_shield": "채굴 자동 차폐", "av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:", @@ -1329,6 +1330,7 @@ "try_again": "다시 시도", "tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL", "tt_address_book": "빠른 전송을 위해 저장된 주소 관리", + "tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시", "tt_auto_lock": "이 비활성 시간 후 지갑 잠금", "tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동", "tt_backup": "wallet.dat 백업 만들기", diff --git a/res/lang/pt.json b/res/lang/pt.json index 7b8864a..561cff4 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -54,6 +54,7 @@ "amount_details": "DETALHES DO VALOR", "amount_exceeds_balance": "Valor excede o saldo", "amount_label": "Valor:", + "animate_avatars": "Animar avatares", "appearance": "APARÊNCIA", "auto_shield": "Auto-blindar mineração", "av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:", @@ -1329,6 +1330,7 @@ "try_again": "Tentar novamente", "tt_addr_url": "URL base para visualizar endereços em um explorador de blocos", "tt_address_book": "Gerenciar endereços salvos para envio rápido", + "tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro", "tt_auto_lock": "Bloquear carteira após este tempo de inatividade", "tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade", "tt_backup": "Criar um backup do seu wallet.dat", diff --git a/res/lang/ru.json b/res/lang/ru.json index 1d6ff49..9bbbdb0 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -54,6 +54,7 @@ "amount_details": "ДЕТАЛИ СУММЫ", "amount_exceeds_balance": "Сумма превышает баланс", "amount_label": "Сумма:", + "animate_avatars": "Анимировать аватары", "appearance": "ВНЕШНИЙ ВИД", "auto_shield": "Авто-экранирование майнинга", "av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:", @@ -1329,6 +1330,7 @@ "try_again": "Повторить", "tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков", "tt_address_book": "Управление сохранёнными адресами для быстрой отправки", + "tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр", "tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия", "tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности", "tt_backup": "Создать резервную копию вашего wallet.dat", diff --git a/res/lang/zh.json b/res/lang/zh.json index 87d39d6..c032a87 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -54,6 +54,7 @@ "amount_details": "金额详情", "amount_exceeds_balance": "金额超过余额", "amount_label": "金额:", + "animate_avatars": "动画头像", "appearance": "外观", "auto_shield": "自动屏蔽挖矿", "av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:", @@ -1329,6 +1330,7 @@ "try_again": "重试", "tt_addr_url": "在区块浏览器中查看地址的基础 URL", "tt_address_book": "管理已保存的地址以快速发送", + "tt_animate_avatars": "播放动画联系人头像(GIF / WebP);关闭时仅显示第一帧", "tt_auto_lock": "在此不活动时间后锁定钱包", "tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私", "tt_backup": "创建 wallet.dat 的备份", diff --git a/src/config/settings.cpp b/src/config/settings.cpp index f8895a7..fcd1b57 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -176,6 +176,7 @@ bool Settings::load(const std::string& path) if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0; loadScalar(j, "contacts_view_mode", contacts_view_mode_); if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0; + loadScalar(j, "animate_avatars", animate_avatars_); loadScalar(j, "scanline_enabled", scanline_enabled_); loadScalar(j, "console_line_accents", console_line_accents_); loadScalar(j, "console_text_color", console_text_color_); @@ -431,6 +432,7 @@ bool Settings::save(const std::string& path) j["balance_layout"] = balance_layout_; // saved as string ID j["portfolio_style"] = portfolio_style_; j["contacts_view_mode"] = contacts_view_mode_; + j["animate_avatars"] = animate_avatars_; j["scanline_enabled"] = scanline_enabled_; j["console_line_accents"] = console_line_accents_; j["console_text_color"] = console_text_color_; diff --git a/src/config/settings.h b/src/config/settings.h index 89b0cdf..67c32b8 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -187,6 +187,9 @@ public: // Contacts tab address-list view: 0 = cards, 1 = list, 2 = table. int getContactsViewMode() const { return contacts_view_mode_; } void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; } + // Play animated contact avatars (GIF/WebP). Off = show the first frame only. + bool getAnimateAvatars() const { return animate_avatars_; } + void setAnimateAvatars(bool v) { animate_avatars_ = v; } // Console scanline effect bool getScanlineEnabled() const { return scanline_enabled_; } @@ -494,6 +497,7 @@ private: std::string balance_layout_ = "classic"; int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero) int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table) + bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars bool scanline_enabled_ = true; bool console_line_accents_ = true; // left color accent bars in console output bool console_text_color_ = true; // per-channel text coloring in console output diff --git a/src/main.cpp b/src/main.cpp index 00fcbab..e9a3070 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include "ui/schema/ui_schema.h" #include "ui/effects/low_spec.h" #include "ui/notifications.h" +#include "ui/windows/contacts_tab.h" #include "ui/theme.h" #include "ui/material/color_theme.h" #include "ui/material/typography.h" @@ -1970,7 +1971,8 @@ int main(int argc, char* argv[]) || app.isTransactionRefreshInProgress() || dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation() || dragonx::ui::Notifications::instance().hasActive() - || dragonx::ui::material::SmoothScrollAnimating(); + || dragonx::ui::material::SmoothScrollAnimating() + || dragonx::ui::ConsumeContactsAvatarAnimation(); // If nothing is happening, allow the next iteration to idle needsRedraw = uiActive || animating; } diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index f08c620..e5cf22a 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -899,6 +899,16 @@ void RenderSettingsPage(App* app) { } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects")); + ImGui::SameLine(0, Layout::spacingLg()); + { + bool anim = app->settings()->getAnimateAvatars(); + if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) { + app->settings()->setAnimateAvatars(anim); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars")); + } + // Console output color toggles (own row — no GPU cost, enabled even in low-spec). renderConsoleColorToggles(app); @@ -1154,6 +1164,16 @@ void RenderSettingsPage(App* app) { } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects")); + ImGui::SameLine(0, Layout::spacingLg()); + { + bool anim = app->settings()->getAnimateAvatars(); + if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) { + app->settings()->setAnimateAvatars(anim); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars")); + } + // Console output color toggles (own row — no GPU cost, enabled even in low-spec). renderConsoleColorToggles(app); diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index b8ac673..273b011 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -67,59 +68,67 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne || toLower(e.notes).find(needleLower) != std::string::npos; } -// Lazily-loaded, path-keyed cache of custom avatar image textures (loaded once, kept for the session). -// Textures are box-downscaled to <=kAvatarTexMax px so a big source photo can't cost tens of MB of -// VRAM — an avatar renders at most ~112px (the edit-dialog preview), so 256 is ample. Decoding is -// budgeted per frame (s_avatarLoads*) so opening a large library/contact list doesn't stall the UI — -// callers show a placeholder and the image fills in over the next frames. -struct AvatarTex { ImTextureID tex = 0; int w = 0, h = 0; }; +// Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE +// (1 frame for stills, N for animated GIF/WebP), box-downscaled by texture_loader to bound VRAM. +// Decoding is budgeted per frame so opening a large library/contact list doesn't stall the UI — callers +// show a placeholder and the image fills in over the next frames. +struct AvatarTex { + std::vector frames; // 1 = still, N = animated + std::vector delays; // seconds per frame (parallel to frames) + float totalDur = 0.0f; + int w = 0, h = 0; +}; static std::unordered_map s_avatarTexCache; -static int s_avatarLoadsThisFrame = 0; -static constexpr int kAvatarTexMax = 256; -static constexpr int kAvatarLoadsPerFrame = 3; +static int s_avatarLoadsThisFrame = 0; +static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab +static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing +static constexpr int kAvatarLoadsPerFrame = 2; +static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode) static const AvatarTex* getAvatarTexture(const std::string& path) { auto it = s_avatarTexCache.find(path); - if (it != s_avatarTexCache.end()) return it->second.tex ? &it->second : nullptr; + if (it != s_avatarTexCache.end()) return it->second.frames.empty() ? nullptr : &it->second; if (s_avatarLoadsThisFrame >= kAvatarLoadsPerFrame) return nullptr; // defer to a later frame s_avatarLoadsThisFrame++; AvatarTex at; - int sw = 0, sh = 0; - unsigned char* raw = util::LoadRawPixelsFromFile(path.c_str(), &sw, &sh); - if (raw && sw > 0 && sh > 0) { - int dw = sw, dh = sh; - if (sw > kAvatarTexMax || sh > kAvatarTexMax) { - float s = (float)kAvatarTexMax / (float)std::max(sw, sh); - dw = std::max(1, (int)(sw * s)); - dh = std::max(1, (int)(sh * s)); - } - std::vector small((size_t)dw * dh * 4); - for (int y = 0; y < dh; y++) { - int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh); - for (int x = 0; x < dw; x++) { - int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw); - uint32_t r=0,g=0,b=0,a=0,cnt=0; - for (int yy = sy0; yy < sy1; yy++) - for (int xx = sx0; xx < sx1; xx++) { - const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4; - r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++cnt; - } - unsigned char* d = small.data() + ((size_t)y * dw + x) * 4; - d[0]=(unsigned char)(r/cnt); d[1]=(unsigned char)(g/cnt); d[2]=(unsigned char)(b/cnt); d[3]=(unsigned char)(a/cnt); + util::AnimFrames af; + if (util::LoadAnimatedRGBA(path.c_str(), kAvatarMaxFrames, af) && !af.frames.empty()) { + at.w = af.w; at.h = 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)) { + at.frames.push_back(t); + at.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.0f); } } - if (util::CreateRawTexture(small.data(), dw, dh, false, &at.tex)) { at.w = dw; at.h = dh; } + for (float d : at.delays) at.totalDur += d; } - if (raw) util::FreeRawPixels(raw); - auto& slot = (s_avatarTexCache[path] = at); - return slot.tex ? &slot : nullptr; + auto& slot = (s_avatarTexCache[path] = std::move(at)); + return slot.frames.empty() ? nullptr : &slot; } -// Drop a cached avatar texture so the next getAvatarTexture reloads from disk — needed when the file -// at that path is overwritten (re-picking the same source path whose contents changed). + +// The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting +// is on — the animated frame for the current ImGui clock time (and flag that an animation is live, so +// the render loop keeps producing frames instead of idling). +static ImTextureID currentAvatarFrame(const AvatarTex* t) { + if (!t || t->frames.empty()) return 0; + if (t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) { + s_avatarAnimatedThisFrame = true; + double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur); + double acc = 0.0; + for (size_t i = 0; i < t->delays.size() && i < t->frames.size(); ++i) { + acc += t->delays[i]; + if (phase < acc) return t->frames[i]; + } + } + return t->frames[0]; +} + +// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk. static void invalidateAvatarTexture(const std::string& path) { auto it = s_avatarTexCache.find(path); if (it == s_avatarTexCache.end()) return; - if (it->second.tex) util::DestroyTexture(it->second.tex); + for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t); s_avatarTexCache.erase(it); } @@ -130,11 +139,13 @@ static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::Add ImFont* letterFont, ImFont* iconFont) { const std::string& av = e.avatar; if (av.rfind("img:", 0) == 0) { - if (const AvatarTex* t = getAvatarTexture(av.substr(4))) { + const AvatarTex* t = getAvatarTexture(av.substr(4)); + ImTextureID tex = currentAvatarFrame(t); + if (tex) { float u0 = 0, v0 = 0, u1 = 1, v1 = 1; // centre-crop to a square so the circle isn't stretched 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; } - dl->AddImageRounded(t->tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r), + dl->AddImageRounded(tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r), ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, r); dl->AddCircle(c, r, material::WithAlpha(material::OnSurface(), 45), 0, 1.0f * dp); return; @@ -240,9 +251,15 @@ static void deleteAvatarLibraryFile(const std::string& path) { s_avatarLibraryDirty = true; } +// 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; } + void RenderContactsTab(App* app) { s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture) + s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars(); auto& S = schema::UI(); // Reuse the existing address-book schema/column config for the table + add/edit form. auto addrTable = S.table("dialogs.address-book", "address-table"); @@ -659,12 +676,13 @@ void RenderContactsTab(App* app) bool hov = ImGui::IsMouseHoveringRect(mn, mx); gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), 6.0f * dp); const AvatarTex* t = ImGui::IsRectVisible(mn, mx) ? getAvatarTexture(path) : nullptr; - if (t && t->tex) { + ImTextureID ttex = currentAvatarFrame(t); + if (ttex) { 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; } float ins = 2.0f * dp; - gdl->AddImageRounded(t->tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins), + gdl->AddImageRounded(ttex, 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 { ImFont* gf = material::Type().iconLarge(); diff --git a/src/ui/windows/contacts_tab.h b/src/ui/windows/contacts_tab.h index 71d298a..691a294 100644 --- a/src/ui/windows/contacts_tab.h +++ b/src/ui/windows/contacts_tab.h @@ -21,6 +21,10 @@ namespace ui { */ void RenderContactsTab(App* app); +// True if an animated avatar frame was drawn since the previous call (clear-on-read). The main render +// loop uses this to keep drawing while an avatar animation plays, then idle when it stops. +bool ConsumeContactsAvatarAnimation(); + // UI-sweep ONLY: open the revamped add/edit dialog on a seeded demo contact, in the given avatar // mode (0 = badge, 1 = icon, 2 = image), so the sweep can capture the preview + avatar picker. // Pair with ContactsSweepCloseDialog(). Do not use outside the sweep. diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index be24c3d..2e67a47 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -389,6 +389,7 @@ void I18n::loadBuiltinEnglish() strings_["simple_background"] = "Simple background"; strings_["console_scanline"] = "Console scanline"; strings_["theme_effects"] = "Theme effects"; + strings_["animate_avatars"] = "Animate avatars"; strings_["acrylic"] = "Acrylic"; strings_["noise"] = "Noise"; strings_["ui_opacity"] = "UI Opacity"; @@ -613,6 +614,7 @@ void I18n::loadBuiltinEnglish() strings_["tt_simple_bg_alt"] = "Use a gradient version of the theme background image\nHotkey: Ctrl+Up"; strings_["tt_scanline"] = "CRT scanline effect in console"; strings_["tt_theme_effects"] = "Shimmer, glow, hue-cycling per theme"; + strings_["tt_animate_avatars"] = "Play animated (GIF / WebP) contact avatars; off shows the first frame only"; strings_["tt_blur"] = "Blur amount (0%% = off, 100%% = maximum)"; strings_["tt_noise"] = "Grain texture intensity (0%% = off, 100%% = maximum)"; strings_["tt_ui_opacity"] = "Card and sidebar opacity (100%% = fully opaque, lower = more see-through)"; diff --git a/src/util/texture_loader.cpp b/src/util/texture_loader.cpp index 4fdaa4c..e31217d 100644 --- a/src/util/texture_loader.cpp +++ b/src/util/texture_loader.cpp @@ -23,6 +23,8 @@ #include #include // WebPAnimDecoder — animated WebP +#include +#include #include #include #include @@ -342,6 +344,129 @@ void FreeRawPixels(unsigned char* pixels) if (pixels) stbi_image_free(pixels); } +// Box-average downscale an RGBA image to dw*dh, appending the result as a new frame in `out`. +static void AppendDownscaledFrame(const unsigned char* src, int sw, int sh, int dw, int dh, + std::vector>& out) +{ + out.emplace_back(); + std::vector& dst = out.back(); + dst.resize((size_t)dw * dh * 4); + for (int y = 0; y < dh; y++) { + int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh); + for (int x = 0; x < dw; x++) { + int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw); + uint32_t r=0,g=0,b=0,a=0,n=0; + for (int yy = sy0; yy < sy1; yy++) + for (int xx = sx0; xx < sx1; xx++) { + const unsigned char* p = src + ((size_t)yy * sw + xx) * 4; + r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n; + } + unsigned char* d = dst.data() + ((size_t)y * dw + x) * 4; + d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n); + } + } +} + +static void FitDims(int sw, int sh, int maxDim, int* dw, int* dh) +{ + *dw = sw; *dh = sh; + if (sw > maxDim || sh > maxDim) { + float s = (float)maxDim / (float)std::max(sw, sh); + *dw = std::max(1, (int)(sw * s)); + *dh = std::max(1, (int)(sh * s)); + } +} + +bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out) +{ + out.frames.clear(); + out.delaysSec.clear(); + out.w = out.h = 0; + std::vector file; + if (!ReadFileToBuffer(path, file)) return false; + const unsigned char* data = file.data(); + const size_t len = file.size(); + if (maxFrames < 1) maxFrames = 1; + + // Static max dimension is generous (an avatar renders <=112px); animations use a smaller cap so a + // long clip can't cost tens of MB of VRAM. We only know if it animates after peeking the frame count. + const int kStillMax = 256, kAnimMax = 128; + + // ---- Animated (or still) WebP via libwebp's demux decoder ---- + if (IsWebP(data, len)) { + WebPData wd; wd.bytes = data; wd.size = len; + WebPAnimDecoderOptions opt; + if (!WebPAnimDecoderOptionsInit(&opt)) return false; + opt.color_mode = MODE_RGBA; + WebPAnimDecoder* dec = WebPAnimDecoderNew(&wd, &opt); + if (!dec) { + // Not an animation container — fall back to a single still decode. + int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h); + if (!px) return false; + int dw,dh; FitDims(w, h, kStillMax, &dw, &dh); + AppendDownscaledFrame(px, w, h, dw, dh, out.frames); + out.delaysSec.push_back(0.0f); out.w = dw; out.h = dh; + free(px); + return true; + } + WebPAnimInfo info; + if (!WebPAnimDecoderGetInfo(dec, &info) || info.canvas_width <= 0 || info.canvas_height <= 0) { + WebPAnimDecoderDelete(dec); return false; + } + const int animated = (info.frame_count > 1); + int dw, dh; FitDims(info.canvas_width, info.canvas_height, animated ? kAnimMax : kStillMax, &dw, &dh); + int prevTs = 0, produced = 0; + while (WebPAnimDecoderHasMoreFrames(dec) && produced < maxFrames) { + uint8_t* buf = nullptr; int ts = 0; + if (!WebPAnimDecoderGetNext(dec, &buf, &ts)) break; // buf valid until next call/delete + AppendDownscaledFrame(buf, info.canvas_width, info.canvas_height, dw, dh, out.frames); + float d = (ts - prevTs) / 1000.0f; prevTs = ts; + out.delaysSec.push_back(std::max(0.02f, d)); + ++produced; + } + WebPAnimDecoderDelete(dec); + if (out.frames.empty()) return false; + out.w = dw; out.h = dh; + return true; + } + + // ---- Animated (or still) GIF via stb ---- + const bool isGif = len >= 6 && memcmp(data, "GIF8", 4) == 0; + if (isGif) { + int* delays = nullptr; int w=0,h=0,z=0,comp=0; + unsigned char* all = stbi_load_gif_from_memory(data, (int)len, &delays, &w, &h, &z, &comp, 4); + if (all && w > 0 && h > 0 && z > 0) { + const int animated = (z > 1); + int dw, dh; FitDims(w, h, animated ? kAnimMax : kStillMax, &dw, &dh); + int frames = std::min(z, maxFrames); + for (int i = 0; i < frames; i++) { + AppendDownscaledFrame(all + (size_t)i * w * h * 4, w, h, dw, dh, out.frames); + float d = (delays && delays[i] > 0) ? delays[i] / 1000.0f : 0.1f; + out.delaysSec.push_back(std::max(0.02f, d)); + } + out.w = dw; out.h = dh; + stbi_image_free(all); + if (delays) stbi_image_free(delays); + return true; + } + if (all) stbi_image_free(all); + if (delays) stbi_image_free(delays); + // fall through to a plain decode if the GIF path failed + } + + // ---- Everything else: a single still frame (PNG/JPEG/BMP/TGA/... and APNG's default image) ---- + { + int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h); + if (!px) return false; + int dw,dh; FitDims(w, h, kStillMax, &dw, &dh); + AppendDownscaledFrame(px, w, h, dw, dh, out.frames); + out.delaysSec.push_back(0.0f); + out.w = dw; out.h = dh; + free(px); + return true; + } +} + void DestroyTexture(ImTextureID texID) { if (!texID) return; diff --git a/src/util/texture_loader.h b/src/util/texture_loader.h index 6580504..826277a 100644 --- a/src/util/texture_loader.h +++ b/src/util/texture_loader.h @@ -6,9 +6,31 @@ #include "imgui.h" +#include + namespace dragonx { namespace util { +/** + * @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations). + */ +struct AnimFrames { + std::vector> frames; ///< each is w*h*4 RGBA (already downscaled) + std::vector delaysSec; ///< per-frame duration in seconds (parallel to frames) + int w = 0; + int h = 0; +}; + +/** + * @brief Decode an image into 1+ downscaled RGBA frames plus per-frame durations. + * + * Animated GIF (via stb) and animated WebP (via libwebp WebPAnimDecoder) return every frame (capped at + * maxFrames); static formats — and APNG, which stb reads as a single image — return one frame. Frames + * are box-downscaled internally (smaller for animations to bound VRAM). The caller uploads each frame + * with CreateRawTexture. Returns false on decode failure. + */ +bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out); + /** * @brief Load a PNG/JPG/BMP image from disk into an OpenGL or DX11 texture. * @param path File path (relative to working directory or absolute)