perf: smooth UI animations and reduce repaint overhead

Batch of performance and animation work: shared FadeHelper/MotionPrefs
primitives, hover animators, shadow pixmap caching, smooth scrolling,
seek time bubble, and widespread component repaint reductions.
This commit is contained in:
dela
2026-07-08 11:29:38 +08:00
parent 03bc33518c
commit ab1accc821
85 changed files with 2308 additions and 661 deletions
+39 -1
View File
@@ -2,11 +2,15 @@
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QStyle>
#include <QStyleOptionButton>
#include <QVBoxLayout>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
class SectionToggleButton : public QPushButton {
public:
@@ -94,6 +98,16 @@ CollapsibleSection::CollapsibleSection(const QString &title, QWidget *parent)
m_contentLayout->setSpacing(8);
mainLayout->addWidget(m_content);
m_heightAnim = new QPropertyAnimation(m_content, "maximumHeight", this);
m_heightAnim->setDuration(AnimationTokens::DurationMs::kNormal);
connect(m_heightAnim, &QPropertyAnimation::finished, this, [this]() {
if (m_expanded) {
m_content->setMaximumHeight(QWIDGETSIZE_MAX);
} else {
m_content->setVisible(false);
}
});
updateToggleText();
connect(m_toggle, &QPushButton::clicked, this,
@@ -101,8 +115,32 @@ CollapsibleSection::CollapsibleSection(const QString &title, QWidget *parent)
}
void CollapsibleSection::setExpanded(bool expanded) {
if (m_expanded == expanded) {
updateToggleText();
return;
}
m_expanded = expanded;
m_content->setVisible(expanded);
m_heightAnim->stop();
if (MotionPrefs::reduceMotion()) {
m_content->setMaximumHeight(QWIDGETSIZE_MAX);
m_content->setVisible(expanded);
updateToggleText();
return;
}
if (expanded) {
m_content->setMaximumHeight(0);
m_content->setVisible(true);
m_heightAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_heightAnim->setStartValue(0);
m_heightAnim->setEndValue(m_content->sizeHint().height());
} else {
m_heightAnim->setEasingCurve(AnimationTokens::Curve::exit());
m_heightAnim->setStartValue(m_content->height());
m_heightAnim->setEndValue(0);
}
m_heightAnim->start();
updateToggleText();
}
@@ -4,6 +4,7 @@
#include <QString>
#include <QWidget>
class QPropertyAnimation;
class QPushButton;
class QVBoxLayout;
@@ -22,6 +23,7 @@ private:
QPushButton* m_toggle;
QWidget* m_content;
QVBoxLayout* m_contentLayout;
QPropertyAnimation* m_heightAnim = nullptr;
bool m_expanded = false;
QString m_title;
};
@@ -1,12 +1,23 @@
#include "collectioncard.h"
#include "collectiongrid.h"
#include "../managers/thememanager.h"
#include "../utils/hoveranimator.h"
#include <QApplication>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
namespace {
QColor lerpColor(const QColor& from, const QColor& to, qreal t)
{
return QColor(qRound(from.red() + (to.red() - from.red()) * t),
qRound(from.green() + (to.green() - from.green()) * t),
qRound(from.blue() + (to.blue() - from.blue()) * t),
qRound(from.alpha() + (to.alpha() - from.alpha()) * t));
}
} // namespace
@@ -40,6 +51,9 @@ CollectionCard::CollectionCard(const QString& itemId,
m_btnRename->setToolTip(tr("Rename"));
m_btnDelete->setToolTip(tr("Delete"));
m_hover = new HoverAnimator(this);
m_hover->setCallback([this](qreal) { update(); });
updateButtonLayout();
connect(m_btnRename, &QPushButton::clicked, this,
@@ -115,6 +129,14 @@ void CollectionCard::paintEvent(QPaintEvent* event)
QColor typeFg = isDark ? QColor("#94A3B8") : QColor("#64748B");
const QColor accent("#3B82F6");
const qreal hp = m_hover->progress();
if (hp > 0.0 && !m_selected && !m_dragging) {
const QColor hoverBorder = isDark ? QColor(96, 165, 250, 190) : QColor(59, 130, 246, 190);
const QColor hoverBg = isDark ? QColor(51, 65, 85, 190) : QColor(255, 255, 255, 255);
border = lerpColor(border, hoverBorder, hp);
cardBg = lerpColor(cardBg, hoverBg, hp);
}
if (m_selected) {
cardBg = isDark ? QColor(30, 41, 59, 220) : QColor(239, 246, 255, 245);
border = isDark ? QColor(96, 165, 250, 220) : QColor(59, 130, 246, 220);
@@ -298,6 +320,18 @@ void CollectionCard::resizeEvent(QResizeEvent* event)
updateButtonLayout();
}
void CollectionCard::enterEvent(QEnterEvent* event)
{
QFrame::enterEvent(event);
m_hover->setHovered(true);
}
void CollectionCard::leaveEvent(QEvent* event)
{
QFrame::leaveEvent(event);
m_hover->setHovered(false);
}
void CollectionCard::toggleSelected()
{
m_selected = !m_selected;
+5
View File
@@ -7,6 +7,8 @@
#include <QPushButton>
class CollectionGrid;
class HoverAnimator;
class QEnterEvent;
class QMouseEvent;
class QResizeEvent;
@@ -44,6 +46,8 @@ protected:
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void enterEvent(QEnterEvent* event) override;
void leaveEvent(QEvent* event) override;
private:
void updateButtonLayout();
@@ -61,6 +65,7 @@ private:
QPushButton* m_btnRename;
QPushButton* m_btnDelete;
HoverAnimator* m_hover = nullptr;
};
#endif
+14 -8
View File
@@ -7,6 +7,8 @@
#include <QScrollBar>
#include <QSet>
#include <QTimer>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
@@ -249,12 +251,12 @@ void CollectionGrid::relayout(bool animate)
}
QPoint target = posForIndex(i, m_columns);
if (animate && card->pos() != target) {
if (animate && !MotionPrefs::reduceMotion() && card->pos() != target) {
stopPosAnimations(card);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(220);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(AnimationTokens::DurationMs::kNormal);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
@@ -319,11 +321,15 @@ void CollectionGrid::onCardDragEnd(CollectionCard* card)
stopPosAnimations(card);
const QPoint target = posForIndex(finalIndex, m_columns);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(180);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
if (MotionPrefs::reduceMotion()) {
card->move(target);
} else {
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(AnimationTokens::DurationMs::kFast);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
}
if (changed && dragCard) {
QStringList newOrderIds;
@@ -9,6 +9,8 @@
#include <QPropertyAnimation>
#include <QResizeEvent>
#include <QStyle>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
constexpr int StripHeight = 36;
@@ -16,7 +18,7 @@ constexpr int ChipHeight = 28;
constexpr int ChipSpacing = 8;
constexpr int ChipHorizontalPadding = 12;
constexpr int DragPreviewYOffset = 5;
constexpr int ChipAnimationDuration = 220;
constexpr int ChipAnimationDuration = AnimationTokens::DurationMs::kNormal;
constexpr qreal DragPreviewOpacity = 0.82;
}
@@ -485,14 +487,14 @@ void DashboardSectionOrderStrip::moveWidgetTo(QWidget* widget,
}
stopPosAnimations(widget);
if (!animate || widget->pos() == targetPos) {
if (!animate || MotionPrefs::reduceMotion() || widget->pos() == targetPos) {
widget->move(targetPos);
return;
}
auto* animation = new QPropertyAnimation(widget, "pos", widget);
animation->setDuration(duration);
animation->setEasingCurve(QEasingCurve::OutCubic);
animation->setEasingCurve(AnimationTokens::Curve::enter());
animation->setEndValue(targetPos);
animation->start(QAbstractAnimation::DeleteWhenStopped);
}
+13 -4
View File
@@ -3,6 +3,7 @@
#include "../managers/thememanager.h"
#include "../utils/mediasourcepreferenceutils.h"
#include "../utils/playerpreferenceutils.h"
#include "../utils/popfeedback.h"
#include "modernmenubutton.h"
#include "splitplayerbutton.h"
#include <QFileInfo>
@@ -105,10 +106,18 @@ DetailActionWidget::DetailActionWidget(QWidget *parent) : QWidget(parent) {
&DetailActionWidget::playRequested);
connect(m_resumeBtn, &QPushButton::clicked, this,
&DetailActionWidget::resumeRequested);
connect(m_favBtn, &QPushButton::clicked, this,
&DetailActionWidget::favoriteRequested);
connect(m_playedBtn, &QPushButton::clicked, this,
&DetailActionWidget::playedToggleRequested);
connect(m_favBtn, &QPushButton::clicked, this, [this]() {
PopFeedback::play(m_favBtn, [btn = m_favBtn](qreal scale) {
btn->setIconSize(QSize(qRound(20 * scale), qRound(20 * scale)));
});
emit favoriteRequested();
});
connect(m_playedBtn, &QPushButton::clicked, this, [this]() {
PopFeedback::play(m_playedBtn, [btn = m_playedBtn](qreal scale) {
btn->setIconSize(QSize(qRound(20 * scale), qRound(20 * scale)));
});
emit playedToggleRequested();
});
connect(m_versionComboBox, &ModernMenuButton::currentIndexChanged, this,
&DetailActionWidget::sourceVersionChanged);
@@ -2,6 +2,9 @@
#include <QPainter>
#include <QStyleOption>
#include <QLinearGradient>
#include <QPropertyAnimation>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
DetailContentWidget::DetailContentWidget(QWidget* parent)
: QWidget(parent),
@@ -14,8 +17,37 @@ DetailContentWidget::DetailContentWidget(QWidget* parent)
}
void DetailContentWidget::setBackdrop(const QPixmap& pix) {
const bool hadBackdrop = !m_backdrop.isNull();
m_backdrop = pix;
m_cachedWidth = -1;
// Fade the hero backdrop in only on first reveal (null -> image), not when a
// network refresh replaces an already-shown backdrop (that would flicker).
if (!pix.isNull() && !hadBackdrop) {
if (MotionPrefs::reduceMotion()) {
m_backdropOpacity = 1.0;
} else {
if (!m_backdropFade) {
m_backdropFade = new QPropertyAnimation(this, "backdropOpacity", this);
m_backdropFade->setDuration(AnimationTokens::DurationMs::kNormal);
m_backdropFade->setEasingCurve(AnimationTokens::Curve::fade());
}
m_backdropFade->stop();
m_backdropFade->setStartValue(0.0);
m_backdropFade->setEndValue(1.0);
m_backdropFade->start();
}
}
update();
}
qreal DetailContentWidget::backdropOpacity() const {
return m_backdropOpacity;
}
void DetailContentWidget::setBackdropOpacity(qreal opacity) {
m_backdropOpacity = opacity;
update();
}
@@ -73,7 +105,9 @@ void DetailContentWidget::paintEvent(QPaintEvent* event) {
}
painter.setOpacity(m_backdropOpacity);
painter.drawPixmap(0, 0, m_scaledBackdrop);
painter.setOpacity(1.0);
QLinearGradient gradient(0, 0, 0, targetHeight);
@@ -5,10 +5,13 @@
#include <QPixmap>
#include <QColor>
class QPropertyAnimation;
class DetailContentWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(QColor baseColor READ baseColor WRITE setBaseColor)
Q_PROPERTY(QColor gradientColor READ gradientColor WRITE setGradientColor)
Q_PROPERTY(qreal backdropOpacity READ backdropOpacity WRITE setBackdropOpacity)
public:
explicit DetailContentWidget(QWidget* parent = nullptr);
@@ -23,6 +26,9 @@ public:
QColor gradientColor() const;
void setGradientColor(const QColor& color);
qreal backdropOpacity() const;
void setBackdropOpacity(qreal opacity);
protected:
void paintEvent(QPaintEvent* event) override;
@@ -34,6 +40,9 @@ private:
QColor m_baseColor;
QColor m_gradientColor;
qreal m_backdropOpacity = 1.0;
QPropertyAnimation* m_backdropFade = nullptr;
};
#endif
@@ -8,6 +8,28 @@
#include <QScrollBar>
#include <QPropertyAnimation>
#include <QEvent>
#include "../tokens/AnimationTokens.h"
#include "../utils/fadehelper.h"
#include "../utils/motionprefs.h"
namespace {
// Fade the nav arrows instead of popping; only reacts to desired-visibility
// changes (this runs on every mouse move).
void setNavButtonVisible(QPushButton* btn, bool visible)
{
const QVariant last = btn->property("navVisible");
if (!last.isNull() && last.toBool() == visible) {
return;
}
btn->setProperty("navVisible", visible);
if (visible) {
FadeHelper::fadeIn(btn, AnimationTokens::DurationMs::kQuick);
} else {
FadeHelper::fadeOut(btn, AnimationTokens::DurationMs::kMicro,
AnimationTokens::Curve::exit(), [btn]() { btn->hide(); });
}
}
} // namespace
#include <QWheelEvent>
#include <QCursor>
#include <QScroller>
@@ -65,8 +87,8 @@ HorizontalListViewGallery::HorizontalListViewGallery(QEmbyCore* core, QWidget* p
m_hScrollAnim = new QPropertyAnimation(m_listView->horizontalScrollBar(), "value", this);
m_hScrollAnim->setEasingCurve(QEasingCurve::OutCubic);
m_hScrollAnim->setDuration(450);
m_hScrollAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_hScrollAnim->setDuration(AnimationTokens::DurationMs::kSlow);
mainLayout->addWidget(m_listView);
@@ -122,9 +144,13 @@ HorizontalListViewGallery::HorizontalListViewGallery(QEmbyCore* core, QWidget* p
int targetValue = bar->value() + directionMultiplier * step;
targetValue = qBound(0, targetValue, bar->maximum());
if (MotionPrefs::reduceMotion()) {
bar->setValue(targetValue);
return;
}
auto* anim = new QPropertyAnimation(bar, "value", this);
anim->setDuration(400);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(AnimationTokens::DurationMs::kSlow);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setStartValue(bar->value());
anim->setEndValue(targetValue);
anim->start(QAbstractAnimation::DeleteWhenStopped);
@@ -166,8 +192,14 @@ void HorizontalListViewGallery::setItems(const QList<MediaItem>& items)
}
if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) {
m_shimmer->stopAnimation();
m_shimmer->hide();
// Cross-fade the skeleton out to reveal the now-populated list underneath.
FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal,
AnimationTokens::Curve::fade(), [this]() {
if (m_shimmer) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
});
}
}
@@ -360,8 +392,17 @@ void HorizontalListViewGallery::setLoading(bool loading)
m_cardStyle == MediaCardDelegate::Cast);
m_shimmer->setGeometry(m_listView->geometry());
m_shimmer->raise();
m_shimmer->show();
// Show + snap opacity back to 1.0 (a prior reveal fade left the effect at 0).
FadeHelper::fadeIn(m_shimmer, 0);
m_shimmer->startAnimation();
} else if (m_shimmer->isVisible()) {
FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal,
AnimationTokens::Curve::fade(), [this]() {
if (m_shimmer) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
});
} else {
m_shimmer->stopAnimation();
m_shimmer->hide();
@@ -420,8 +461,8 @@ bool HorizontalListViewGallery::eventFilter(QObject* obj, QEvent* event)
} else if (event->type() == QEvent::Leave) {
QPoint globalPos = QCursor::pos();
if (!this->rect().contains(this->mapFromGlobal(globalPos))) {
m_btnLeft->hide();
m_btnRight->hide();
setNavButtonVisible(m_btnLeft, false);
setNavButtonVisible(m_btnRight, false);
}
} else if (obj == m_listView->viewport() && event->type() == QEvent::ToolTip) {
return TextWrapUtils::showWrappedMediaItemToolTip(m_listView, event);
@@ -447,9 +488,13 @@ bool HorizontalListViewGallery::eventFilter(QObject* obj, QEvent* event)
if (newTarget != hBar->value()) {
m_hScrollTarget = newTarget;
m_hScrollAnim->stop();
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
if (MotionPrefs::reduceMotion()) {
hBar->setValue(m_hScrollTarget);
} else {
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
}
}
}
return true;
@@ -468,8 +513,8 @@ void HorizontalListViewGallery::updateButtonsVisibility()
QPoint localPos = this->mapFromGlobal(globalPos);
if (!this->rect().contains(localPos)) {
m_btnLeft->hide();
m_btnRight->hide();
setNavButtonVisible(m_btnLeft, false);
setNavButtonVisible(m_btnRight, false);
return;
}
@@ -478,8 +523,8 @@ void HorizontalListViewGallery::updateButtonsVisibility()
bool isLeftHalf = localPos.x() < (currentWidth / 2);
m_btnLeft->setVisible(isLeftHalf && bar->value() > 0);
m_btnRight->setVisible(!isLeftHalf && bar->value() < bar->maximum());
setNavButtonVisible(m_btnLeft, isLeftHalf && bar->value() > 0);
setNavButtonVisible(m_btnRight, !isLeftHalf && bar->value() < bar->maximum());
}
void HorizontalListViewGallery::updateVisibleImagePriority()
@@ -6,6 +6,28 @@
#include <QPropertyAnimation>
#include <QEvent>
#include <QWheelEvent>
#include "../tokens/AnimationTokens.h"
#include "../utils/fadehelper.h"
#include "../utils/motionprefs.h"
namespace {
// Fade the nav arrows instead of popping; only reacts to desired-visibility
// changes (this runs on every mouse move).
void setNavButtonVisible(QPushButton* btn, bool visible)
{
const QVariant last = btn->property("navVisible");
if (!last.isNull() && last.toBool() == visible) {
return;
}
btn->setProperty("navVisible", visible);
if (visible) {
FadeHelper::fadeIn(btn, AnimationTokens::DurationMs::kQuick);
} else {
FadeHelper::fadeOut(btn, AnimationTokens::DurationMs::kMicro,
AnimationTokens::Curve::exit(), [btn]() { btn->hide(); });
}
}
} // namespace
#include <QTimer>
#include <QCursor>
#include <QScroller>
@@ -52,8 +74,8 @@ HorizontalWidgetGallery::HorizontalWidgetGallery(QWidget* parent)
m_hScrollAnim = new QPropertyAnimation(m_scrollArea->horizontalScrollBar(), "value", this);
m_hScrollAnim->setEasingCurve(QEasingCurve::OutCubic);
m_hScrollAnim->setDuration(450);
m_hScrollAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_hScrollAnim->setDuration(AnimationTokens::DurationMs::kSlow);
m_contentWidget = new QWidget(m_scrollArea);
m_contentWidget->setObjectName("gallery-content-widget");
@@ -96,9 +118,13 @@ HorizontalWidgetGallery::HorizontalWidgetGallery(QWidget* parent)
int targetValue = bar->value() + directionMultiplier * step;
targetValue = qBound(0, targetValue, bar->maximum());
if (MotionPrefs::reduceMotion()) {
bar->setValue(targetValue);
return;
}
auto* anim = new QPropertyAnimation(bar, "value", this);
anim->setDuration(400);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(AnimationTokens::DurationMs::kSlow);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setStartValue(bar->value());
anim->setEndValue(targetValue);
anim->start(QAbstractAnimation::DeleteWhenStopped);
@@ -192,8 +218,8 @@ bool HorizontalWidgetGallery::eventFilter(QObject* obj, QEvent* event)
} else if (event->type() == QEvent::Leave) {
QPoint globalPos = QCursor::pos();
if (!this->rect().contains(this->mapFromGlobal(globalPos))) {
m_btnLeft->hide();
m_btnRight->hide();
setNavButtonVisible(m_btnLeft, false);
setNavButtonVisible(m_btnRight, false);
}
} else if (event->type() == QEvent::Wheel) {
@@ -213,9 +239,13 @@ bool HorizontalWidgetGallery::eventFilter(QObject* obj, QEvent* event)
if (newTarget != hBar->value()) {
m_hScrollTarget = newTarget;
m_hScrollAnim->stop();
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
if (MotionPrefs::reduceMotion()) {
hBar->setValue(m_hScrollTarget);
} else {
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
}
}
}
return true;
@@ -240,8 +270,8 @@ void HorizontalWidgetGallery::updateButtonsVisibility()
QPoint localPos = this->mapFromGlobal(globalPos);
if (!this->rect().contains(localPos)) {
m_btnLeft->hide();
m_btnRight->hide();
setNavButtonVisible(m_btnLeft, false);
setNavButtonVisible(m_btnRight, false);
return;
}
@@ -250,6 +280,6 @@ void HorizontalWidgetGallery::updateButtonsVisibility()
bool isLeftHalf = localPos.x() < (currentWidth / 2);
m_btnLeft->setVisible(isLeftHalf && bar->value() > 0);
m_btnRight->setVisible(!isLeftHalf && bar->value() < bar->maximum());
setNavButtonVisible(m_btnLeft, isLeftHalf && bar->value() > 0);
setNavButtonVisible(m_btnRight, !isLeftHalf && bar->value() < bar->maximum());
}
@@ -1,6 +1,7 @@
#include "libraryactionmenu.h"
#include "../managers/thememanager.h"
#include "../utils/popupfade.h"
#include <QAction>
#include <QFile>
#include <QIcon>
@@ -16,6 +17,8 @@ LibraryActionMenu::LibraryActionMenu(const VirtualFolder& folder,
Qt::NoDropShadowWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
PopupFade::attach(this);
setupMenu();
}
+19 -4
View File
@@ -1,8 +1,9 @@
#include "loadingoverlay.h"
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QEasingCurve>
#include <QConicalGradient>
#include <QLinearGradient>
#include <QMouseEvent>
@@ -14,17 +15,26 @@ LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent) {
m_rotateAnim = new QPropertyAnimation(this, "angle", this);
m_rotateAnim->setStartValue(0.0);
m_rotateAnim->setEndValue(360.0);
m_rotateAnim->setDuration(1000);
m_rotateAnim->setDuration(AnimationTokens::DurationMs::kGentle);
m_rotateAnim->setLoopCount(-1);
m_fadeAnim = new QPropertyAnimation(this, "opacity", this);
m_fadeAnim->setDuration(300);
m_fadeAnim->setEasingCurve(QEasingCurve::InOutQuad);
m_fadeAnim->setDuration(AnimationTokens::DurationMs::kNormal);
m_fadeAnim->setEasingCurve(AnimationTokens::Curve::fade());
}
void LoadingOverlay::start() {
show();
raise();
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: static indicator, no spin, no fade.
m_fadeAnim->stop();
m_opacity = 1.0;
update();
return;
}
m_rotateAnim->start();
m_fadeAnim->disconnect();
@@ -34,6 +44,11 @@ void LoadingOverlay::start() {
}
void LoadingOverlay::stop() {
if (MotionPrefs::reduceMotion()) {
forceStop();
return;
}
m_fadeAnim->disconnect();
connect(m_fadeAnim, &QAbstractAnimation::finished, this, [this]() {
hide();
@@ -4,6 +4,7 @@
#include "../utils/mediaidentifyutils.h"
#include "../utils/mediaitemutils.h"
#include "../utils/playlistutils.h"
#include "../utils/popupfade.h"
#include <qembycore.h>
#include <services/manager/servermanager.h>
#include <models/profile/serverprofile.h>
@@ -33,6 +34,8 @@ MediaActionMenu::MediaActionMenu(const MediaItem& item, QEmbyCore* core,
setAttribute(Qt::WA_TranslucentBackground);
PopupFade::attach(this);
setupMenu();
}
+21 -4
View File
@@ -15,6 +15,8 @@
#include <QSet>
#include <QStyleOptionViewItem>
#include <algorithm>
#include "../tokens/AnimationTokens.h"
#include "../utils/fadehelper.h"
MediaGridWidget::MediaGridWidget(QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core), m_basePadding(20), m_currentStyle(MediaCardDelegate::Poster),
@@ -59,7 +61,7 @@ MediaGridWidget::MediaGridWidget(QEmbyCore* core, QWidget* parent)
m_vScrollController =
new SmoothScrollController(m_listView->verticalScrollBar(), this);
m_vScrollController->setDuration(160);
m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast);
m_listView->viewport()->installEventFilter(this);
@@ -141,8 +143,17 @@ void MediaGridWidget::setLoading(bool loading)
m_shimmer->setShowSubtitle(false);
m_shimmer->setGeometry(m_listView->geometry());
m_shimmer->raise();
m_shimmer->show();
// Show + snap opacity back to 1.0 (a prior reveal fade left the effect at 0).
FadeHelper::fadeIn(m_shimmer, 0);
m_shimmer->startAnimation();
} else if (m_shimmer->isVisible()) {
FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal,
AnimationTokens::Curve::fade(), [this]() {
if (m_shimmer) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
});
} else {
m_shimmer->stopAnimation();
m_shimmer->hide();
@@ -155,8 +166,14 @@ void MediaGridWidget::setItems(const QList<MediaItem>& items) {
if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) {
m_shimmer->stopAnimation();
m_shimmer->hide();
// Cross-fade the skeleton out to reveal the now-populated grid underneath.
FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal,
AnimationTokens::Curve::fade(), [this]() {
if (m_shimmer) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
});
}
if (!items.isEmpty()) {
QMetaObject::invokeMethod(
@@ -1,4 +1,6 @@
#include "moderncombobox.h"
#include "../utils/fadehelper.h"
#include "../utils/popupfade.h"
#include <QAbstractItemView>
#include <QApplication>
#include <QEvent>
@@ -132,6 +134,8 @@ void ModernComboBox::showPopup() {
return;
}
PopupFade::run(popupContainer);
auto *vScrollBar = popupView->verticalScrollBar();
@@ -267,6 +271,8 @@ void ModernComboBox::showEmbeddedPopup() {
overlay->show();
overlay->raise();
listFrame->show();
// Child overlay (not a top-level window) — FadeHelper, not PopupFade.
FadeHelper::fadeIn(listFrame, AnimationTokens::DurationMs::kQuick);
listWidget->setFocus();
@@ -1,4 +1,5 @@
#include "modernmenubutton.h"
#include "../utils/popupfade.h"
#include <QAction>
#include <QLabel>
#include <QStyle>
@@ -91,6 +92,7 @@ ModernMenuButton::ModernMenuButton(QWidget *parent)
m_menu->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
m_menu->setAttribute(Qt::WA_TranslucentBackground, true);
m_menu->setAutoFillBackground(false);
PopupFade::attach(m_menu);
setCursor(Qt::PointingHandCursor);
+16 -8
View File
@@ -1,4 +1,5 @@
#include "modernslider.h"
#include "../utils/hoveranimator.h"
#include <QPainter>
#include <QPainterPath>
@@ -7,6 +8,9 @@ ModernSlider::ModernSlider(Qt::Orientation orientation, QWidget *parent)
setCursor(Qt::PointingHandCursor);
setMouseTracking(true);
setAttribute(Qt::WA_Hover, true);
m_hover = new HoverAnimator(this);
m_hover->setCallback([this](qreal) { update(); });
}
void ModernSlider::setBufferValue(int value) {
@@ -56,17 +60,19 @@ void ModernSlider::mouseReleaseEvent(QMouseEvent *ev) {
}
void ModernSlider::mouseMoveEvent(QMouseEvent *ev) {
if (!m_isPressed) {
emit hoverMoved(ev->pos());
}
QSlider::mouseMoveEvent(ev);
}
bool ModernSlider::event(QEvent *ev) {
if (ev->type() == QEvent::Enter) {
m_isHovered = true;
update();
m_hover->setHovered(true);
} else if (ev->type() == QEvent::Leave) {
m_isHovered = false;
update();
m_hover->setHovered(false);
emit hoverLeft();
}
return QSlider::event(ev);
}
@@ -77,10 +83,12 @@ void ModernSlider::paintEvent(QPaintEvent *ev) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const int trackHeight =
(m_isHovered || m_isPressed) ? m_hoverTrackHeight : m_normalTrackHeight;
const int handleRadius =
(m_isHovered || m_isPressed) ? m_activeHandleRadius : m_normalHandleRadius;
// Pressed shows the active size instantly; hover grows/shrinks smoothly.
const qreal hp = m_isPressed ? 1.0 : m_hover->progress();
const qreal trackHeight =
m_normalTrackHeight + hp * (m_hoverTrackHeight - m_normalTrackHeight);
const qreal handleRadius =
m_normalHandleRadius + hp * (m_activeHandleRadius - m_normalHandleRadius);
const int edgePadding = qMax(m_normalHandleRadius, m_activeHandleRadius);
QRectF trackRect;
+9 -1
View File
@@ -8,6 +8,8 @@
#include <QColor>
#include <QtGlobal>
class HoverAnimator;
class ModernSlider : public QSlider {
Q_OBJECT
@@ -62,6 +64,12 @@ public:
update();
}
Q_SIGNALS:
// Cursor position while hovering the (un-pressed) slider — drives hover
// affordances like the player's seek time bubble.
void hoverMoved(const QPoint &pos);
void hoverLeft();
protected:
void mousePressEvent(QMouseEvent *ev) override;
void mouseReleaseEvent(QMouseEvent *ev) override;
@@ -71,8 +79,8 @@ protected:
private:
int m_bufferValue = 0;
bool m_isHovered = false;
bool m_isPressed = false;
HoverAnimator *m_hover = nullptr;
QColor m_themeColor = QColor("#3B82F6");
@@ -1,10 +1,12 @@
#include "modernsortbutton.h"
#include "../utils/popupfade.h"
#include <QAction>
ModernSortButton::ModernSortButton(QWidget *parent)
: QPushButton(parent), m_currentIndex(-1), m_descending(true)
{
m_menu = new QMenu(this);
PopupFade::attach(m_menu);
+10 -2
View File
@@ -1,4 +1,6 @@
#include "modernswitch.h"
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
#include <QPainterPath>
ModernSwitch::ModernSwitch(QWidget* parent)
@@ -14,8 +16,10 @@ ModernSwitch::ModernSwitch(QWidget* parent)
m_animation = new QPropertyAnimation(this, "offset", this);
m_animation->setDuration(150);
m_animation->setEasingCurve(QEasingCurve::InOutSine);
m_animation->setDuration(AnimationTokens::DurationMs::kMicro);
// A binary toggle wants a decisive settle, not the flat slow-in/slow-out of
// InOutSine; OutCubic reads as a proper switch.
m_animation->setEasingCurve(AnimationTokens::Curve::enter());
connect(this, &ModernSwitch::toggled, this, &ModernSwitch::updateAnimation);
}
@@ -110,6 +114,10 @@ void ModernSwitch::leaveEvent(QEvent* event) {
void ModernSwitch::updateAnimation(bool checked) {
m_animation->stop();
if (MotionPrefs::reduceMotion()) {
setOffset(checked ? 1.0 : 0.0);
return;
}
m_animation->setStartValue(m_offset);
m_animation->setEndValue(checked ? 1.0 : 0.0);
m_animation->start();
+48 -16
View File
@@ -7,6 +7,8 @@
#include <QPainter>
#include <QStyleOption>
#include <QEasingCurve>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
@@ -66,12 +68,12 @@ ModernToast::ModernToast(QWidget *parent)
m_showOpacity = new QPropertyAnimation(this, "windowOpacity", this);
m_showOpacity->setDuration(200);
m_showOpacity->setEasingCurve(QEasingCurve::OutQuad);
m_showOpacity->setDuration(AnimationTokens::DurationMs::kFast);
m_showOpacity->setEasingCurve(AnimationTokens::Curve::fade());
m_showSlide = new QPropertyAnimation(this, "geometry", this);
m_showSlide->setDuration(350);
QEasingCurve slideCurve(QEasingCurve::OutBack);
m_showSlide->setDuration(AnimationTokens::DurationMs::kSlow);
QEasingCurve slideCurve(AnimationTokens::Curve::bounce());
slideCurve.setOvershoot(1.2);
m_showSlide->setEasingCurve(slideCurve);
@@ -83,16 +85,16 @@ ModernToast::ModernToast(QWidget *parent)
m_crtSquashY = new QPropertyAnimation(this, "geometry", this);
m_crtSquashY->setDuration(160);
m_crtSquashY->setEasingCurve(QEasingCurve::InCubic);
m_crtSquashY->setDuration(AnimationTokens::DurationMs::kFast);
m_crtSquashY->setEasingCurve(AnimationTokens::Curve::exit());
m_crtSquashX = new QPropertyAnimation(this, "geometry", this);
m_crtSquashX->setDuration(120);
m_crtSquashX->setEasingCurve(QEasingCurve::InExpo);
m_crtSquashX->setDuration(AnimationTokens::DurationMs::kMicro);
m_crtSquashX->setEasingCurve(AnimationTokens::Curve::exit());
m_hideOpacity = new QPropertyAnimation(this, "windowOpacity", this);
m_hideOpacity->setDuration(120);
m_hideOpacity->setEasingCurve(QEasingCurve::InCubic);
m_hideOpacity->setDuration(AnimationTokens::DurationMs::kMicro);
m_hideOpacity->setEasingCurve(AnimationTokens::Curve::exit());
m_crtPhase2Group = new QParallelAnimationGroup(this);
m_crtPhase2Group->addAnimation(m_crtSquashX);
@@ -116,6 +118,19 @@ ModernToast::ModernToast(QWidget *parent)
m_stayTimer = new QTimer(this);
m_stayTimer->setSingleShot(true);
connect(m_stayTimer, &QTimer::timeout, this, [this]() {
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: dismiss instantly, no CRT squash.
m_hideGroup->stop();
m_isCrtFadingOut = false;
m_comboCount = 0;
setWindowOpacity(0.0);
hide();
if (graphicsEffect()) {
graphicsEffect()->setEnabled(true);
}
return;
}
m_isCrtFadingOut = true;
update();
@@ -143,29 +158,29 @@ ModernToast::ModernToast(QWidget *parent)
});
QEasingCurve expandCurve(QEasingCurve::OutBack);
QEasingCurve expandCurve(AnimationTokens::Curve::bounce());
expandCurve.setOvershoot(2.5);
m_shellExpand = new QPropertyAnimation(this, "geometry", this);
m_shellExpand->setDuration(160);
m_shellExpand->setDuration(AnimationTokens::DurationMs::kFast);
m_shellExpand->setEasingCurve(expandCurve);
m_textExpand = new QPropertyAnimation(this, "textScale", this);
m_textExpand->setDuration(160);
m_textExpand->setDuration(AnimationTokens::DurationMs::kFast);
m_textExpand->setEasingCurve(expandCurve);
m_expandGroup = new QParallelAnimationGroup(this);
m_expandGroup->addAnimation(m_shellExpand);
m_expandGroup->addAnimation(m_textExpand);
QEasingCurve shrinkCurve(QEasingCurve::OutCubic);
QEasingCurve shrinkCurve(AnimationTokens::Curve::enter());
m_shellShrink = new QPropertyAnimation(this, "geometry", this);
m_shellShrink->setDuration(220);
m_shellShrink->setDuration(AnimationTokens::DurationMs::kNormal);
m_shellShrink->setEasingCurve(shrinkCurve);
m_textShrink = new QPropertyAnimation(this, "textScale", this);
m_textShrink->setDuration(220);
m_textShrink->setDuration(AnimationTokens::DurationMs::kNormal);
m_textShrink->setEasingCurve(shrinkCurve);
m_shrinkGroup = new QParallelAnimationGroup(this);
@@ -284,6 +299,23 @@ void ModernToast::showWithAnimation(const QString &msg, int durationMs) {
show();
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: present instantly at final geometry, no slide/bounce/combo pop.
m_showGroup->stop();
m_popSequentialGroup->stop();
m_hideGroup->stop();
m_isCrtFadingOut = false;
m_comboCount = 0;
if (graphicsEffect()) {
graphicsEffect()->setEnabled(true);
}
setTextScale(1.0);
setGeometry(m_baseGeometry);
setWindowOpacity(1.0);
m_stayTimer->start(durationMs);
return;
}
bool isFullyHidden = (windowOpacity() == 0.0 && m_showGroup->state() != QAbstractAnimation::Running);
bool isFadingOut = (m_hideGroup->state() == QAbstractAnimation::Running);
+1 -1
View File
@@ -130,7 +130,7 @@ bool MpvController::init() {
mpv_set_option_string(m_mpv, "sub-hinting", "none");
mpv_set_option_string(m_mpv, "sub-ass-hinting", "none");
mpv_request_log_messages(m_mpv, "warn");
mpv_request_log_messages(m_mpv, "debug");
@@ -13,6 +13,8 @@
#include <cmath>
#include <config/config_keys.h>
#include <config/configstore.h>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace
{
@@ -98,14 +100,14 @@ QWidget *PlayerLongPressHandler::createIndicator(
overlay->setGraphicsEffect(opacityEffect);
opacityAnim = new QPropertyAnimation(opacityEffect, "opacity", this);
opacityAnim->setEasingCurve(QEasingCurve::OutCubic);
opacityAnim->setEasingCurve(AnimationTokens::Curve::enter());
geometryAnim = new QPropertyAnimation(overlay, "geometry", this);
geometryAnim->setEasingCurve(QEasingCurve::OutCubic);
geometryAnim->setEasingCurve(AnimationTokens::Curve::enter());
iconAnim = new QPropertyAnimation(iconLabel, "pos", this);
iconAnim->setDuration(220);
iconAnim->setEasingCurve(QEasingCurve::InOutSine);
iconAnim->setDuration(AnimationTokens::DurationMs::kNormal);
iconAnim->setEasingCurve(AnimationTokens::Curve::oscillate());
connect(opacityAnim, &QPropertyAnimation::finished, overlay,
[overlay, opacityEffect]()
@@ -151,7 +153,7 @@ double PlayerLongPressHandler::nextPlaybackRate(double currentRate) const
bool PlayerLongPressHandler::canStartPropertyAnimation(const QPropertyAnimation *animation) const
{
return animation && animation->targetObject();
return animation && animation->targetObject() && !MotionPrefs::reduceMotion();
}
bool PlayerLongPressHandler::isMouseEdgeLongPressEnabled() const
@@ -545,10 +547,14 @@ void PlayerLongPressHandler::animateIndicator(
overlay->raise();
opacityEffect->setOpacity(startOpacity);
opacityAnim->setDuration(wasVisible ? 120 : 180);
opacityAnim->setDuration(wasVisible ? AnimationTokens::DurationMs::kMicro
: AnimationTokens::DurationMs::kFast);
opacityAnim->setStartValue(startOpacity);
opacityAnim->setEndValue(targetOpacity);
// Geometry lags the opacity slightly (140/200 vs 120/180) so the pill
// reads as sliding into place — tuned choreography, intentionally off
// the shared duration scale.
geometryAnim->setDuration(wasVisible ? 140 : 200);
geometryAnim->setStartValue(startGeometry);
geometryAnim->setEndValue(targetGeometry);
@@ -564,11 +570,11 @@ void PlayerLongPressHandler::animateIndicator(
const QRect currentGeometry = overlay->geometry();
overlay->setProperty("indicatorVisible", false);
opacityAnim->setDuration(120);
opacityAnim->setDuration(AnimationTokens::DurationMs::kMicro);
opacityAnim->setStartValue(opacityEffect->opacity());
opacityAnim->setEndValue(0.0);
geometryAnim->setDuration(120);
geometryAnim->setDuration(AnimationTokens::DurationMs::kMicro);
geometryAnim->setStartValue(currentGeometry);
geometryAnim->setEndValue(
currentGeometry.translated(direction < 0 ? -10 : 10, 0));
@@ -581,6 +587,12 @@ void PlayerLongPressHandler::animateIndicator(
else if (opacityEffect)
{
opacityEffect->setOpacity(visible ? (active ? 1.0 : 0.62) : 0.0);
if (!visible)
{
// The animated path hides via the opacityAnim finished handler;
// when snapping we must hide directly.
overlay->hide();
}
}
if (canStartPropertyAnimation(geometryAnim))
@@ -755,7 +767,7 @@ void PlayerLongPressHandler::updateIndicators()
m_rightIndicatorText, 1, rightActive,
rightActive ? indicatorText : QString());
if (m_pulseTimer && !m_pulseTimer->isActive())
if (m_pulseTimer && !m_pulseTimer->isActive() && !MotionPrefs::reduceMotion())
{
m_pulseForward = false;
updateIndicatorPulse();
+5 -2
View File
@@ -1,4 +1,6 @@
#include "playerosdlayer.h"
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
#include <QAbstractAnimation>
#include <QGraphicsOpacityEffect>
#include <QIcon>
@@ -56,7 +58,8 @@ PlayerOsdLayer::PlayerOsdLayer(QWidget *parent)
m_fadeAnim = new QPropertyAnimation(m_opacity, "opacity", this);
m_fadeAnim->setDuration(250);
m_fadeAnim->setDuration(AnimationTokens::DurationMs::kNormal);
m_fadeAnim->setEasingCurve(AnimationTokens::Curve::fade());
m_hideTimer = new QTimer(this);
@@ -276,7 +279,7 @@ void PlayerOsdLayer::fadeIn()
bool PlayerOsdLayer::canStartAnimation() const
{
return m_fadeAnim && m_fadeAnim->targetObject();
return m_fadeAnim && m_fadeAnim->targetObject() && !MotionPrefs::reduceMotion();
}
void PlayerOsdLayer::updateSeekLayout()
@@ -134,6 +134,9 @@ QWidget *PlayerOverlayDialog::surfaceWidget() const
return m_surface;
}
// NOTE: no FadeHelper here — m_surface carries a QGraphicsDropShadowEffect,
// and nesting a QGraphicsOpacityEffect on the parent breaks rendering
// ("QPainter not active" spam, invisible surface). Open/close stay instant.
void PlayerOverlayDialog::open()
{
syncOverlayGeometry();
@@ -14,6 +14,7 @@
#include <QPainterPath>
#include <QVariantAnimation>
#include <utility>
#include "../tokens/AnimationTokens.h"
namespace
{
@@ -30,8 +31,8 @@ constexpr int kChipBadgeHeight = 12;
constexpr int kChipRemoveTop = 0;
constexpr int kChipRemoveButtonSize = 12;
constexpr int kChipRemoveHoverSize = 12;
constexpr int kChipHoverDecorFadeInDurationMs = 150;
constexpr int kChipHoverDecorFadeOutDurationMs = 120;
constexpr int kChipHoverDecorFadeInDurationMs = AnimationTokens::DurationMs::kQuick;
constexpr int kChipHoverDecorFadeOutDurationMs = AnimationTokens::DurationMs::kMicro;
constexpr qreal kChipHoverDecorMinScale = 0.9;
constexpr qreal kChipHoverDecorMaxShiftY = 1.8;
constexpr qreal kChipRemoveDecorDelayProgress = 0.18;
@@ -259,7 +260,7 @@ SearchHistoryChip::SearchHistoryChip(QWidget *parent)
m_hoverDecorAnimation->setStartValue(0.0);
m_hoverDecorAnimation->setEndValue(1.0);
m_hoverDecorAnimation->setDuration(kChipHoverDecorFadeInDurationMs);
m_hoverDecorAnimation->setEasingCurve(QEasingCurve::OutCubic);
m_hoverDecorAnimation->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_hoverDecorAnimation, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) {
m_hoverDecorProgress = value.toReal();
@@ -559,8 +560,8 @@ void SearchHistoryChip::syncHoverDecorationVisibility()
m_hoverDecorAnimation->stop();
m_hoverDecorAnimation->setDuration(visible ? kChipHoverDecorFadeInDurationMs
: kChipHoverDecorFadeOutDurationMs);
m_hoverDecorAnimation->setEasingCurve(visible ? QEasingCurve::OutCubic
: QEasingCurve::InCubic);
m_hoverDecorAnimation->setEasingCurve(visible ? AnimationTokens::Curve::enter()
: AnimationTokens::Curve::exit());
m_hoverDecorAnimation->setStartValue(m_hoverDecorProgress);
m_hoverDecorAnimation->setEndValue(targetProgress);
m_hoverDecorAnimation->start();
+18 -8
View File
@@ -22,6 +22,7 @@
#include <QVBoxLayout>
#include <algorithm>
#include <utility>
#include "../tokens/AnimationTokens.h"
namespace {
@@ -37,12 +38,15 @@ constexpr int kSearchHistoryPopupEdgeScrollBarWidth = 4;
constexpr int kSearchHistoryPopupContentRightInset = 6;
constexpr int kSearchHistoryPopupCollapsedBodyMaxHeight = 228;
constexpr int kSearchHistoryPopupExpandedBodyMaxHeight = 320;
// Expand/collapse/sort/hide are a deliberately-tuned multi-stage choreography for
// this popup and intentionally sit off the shared duration scale; only the show
// duration lines up with a design tier (kFast).
constexpr int kSearchHistoryPopupExpandDurationMs = 240;
constexpr int kSearchHistoryPopupCollapseDurationMs = 200;
constexpr int kSearchHistoryPopupSortTransitionDurationMs = 190;
constexpr qreal kSearchHistoryPopupSortTransitionMidpoint = 0.44;
constexpr qreal kSearchHistoryPopupSortTransitionMinOpacity = 0.18;
constexpr int kSearchHistoryPopupShowDurationMs = 180;
constexpr int kSearchHistoryPopupShowDurationMs = AnimationTokens::DurationMs::kFast;
constexpr int kSearchHistoryPopupHideDurationMs = 140;
constexpr int kSearchHistoryPopupVisibilityOffsetY = 8;
@@ -122,6 +126,13 @@ SearchHistoryPopup::SearchHistoryPopup(QWidget *parent)
m_card->setObjectName("SearchHistoryPopupCard");
m_card->setAttribute(Qt::WA_StyledBackground, true);
// Drives the show/hide opacity fade. Without this the effect is never created
// and every `if (m_cardOpacityEffect)` branch is dead — the popup pops instead
// of fading. m_card owns no other graphics effect, so this is safe.
m_cardOpacityEffect = new QGraphicsOpacityEffect(m_card);
m_cardOpacityEffect->setOpacity(1.0);
m_card->setGraphicsEffect(m_cardOpacityEffect);
auto *mainLayout = new QVBoxLayout(m_card);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
@@ -246,7 +257,7 @@ SearchHistoryPopup::SearchHistoryPopup(QWidget *parent)
m_heightAnimation->setStartValue(0.0);
m_heightAnimation->setEndValue(1.0);
m_heightAnimation->setDuration(kSearchHistoryPopupExpandDurationMs);
m_heightAnimation->setEasingCurve(QEasingCurve::InOutCubic);
m_heightAnimation->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_heightAnimation, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) {
const qreal progress = value.toReal();
@@ -278,7 +289,7 @@ SearchHistoryPopup::SearchHistoryPopup(QWidget *parent)
m_sortTransitionAnimation->setStartValue(0.0);
m_sortTransitionAnimation->setEndValue(1.0);
m_sortTransitionAnimation->setDuration(kSearchHistoryPopupSortTransitionDurationMs);
m_sortTransitionAnimation->setEasingCurve(QEasingCurve::InOutCubic);
m_sortTransitionAnimation->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_sortTransitionAnimation, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) {
if (!m_flowOpacityEffect)
@@ -331,7 +342,7 @@ SearchHistoryPopup::SearchHistoryPopup(QWidget *parent)
m_visibilityAnimation->setStartValue(0.0);
m_visibilityAnimation->setEndValue(1.0);
m_visibilityAnimation->setDuration(kSearchHistoryPopupShowDurationMs);
m_visibilityAnimation->setEasingCurve(QEasingCurve::OutCubic);
m_visibilityAnimation->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_visibilityAnimation, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) {
const qreal progress = value.toReal();
@@ -451,7 +462,7 @@ void SearchHistoryPopup::showBelow(QWidget *anchor) {
m_visibilityTargetOpacity = 1.0;
m_visibilityTargetOffsetY = 0;
m_visibilityAnimation->setDuration(kSearchHistoryPopupShowDurationMs);
m_visibilityAnimation->setEasingCurve(QEasingCurve::OutCubic);
m_visibilityAnimation->setEasingCurve(AnimationTokens::Curve::enter());
m_visibilityAnimation->setStartValue(0.0);
m_visibilityAnimation->setEndValue(1.0);
m_visibilityAnimation->start();
@@ -507,7 +518,7 @@ void SearchHistoryPopup::dismiss(bool immediate)
}
m_visibilityAnimation->setDuration(kSearchHistoryPopupHideDurationMs);
m_visibilityAnimation->setEasingCurve(QEasingCurve::InCubic);
m_visibilityAnimation->setEasingCurve(AnimationTokens::Curve::exit());
m_visibilityAnimation->setStartValue(0.0);
m_visibilityAnimation->setEndValue(1.0);
m_visibilityAnimation->start();
@@ -1116,8 +1127,7 @@ void SearchHistoryPopup::setExpanded(bool expanded)
{
m_heightAnimation->setDuration(expanded ? kSearchHistoryPopupExpandDurationMs
: kSearchHistoryPopupCollapseDurationMs);
m_heightAnimation->setEasingCurve(expanded ? QEasingCurve::OutCubic
: QEasingCurve::InOutCubic);
m_heightAnimation->setEasingCurve(AnimationTokens::Curve::enter());
}
}
@@ -0,0 +1,80 @@
#include "seektimebubble.h"
#include <QLabel>
#include <QPainter>
#include <QVBoxLayout>
#include "../utils/fadehelper.h"
SeekTimeBubble::SeekTimeBubble(QWidget *parent)
: QWidget(parent)
{
setAttribute(Qt::WA_TransparentForMouseEvents);
setVisible(false);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(10, 4, 10, 4);
layout->setSpacing(4);
m_thumbSlot = new QLabel(this);
m_thumbSlot->setObjectName("seekBubbleThumb");
m_thumbSlot->setVisible(false);
layout->addWidget(m_thumbSlot, 0, Qt::AlignHCenter);
m_timeLabel = new QLabel(this);
m_timeLabel->setObjectName("seekBubbleTime");
m_timeLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(m_timeLabel, 0, Qt::AlignHCenter);
}
void SeekTimeBubble::setTimeText(const QString &text)
{
if (m_timeLabel->text() == text)
{
return;
}
m_timeLabel->setText(text);
adjustSize();
}
void SeekTimeBubble::showAt(const QPoint &anchor)
{
adjustSize();
QPoint topLeft(anchor.x() - width() / 2, anchor.y() - height());
if (parentWidget())
{
const int maxX = parentWidget()->width() - width();
topLeft.setX(qBound(0, topLeft.x(), qMax(0, maxX)));
topLeft.setY(qMax(0, topLeft.y()));
}
move(topLeft);
if (!m_shown)
{
m_shown = true;
raise();
FadeHelper::fadeIn(this, AnimationTokens::DurationMs::kQuick);
}
}
void SeekTimeBubble::hideBubble()
{
if (!m_shown)
{
return;
}
m_shown = false;
FadeHelper::fadeOut(this, AnimationTokens::DurationMs::kMicro,
AnimationTokens::Curve::exit(), [this]() { hide(); });
}
void SeekTimeBubble::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QPen(QColor(148, 163, 184, 60), 1));
painter.setBrush(QColor(15, 23, 42, 235));
painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 6, 6);
}
+37
View File
@@ -0,0 +1,37 @@
#ifndef SEEKTIMEBUBBLE_H
#define SEEKTIMEBUBBLE_H
#include <QWidget>
class QLabel;
// Small floating bubble shown above the player seek bar while hovering,
// displaying the timestamp under the cursor. Fades in kQuick / out kMicro
// (via FadeHelper, so reduce-motion snaps). The layout reserves a hidden
// thumbnail slot above the time label so a trickplay preview image can be
// added later without re-layouting.
//
// The bubble is an always-dark player surface, styled by objectName
// "seekBubbleTime" in the QSS plus a self-painted panel.
class SeekTimeBubble : public QWidget
{
Q_OBJECT
public:
explicit SeekTimeBubble(QWidget *parent);
void setTimeText(const QString &text);
// Anchor = bottom-center of the bubble, in parent coordinates. Clamped to
// stay inside the parent. Fades in on the first call after a hide.
void showAt(const QPoint &anchor);
void hideBubble();
protected:
void paintEvent(QPaintEvent *event) override;
private:
QLabel *m_thumbSlot;
QLabel *m_timeLabel;
bool m_shown = false;
};
#endif // SEEKTIMEBUBBLE_H
+9 -2
View File
@@ -10,6 +10,8 @@
#include <QLabel>
#include <QPushButton>
#include <QFont>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
ServerWheelView::ServerWheelView(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_StyledBackground, true);
@@ -17,8 +19,8 @@ ServerWheelView::ServerWheelView(QWidget* parent) : QWidget(parent) {
m_scrollAnim = new QVariantAnimation(this);
m_scrollAnim->setEasingCurve(QEasingCurve::OutExpo);
m_scrollAnim->setDuration(450);
m_scrollAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_scrollAnim->setDuration(AnimationTokens::DurationMs::kSlow);
connect(m_scrollAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& val){
m_scrollOffset = val.toReal();
updateLayout();
@@ -186,6 +188,11 @@ void ServerWheelView::installFilterRecursively(QWidget* w) {
void ServerWheelView::scrollTo(qreal target) {
m_scrollAnim->stop();
if (MotionPrefs::reduceMotion()) {
m_scrollOffset = target;
updateLayout();
return;
}
m_scrollAnim->setStartValue(m_scrollOffset);
m_scrollAnim->setEndValue(target);
m_scrollAnim->start();
+3 -3
View File
@@ -1,8 +1,8 @@
#include "settingssubpanel.h"
#include "../managers/thememanager.h"
#include "../tokens/AnimationTokens.h"
#include <config/configstore.h>
#include <config/config_keys.h>
#include <QEasingCurve>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
@@ -39,8 +39,8 @@ SettingsSubPanel::SettingsSubPanel(const QString &iconPath, QWidget *parent)
m_animation = new QPropertyAnimation(this, "panelHeight", this);
m_animation->setDuration(250);
m_animation->setEasingCurve(QEasingCurve::OutCubic);
m_animation->setDuration(AnimationTokens::DurationMs::kNormal);
m_animation->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_animation, &QPropertyAnimation::finished, this, [this]() {
+13 -5
View File
@@ -1,5 +1,7 @@
#include "shimmerwidget.h"
#include "../managers/thememanager.h"
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
@@ -14,9 +16,9 @@ ShimmerWidget::ShimmerWidget(QWidget* parent)
m_animation = new QPropertyAnimation(this, "shimmerPhase", this);
m_animation->setStartValue(0.0);
m_animation->setEndValue(1.0);
m_animation->setDuration(1800);
m_animation->setDuration(AnimationTokens::DurationMs::kShimmer);
m_animation->setLoopCount(-1);
m_animation->setEasingCurve(QEasingCurve::Linear);
m_animation->setEasingCurve(AnimationTokens::Curve::loop());
}
ShimmerWidget::~ShimmerWidget()
@@ -63,10 +65,16 @@ void ShimmerWidget::setShimmerPhase(qreal phase)
void ShimmerWidget::startAnimation()
{
if (m_animation && m_animation->state() != QAbstractAnimation::Running) {
m_shimmerPhase = 0.0;
m_animation->start();
if (!m_animation || m_animation->state() == QAbstractAnimation::Running) {
return;
}
m_shimmerPhase = 0.0;
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: show a static skeleton (base shapes) with no moving sweep.
update();
return;
}
m_animation->start();
}
void ShimmerWidget::stopAnimation()
@@ -1,4 +1,5 @@
#include "slidingstackedwidget.h"
#include "../tokens/AnimationTokens.h"
#include <QWidget>
#include <QLabel>
#include <QScreen>
@@ -17,8 +18,8 @@ QPoint interpolatePoint(const QPoint &start, const QPoint &end, qreal progress)
SlidingStackedWidget::SlidingStackedWidget(QWidget *parent)
: QStackedWidget(parent),
m_speed(350),
m_animationType(QEasingCurve::OutCubic),
m_speed(AnimationTokens::DurationMs::kSlow),
m_animationType(AnimationTokens::Curve::enter().type()),
m_isAnimating(false),
m_nextIndex(0)
{
@@ -45,6 +46,35 @@ void SlidingStackedWidget::slideInWgt(QWidget *widget, SlideDirection direction)
}
}
void SlidingStackedWidget::jumpToWgt(QWidget *widget)
{
int idx = indexOf(widget);
if (idx != -1) {
jumpToIdx(idx);
}
}
void SlidingStackedWidget::jumpToIdx(int idx)
{
if (idx < 0 || idx >= count())
return;
if (currentIndex() == idx && !m_isAnimating)
return;
if (m_isAnimating) {
m_slideTimer.stop();
animationDoneSlot();
}
m_nextIndex = idx;
setCurrentIndex(idx);
QWidget *w = widget(idx);
if (w) w->raise();
flushPendingWidgetDisposals();
Q_EMIT animationFinished();
}
void SlidingStackedWidget::slideInIdx(int idx, SlideDirection direction)
{
if (idx < 0 || idx >= count())
@@ -64,12 +94,7 @@ void SlidingStackedWidget::slideInIdx(int idx, SlideDirection direction)
bool reduceAnimations = ConfigStore::instance()->get<bool>(ConfigKeys::UiAnimations, false);
if (reduceAnimations) {
m_nextIndex = idx;
setCurrentIndex(idx);
QWidget *w = widget(idx);
if (w) w->raise();
flushPendingWidgetDisposals();
Q_EMIT animationFinished();
jumpToIdx(idx);
return;
}
@@ -33,6 +33,10 @@ public:
void slideInIdx(int idx, SlideDirection direction = Automatic);
void slideInWgt(QWidget *widget, SlideDirection direction = Automatic);
// Instant switch (no slide), safely finishing any in-flight animation.
// For targets with their own entrance animation (e.g. the player view).
void jumpToIdx(int idx);
void jumpToWgt(QWidget *widget);
void disposeWidgetWhenSafe(QWidget *widget);
Q_SIGNALS:
@@ -1,5 +1,6 @@
#include "splitplayerbutton.h"
#include "../managers/externalplayerdetector.h"
#include "../utils/popupfade.h"
#include <QPainter>
#include <QPainterPath>
#include <QMouseEvent>
@@ -26,6 +27,7 @@ SplitPlayerButton::SplitPlayerButton(QWidget *parent)
m_menu->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
m_menu->setAttribute(Qt::WA_TranslucentBackground, true);
m_menu->setAutoFillBackground(false);
PopupFade::attach(m_menu);
}
void SplitPlayerButton::setPlayers(const QList<DetectedPlayer> &players,
+2 -1
View File
@@ -22,6 +22,7 @@
#include <QStyle>
#include <QStringList>
#include <QVBoxLayout>
#include "../tokens/AnimationTokens.h"
namespace {
@@ -115,7 +116,7 @@ TaskEditDialog::TaskEditDialog(QEmbyCore *core, const QString &taskId,
m_pageStack = new SlidingStackedWidget(this);
m_pageStack->setSpeed(260);
m_pageStack->setEasingCurve(QEasingCurve::OutCubic);
m_pageStack->setEasingCurve(AnimationTokens::Curve::enter().type());
setupOverviewPage();
setupTriggersPage();
@@ -7,6 +7,8 @@
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
@@ -148,11 +150,15 @@ void TranscodingCodecListWidget::onRowDragFinish(TranscodingCodecRowWidget* row)
m_dragHotSpotY = 0;
stopPosAnimations(row);
auto* animation = new QPropertyAnimation(row, "pos", row);
animation->setDuration(180);
animation->setEasingCurve(QEasingCurve::OutCubic);
animation->setEndValue(QPoint(0, rowTopForIndex(finalIndex)));
animation->start(QAbstractAnimation::DeleteWhenStopped);
if (MotionPrefs::reduceMotion()) {
row->move(QPoint(0, rowTopForIndex(finalIndex)));
} else {
auto* animation = new QPropertyAnimation(row, "pos", row);
animation->setDuration(AnimationTokens::DurationMs::kFast);
animation->setEasingCurve(AnimationTokens::Curve::enter());
animation->setEndValue(QPoint(0, rowTopForIndex(finalIndex)));
animation->start(QAbstractAnimation::DeleteWhenStopped);
}
if (changed) {
emit orderChanged(codecOrder());
@@ -276,12 +282,12 @@ void TranscodingCodecListWidget::relayout(bool animate) {
}
const QPoint targetPos(0, top);
if (animate && row->pos() != targetPos) {
if (animate && !MotionPrefs::reduceMotion() && row->pos() != targetPos) {
stopPosAnimations(row);
auto* animation = new QPropertyAnimation(row, "pos", row);
animation->setDuration(220);
animation->setEasingCurve(QEasingCurve::OutCubic);
animation->setDuration(AnimationTokens::DurationMs::kNormal);
animation->setEasingCurve(AnimationTokens::Curve::enter());
animation->setEndValue(targetPos);
animation->start(QAbstractAnimation::DeleteWhenStopped);
} else {
+2 -1
View File
@@ -28,6 +28,7 @@
#include <QStyle>
#include <QTime>
#include <QVBoxLayout>
#include "../tokens/AnimationTokens.h"
namespace {
@@ -165,7 +166,7 @@ UserEditDialog::UserEditDialog(QEmbyCore *core, const QString &userId,
m_pageStack = new SlidingStackedWidget(this);
m_pageStack->setObjectName("UserEditPageStack");
m_pageStack->setSpeed(260);
m_pageStack->setEasingCurve(QEasingCurve::OutCubic);
m_pageStack->setEasingCurve(AnimationTokens::Curve::enter().type());
setupProfileTab();
setupAccessTab();
+9 -8
View File
@@ -1,4 +1,5 @@
#include "mainwindow.h"
#include "tokens/AnimationTokens.h"
#include <qembycore.h>
#include "views/public/loginview.h"
#include "views/media/playerview.h"
@@ -566,11 +567,11 @@ MainWindow::MainWindow(QWidget *parent)
auto anim = new QPropertyAnimation(transition, "radius", transition);
anim->setDuration(450);
anim->setDuration(AnimationTokens::DurationMs::kSlow);
anim->setStartValue(0);
anim->setEndValue(maxRadius);
anim->setEasingCurve(QEasingCurve::InOutCubic);
anim->setEasingCurve(AnimationTokens::Curve::enter());
connect(anim, &QPropertyAnimation::finished, transition, &QObject::deleteLater);
@@ -662,10 +663,10 @@ MainWindow::MainWindow(QWidget *parent)
auto anim = new QPropertyAnimation(transition, "radius", transition);
anim->setDuration(450);
anim->setDuration(AnimationTokens::DurationMs::kSlow);
anim->setStartValue(0);
anim->setEndValue(maxRadius);
anim->setEasingCurve(QEasingCurve::InOutCubic);
anim->setEasingCurve(AnimationTokens::Curve::enter());
connect(anim, &QPropertyAnimation::finished, transition, &QObject::deleteLater);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
@@ -957,10 +958,10 @@ void MainWindow::navigateToHome()
m_viewStack->setCurrentWidget(m_homeView);
auto *fadeIn = new QPropertyAnimation(homeOpacity, "opacity", this);
fadeIn->setDuration(250);
fadeIn->setDuration(AnimationTokens::DurationMs::kNormal);
fadeIn->setStartValue(0.0);
fadeIn->setEndValue(1.0);
fadeIn->setEasingCurve(QEasingCurve::OutQuad);
fadeIn->setEasingCurve(AnimationTokens::Curve::fade());
connect(fadeIn, &QPropertyAnimation::finished, this, [this]() {
m_homeView->setGraphicsEffect(nullptr);
@@ -1017,10 +1018,10 @@ void MainWindow::navigateToLogin() {
m_viewStack->setCurrentWidget(m_loginView);
auto *fadeIn = new QPropertyAnimation(loginOpacity, "opacity", this);
fadeIn->setDuration(250);
fadeIn->setDuration(AnimationTokens::DurationMs::kNormal);
fadeIn->setStartValue(0.0);
fadeIn->setEndValue(1.0);
fadeIn->setEasingCurve(QEasingCurve::OutQuad);
fadeIn->setEasingCurve(AnimationTokens::Curve::fade());
connect(fadeIn, &QPropertyAnimation::finished, this, [this]() {
m_loginView->setGraphicsEffect(nullptr);
+74 -57
View File
@@ -1,13 +1,21 @@
/* =========================================================
全局基础设定 & Tailwind Dark 色板映射
Primary Blue: #3B82F6, Hover: #2563EB
Danger Red: #EF4444, Hover: #DC2626
Slate-900 (主背景): #0F172A
Slate-800 (卡片/侧边栏): #1E293B
Slate-700 (边框/悬浮): #334155
Slate-400 (次要文字): #94A3B8
Slate-300 (正文文字): #CBD5E1
Slate-50 (标题文字): #F8FAFC
qEmby Dark Theme — QSS Style Sheet
Design tokens: see src/qEmbyApp/tokens/DesignTokens.h
Animation tokens: see src/qEmbyApp/tokens/AnimationTokens.h
Border-radius scale: 2(kXs) 4(kSm) 6(kMd) 8(kLg) 14(kXl)
Spacing scale: 4(kXs) 8(kSm) 12(kMd) 16(kLg) 24(kXl)
Font scale: 10 11 12 13 14 16 20 24 28
Color palette (Tailwind Dark):
Primary Blue: #3B82F6, Hover: #2563EB
Danger Red: #EF4444, Hover: #DC2626
Slate-900 (主背景): #0F172A
Slate-800 (卡片/侧边栏): #1E293B
Slate-700 (边框/悬浮): #334155
Slate-400 (次要文字): #94A3B8
Slate-300 (正文文字): #CBD5E1
Slate-50 (标题文字): #F8FAFC
========================================================= */
/* =========================================
@@ -252,7 +260,7 @@ QListView#SearchCompleterPopup,
QListView#SearchHistoryCompleterPopup {
background-color: #0F172A;
border: 1px solid rgba(148, 163, 184, 0.20);
border-radius: 20px;
border-radius: 14px;
padding: 0px;
outline: none;
}
@@ -450,7 +458,7 @@ QMenu::separator {
QMenu#AppTextContextMenu {
background-color: #0F172A;
border: 1px solid #334155;
border-radius: 12px;
border-radius: 14px;
padding: 8px 0px;
}
@@ -532,7 +540,7 @@ QWidget#login-add-page QLineEdit:focus {
QWidget#login-add-page QWidget#login-ssl-card {
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 12px;
border-radius: 14px;
}
QWidget#login-add-page QWidget#login-ssl-card:hover {
@@ -558,7 +566,7 @@ QWidget#login-add-page QLabel#login-ssl-description {
QWidget#server-card {
background-color: #1E293B;
border: 1px solid #334155;
border-radius: 12px;
border-radius: 14px;
}
QWidget#server-card:hover {
@@ -578,7 +586,7 @@ QLabel#server-url-label {
QLabel#server-fallback-icon {
background-color: #1E3A8A;
color: #60A5FA;
border-radius: 20px;
border-radius: 14px;
font-weight: bold;
}
@@ -613,7 +621,7 @@ QPushButton#add-server-btn {
qproperty-iconSize: 16px 16px;
background-color: transparent;
border: 2px dashed #334155;
border-radius: 12px;
border-radius: 14px;
color: #60A5FA;
font-size: 14px;
font-weight: 600;
@@ -999,7 +1007,7 @@ ModernMessageBox {
QWidget[playerDialogSurface="true"] {
background-color: rgba(15, 23, 42, 0.94);
border: 1px solid rgba(71, 85, 105, 0.88);
border-radius: 18px;
border-radius: 14px;
}
QWidget#dialog-titlebar {
@@ -1168,7 +1176,7 @@ QWidget#playerDanmakuSettingsDialog {
}
#playerDanmakuSettingsDialog QLabel#playerDanmakuTileIcon[compactIcon="true"] {
border-radius: 11px;
border-radius: 14px;
}
#playerDanmakuSettingsDialog QLabel#playerDanmakuTileTitle {
@@ -1489,7 +1497,7 @@ QListWidget#modernComboEmbeddedList QScrollBar:vertical {
QListWidget#modernComboEmbeddedList QScrollBar::handle:vertical {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 3px;
border-radius: 4px;
min-height: 30px;
}
@@ -2312,7 +2320,7 @@ QLabel#identify-preview-label {
QLabel#identify-preview-label[media-image-editor="true"] {
background-color: rgba(15, 23, 42, 0.84);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
border-radius: 14px;
padding: 0px;
}
@@ -2377,13 +2385,13 @@ QProgressBar#detail-progress-bar {
background-color: rgba(255, 255, 255, 0.12);
border: 1px solid transparent;
/* 核心:用透明边框强制覆盖原生样式引擎 */
border-radius: 1px;
border-radius: 2px;
}
QProgressBar#detail-progress-bar::chunk {
background-color: #3B82F6;
/* 如果你要苹果绿,请替换为 #30D158 */
border-radius: 1px;
border-radius: 2px;
}
/* =========================================
@@ -2470,7 +2478,7 @@ QPushButton#hud-close-btn:hover {
QPushButton.player-hud-btn {
background: transparent;
border: none;
border-radius: 12px;
border-radius: 14px;
}
QPushButton.player-hud-btn:hover {
@@ -2516,7 +2524,7 @@ QPushButton#playerSpeedBtn {
/* 彻底抹除边框 */
outline: none;
/* 彻底抹除焦点虚线 */
border-radius: 12px;
border-radius: 14px;
padding: 0 10px;
}
@@ -2524,7 +2532,7 @@ QPushButton#playerMediaSwitchBtn {
min-width: 0px;
padding: 0px;
border: none;
border-radius: 12px;
border-radius: 14px;
}
QPushButton#playerMediaSwitchBtn:hover {
@@ -2749,7 +2757,7 @@ QPushButton#immersive-stream-btn {
border: none;
padding: 4px 10px;
/* 【调优】上下内边距收紧,打造细长的精致胶囊 */
border-radius: 5px;
border-radius: 6px;
/* 圆角微调,配合更小的高度 */
text-align: left;
}
@@ -2774,7 +2782,7 @@ QLineEdit#detail-episode-jump-edit {
font-weight: 500;
border: none;
padding: 4px 8px;
border-radius: 5px;
border-radius: 6px;
selection-background-color: rgba(59, 130, 246, 0.55);
selection-color: #FFFFFF;
}
@@ -2814,7 +2822,7 @@ QMenu#immersive-stream-menu {
QMenu#immersive-stream-menu::item {
color: #E0E0E0;
padding: 6px 28px 6px 14px;
border-radius: 5px;
border-radius: 6px;
font-size: 12px;
}
@@ -2850,7 +2858,7 @@ QWidget#stream-menu-item-host {
QWidget#stream-menu-item {
background: transparent;
border-radius: 5px;
border-radius: 6px;
}
QWidget#stream-menu-item:hover,
@@ -2915,15 +2923,24 @@ QLabel#osdSeekTimeLabel {
border: 1px solid rgba(255, 255, 255, 28);
}
/* 进度条悬停时间气泡(面板底色由 SeekTimeBubble 自绘) */
QLabel#seekBubbleTime {
background: transparent;
color: #F8FAFC;
font-family: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
font-size: 12px;
font-weight: 600;
}
QWidget#osdSeekStem {
background-color: rgba(96, 165, 250, 180);
border-radius: 1px;
border-radius: 2px;
}
QWidget#osdSeekMarker {
background-color: rgba(248, 250, 252, 220);
border: 1px solid rgba(96, 165, 250, 230);
border-radius: 3px;
border-radius: 4px;
}
QWidget#osdVolumePanel {
@@ -2961,7 +2978,7 @@ QLabel#osdVolumeLabel {
QWidget#playerMouseEdgeIndicator {
background-color: rgba(255, 255, 255, 18);
border: 1px solid rgba(255, 255, 255, 52);
border-radius: 22px;
border-radius: 14px;
}
QWidget#playerMouseEdgeIndicator[gestureActive="true"] {
@@ -3053,7 +3070,7 @@ QScrollBar#modernMenuScrollBar:vertical {
QScrollBar#modernMenuScrollBar::handle:vertical {
background: rgba(255, 255, 255, 0.45);
border-radius: 3px;
border-radius: 4px;
min-height: 28px;
/* 显式归零,覆盖全局规则的 margin: 0px 3px,否则会被压缩成 0 宽不可见 */
margin: 0px;
@@ -3155,7 +3172,7 @@ LoadingOverlay {
#SettingsCard {
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
border-radius: 14px;
}
#SettingsCard:hover {
@@ -3546,7 +3563,7 @@ QLineEdit#TagInputInlineEdit {
QToolButton#TagInputClearBtn {
background: transparent;
border: none;
border-radius: 3px;
border-radius: 4px;
qproperty-icon: url(":/svg/dark/close.svg");
qproperty-iconSize: 8px 8px;
}
@@ -3595,7 +3612,7 @@ QMenu#TagPresetMenu::indicator {
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.06);
border-left: 3px solid #3B82F6;
border-radius: 12px;
border-radius: 14px;
margin-left: 20px;
}
@@ -3801,7 +3818,7 @@ QLabel#AboutThanksAvatar {
color: #FFFFFF;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #F97316, stop:0.48 #DB2777, stop:1 #2563EB);
border-radius: 11px;
border-radius: 14px;
font-size: 10px;
font-weight: 700;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
@@ -3965,7 +3982,7 @@ QLabel#SettingsPageTitle {
/* 左右宽阔的热区,保证不拥挤 */
margin: 2px 6px;
/* 【核心】:让 hover 高亮区不贴边,形成独立胶囊 */
border-radius: 5px;
border-radius: 6px;
/* 胶囊状内圆角 */
font-size: 13px;
}
@@ -3997,14 +4014,14 @@ QLabel#SettingsPageTitle {
#ManageInfoCard {
background-color: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(71, 85, 105, 0.4);
border-radius: 12px;
border-radius: 14px;
padding: 20px;
}
#ManageActivityCard {
background-color: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(71, 85, 105, 0.4);
border-radius: 12px;
border-radius: 14px;
padding: 0px;
}
@@ -4062,7 +4079,7 @@ QFrame#TranscodingGroupBlock,
QFrame#TranscodingCodecItem {
background-color: rgba(30, 41, 59, 0.6);
border: 1px solid rgba(71, 85, 105, 0.28);
border-radius: 12px;
border-radius: 14px;
}
QFrame#TranscodingCodecItem[dragging="true"] {
@@ -4295,12 +4312,12 @@ QProgressBar#NowPlayingProgressBar {
max-height: 2px;
background-color: rgba(255, 255, 255, 0.25);
border: 1px solid transparent;
border-radius: 1px;
border-radius: 2px;
}
QProgressBar#NowPlayingProgressBar::chunk {
background-color: #3B82F6;
border-radius: 1px;
border-radius: 2px;
}
QLabel#NowPlayingUser {
@@ -4756,7 +4773,7 @@ QCheckBox#LibraryMetadataLockCheck::indicator:checked {
QLineEdit#PlaylistSearchEdit {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 7px;
border-radius: 6px;
color: #CBD5E1;
font-size: 12px;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
@@ -5286,12 +5303,12 @@ QProgressBar#ManageLibScanProgress {
max-height: 2px;
background-color: rgba(255, 255, 255, 0.12);
border: 1px solid transparent;
border-radius: 1px;
border-radius: 2px;
}
QProgressBar#ManageLibScanProgress::chunk {
background-color: #3B82F6;
border-radius: 1px;
border-radius: 2px;
}
/* 取消扫描按钮 */
@@ -5383,7 +5400,7 @@ QToolButton#TagInputClearBtn {
border: none;
qproperty-icon: url(:/svg/dark/close.svg);
qproperty-iconSize: 10px 10px;
border-radius: 5px;
border-radius: 6px;
}
QToolButton#TagInputClearBtn:hover {
@@ -5441,7 +5458,7 @@ QLineEdit#TagPresetSearchEdit:focus {
/* 弹出面板内复选框 */
QFrame#TagPresetOptionRow {
background-color: transparent;
border-radius: 5px;
border-radius: 6px;
}
QFrame#TagPresetOptionRow:hover {
@@ -5464,7 +5481,7 @@ QCheckBox#TagPresetCheckbox {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
spacing: 8px;
padding: 3px 6px;
border-radius: 5px;
border-radius: 6px;
}
QCheckBox#TagPresetCheckbox:hover {
@@ -5606,7 +5623,7 @@ QPushButton#ManageTabBtn:checked:hover {
QFrame#ManageUserCard {
background-color: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(71, 85, 105, 0.4);
border-radius: 12px;
border-radius: 14px;
}
QFrame#ManageUserCard:hover {
@@ -5618,7 +5635,7 @@ QFrame#ManageUserCard:hover {
QLabel#ManageUserAvatar {
background-color: rgba(59, 130, 246, 0.2);
border: 1px solid rgba(59, 130, 246, 0.35);
border-radius: 24px;
border-radius: 14px;
color: #93C5FD;
font-size: 18px;
font-weight: 700;
@@ -5813,7 +5830,7 @@ QPlainTextEdit#ManageLogViewer {
QFrame#UserEditNavCard {
background-color: rgba(30, 41, 59, 0.62);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 12px;
border-radius: 14px;
}
QTabBar#UserEditTabBar {
@@ -5846,7 +5863,7 @@ QTabBar#UserEditTabBar::tab:hover:!selected {
QFrame#UserEditSectionCard {
background-color: rgba(30, 41, 59, 0.58);
border: 1px solid rgba(148, 163, 184, 0.10);
border-radius: 12px;
border-radius: 14px;
}
QLabel#UserEditSectionTitle {
@@ -5875,7 +5892,7 @@ QWidget#UserEditFolderListContainer {
QFrame#UserEditFooterBar {
background-color: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 12px;
border-radius: 14px;
}
/* UserEdit 滚动区 */
@@ -6056,7 +6073,7 @@ QScrollArea#DownloadRecordsArea QScrollBar:vertical {
QScrollArea#DownloadRecordsArea QScrollBar::handle:vertical {
background-color: rgba(148, 163, 184, 0.28);
border: none;
border-radius: 3px;
border-radius: 4px;
min-height: 28px;
}
@@ -6148,7 +6165,7 @@ QLabel#DownloadSectionTitle {
QLabel#DownloadLocationValue {
background-color: rgba(15, 23, 42, 0.52);
border: 1px solid rgba(100, 116, 139, 0.14);
border-radius: 12px;
border-radius: 14px;
color: #F8FAFC;
padding: 10px 12px;
font-size: 12px;
@@ -6180,7 +6197,7 @@ QLabel#DownloadEmptyHint {
QPushButton#DownloadIconButton {
background-color: rgba(15, 23, 42, 0.76);
border: 1px solid rgba(100, 116, 139, 0.24);
border-radius: 11px;
border-radius: 14px;
padding: 0px;
}
@@ -6252,7 +6269,7 @@ QPushButton#DownloadIconButton:disabled {
#webdav-sync-dialog QLineEdit {
background-color: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 7px;
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
color: #F8FAFC;
+66 -49
View File
@@ -1,5 +1,13 @@
/* =========================================================
全局基础设定 & Tailwind 色板变量映射 (供参考)
qEmby Light Theme — QSS Style Sheet
Design tokens: see src/qEmbyApp/tokens/DesignTokens.h
Animation tokens: see src/qEmbyApp/tokens/AnimationTokens.h
Border-radius scale: 2(kXs) 4(kSm) 6(kMd) 8(kLg) 14(kXl)
Spacing scale: 4(kXs) 8(kSm) 12(kMd) 16(kLg) 24(kXl)
Font scale: 10 11 12 13 14 16 20 24 28
Color palette (Tailwind Light):
Primary Blue: #3B82F6, Hover: #2563EB
Danger Red: #EF4444, Hover: #DC2626
Gray-800 (标题): #1F2937
@@ -257,7 +265,7 @@ QListView#SearchCompleterPopup,
QListView#SearchHistoryCompleterPopup {
background-color: #FFFFFF;
border: 1px solid #E4EBF3;
border-radius: 20px;
border-radius: 14px;
padding: 0px;
outline: none;
}
@@ -458,7 +466,7 @@ QMenu::separator {
QMenu#AppTextContextMenu {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
padding: 8px 0px;
}
@@ -542,7 +550,7 @@ QWidget#login-add-page QLineEdit:focus {
QWidget#login-add-page QWidget#login-ssl-card {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
}
QWidget#login-add-page QWidget#login-ssl-card:hover {
@@ -568,7 +576,7 @@ QWidget#login-add-page QLabel#login-ssl-description {
QWidget#server-card {
background-color: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 12px;
border-radius: 14px;
}
QWidget#server-card:hover {
@@ -588,7 +596,7 @@ QLabel#server-url-label {
QLabel#server-fallback-icon {
background-color: #EFF6FF;
color: #3B82F6;
border-radius: 20px;
border-radius: 14px;
font-weight: bold;
}
@@ -623,7 +631,7 @@ QPushButton#add-server-btn {
qproperty-iconSize: 16px 16px;
background-color: transparent;
border: 2px dashed #BFDBFE;
border-radius: 12px;
border-radius: 14px;
color: #3B82F6;
font-size: 14px;
font-weight: 600;
@@ -1014,7 +1022,7 @@ ModernMessageBox {
QWidget[playerDialogSurface="true"] {
background-color: rgba(255, 255, 255, 0.96);
border: 1px solid #D1D5DB;
border-radius: 18px;
border-radius: 14px;
}
QWidget#dialog-titlebar {
@@ -1190,7 +1198,7 @@ QWidget#playerDanmakuSettingsDialog {
}
#playerDanmakuSettingsDialog QLabel#playerDanmakuTileIcon[compactIcon="true"] {
border-radius: 11px;
border-radius: 14px;
}
#playerDanmakuSettingsDialog QLabel#playerDanmakuTileTitle {
@@ -1512,7 +1520,7 @@ QListWidget#modernComboEmbeddedList QScrollBar:vertical {
QListWidget#modernComboEmbeddedList QScrollBar::handle:vertical {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 3px;
border-radius: 4px;
min-height: 30px;
}
@@ -2411,7 +2419,7 @@ QLabel#identify-preview-label {
QLabel#identify-preview-label[media-image-editor="true"] {
background-color: #EEF2F7;
border: 1px solid #D8E0EA;
border-radius: 12px;
border-radius: 14px;
padding: 0px;
}
@@ -2451,13 +2459,13 @@ QProgressBar#detail-progress-bar {
background-color: rgba(0, 0, 0, 0.12);
border: 1px solid transparent;
/* 核心:用透明边框强制覆盖原生样式引擎 */
border-radius: 1px;
border-radius: 2px;
}
QProgressBar#detail-progress-bar::chunk {
background-color: #3B82F6;
/* 主题蓝 */
border-radius: 1px;
border-radius: 2px;
}
/* =========================================
@@ -2544,7 +2552,7 @@ QPushButton#hud-close-btn:hover {
QPushButton.player-hud-btn {
background: transparent;
border: none;
border-radius: 12px;
border-radius: 14px;
}
QPushButton.player-hud-btn:hover {
@@ -2590,7 +2598,7 @@ QPushButton#playerSpeedBtn {
/* 彻底抹除边框 */
outline: none;
/* 彻底抹除焦点虚线 */
border-radius: 12px;
border-radius: 14px;
padding: 0 10px;
}
@@ -2598,7 +2606,7 @@ QPushButton#playerMediaSwitchBtn {
min-width: 0px;
padding: 0px;
border: none;
border-radius: 12px;
border-radius: 14px;
}
QPushButton#playerMediaSwitchBtn:hover {
@@ -2822,7 +2830,7 @@ QPushButton#immersive-stream-btn {
border: none;
padding: 4px 10px;
/* 【调优】上下内边距收紧,打造细长的精致胶囊 */
border-radius: 5px;
border-radius: 6px;
text-align: left;
}
@@ -2846,7 +2854,7 @@ QLineEdit#detail-episode-jump-edit {
font-weight: 500;
border: none;
padding: 4px 8px;
border-radius: 5px;
border-radius: 6px;
selection-background-color: rgba(59, 130, 246, 0.22);
selection-color: #1D4ED8;
}
@@ -2884,7 +2892,7 @@ QMenu#immersive-stream-menu {
QMenu#immersive-stream-menu::item {
color: #333333;
padding: 6px 28px 6px 14px;
border-radius: 5px;
border-radius: 6px;
font-size: 12px;
}
@@ -2921,7 +2929,7 @@ QWidget#stream-menu-item-host {
QWidget#stream-menu-item {
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
border-radius: 6px;
}
QWidget#stream-menu-item:hover {
@@ -2999,15 +3007,24 @@ QLabel#osdSeekTimeLabel {
border: 1px solid rgba(255, 255, 255, 28);
}
/* 进度条悬停时间气泡(播放器恒暗,与 dark 一致;面板底色由 SeekTimeBubble 自绘) */
QLabel#seekBubbleTime {
background: transparent;
color: #F8FAFC;
font-family: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
font-size: 12px;
font-weight: 600;
}
QWidget#osdSeekStem {
background-color: rgba(96, 165, 250, 180);
border-radius: 1px;
border-radius: 2px;
}
QWidget#osdSeekMarker {
background-color: rgba(248, 250, 252, 220);
border: 1px solid rgba(96, 165, 250, 230);
border-radius: 3px;
border-radius: 4px;
}
QWidget#osdVolumePanel {
@@ -3045,7 +3062,7 @@ QLabel#osdVolumeLabel {
QWidget#playerMouseEdgeIndicator {
background-color: rgba(255, 255, 255, 18);
border: 1px solid rgba(255, 255, 255, 52);
border-radius: 22px;
border-radius: 14px;
}
QWidget#playerMouseEdgeIndicator[gestureActive="true"] {
@@ -3140,7 +3157,7 @@ QScrollBar#modernMenuScrollBar:vertical {
QScrollBar#modernMenuScrollBar::handle:vertical {
background: rgba(255, 255, 255, 0.45);
border-radius: 3px;
border-radius: 4px;
min-height: 28px;
/* 显式归零,覆盖全局规则的 margin: 0px 3px,否则会被压缩成 0 宽不可见 */
margin: 0px;
@@ -3343,7 +3360,7 @@ QLabel#AboutThanksAvatar {
color: #FFFFFF;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #FB923C, stop:0.48 #DB2777, stop:1 #2563EB);
border-radius: 11px;
border-radius: 14px;
font-size: 10px;
font-weight: 700;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
@@ -3488,7 +3505,7 @@ QLabel#SettingsPageTitle {
#SettingsCard {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
}
#SettingsCard:hover {
@@ -3874,7 +3891,7 @@ QLineEdit#TagInputInlineEdit {
QToolButton#TagInputClearBtn {
background: transparent;
border: none;
border-radius: 3px;
border-radius: 4px;
qproperty-icon: url(":/svg/light/close.svg");
qproperty-iconSize: 8px 8px;
}
@@ -3923,7 +3940,7 @@ QMenu#TagPresetMenu::indicator {
background: rgba(0, 0, 0, 0.018);
border: 1px solid rgba(0, 0, 0, 0.06);
border-left: 3px solid #4A90D9;
border-radius: 12px;
border-radius: 14px;
margin-left: 20px;
}
@@ -4030,7 +4047,7 @@ QLabel#SettingsSubTitle {
/* Tailwind Gray 700 */
padding: 6px 32px 6px 14px;
margin: 2px 6px;
border-radius: 5px;
border-radius: 6px;
font-size: 13px;
}
@@ -4060,14 +4077,14 @@ QLabel#SettingsSubTitle {
#ManageInfoCard {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
padding: 20px;
}
#ManageActivityCard {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
padding: 0px;
}
@@ -4125,7 +4142,7 @@ QFrame#TranscodingGroupBlock,
QFrame#TranscodingCodecItem {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
}
QFrame#TranscodingCodecItem[dragging="true"] {
@@ -4358,12 +4375,12 @@ QProgressBar#NowPlayingProgressBar {
max-height: 2px;
background-color: rgba(0, 0, 0, 0.12);
border: 1px solid transparent;
border-radius: 1px;
border-radius: 2px;
}
QProgressBar#NowPlayingProgressBar::chunk {
background-color: #3B82F6;
border-radius: 1px;
border-radius: 2px;
}
QLabel#NowPlayingUser {
@@ -4818,7 +4835,7 @@ QCheckBox#LibraryMetadataLockCheck::indicator:checked {
QLineEdit#PlaylistSearchEdit {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 7px;
border-radius: 6px;
color: #334155;
font-size: 12px;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
@@ -5347,12 +5364,12 @@ QProgressBar#ManageLibScanProgress {
max-height: 2px;
background-color: rgba(0, 0, 0, 0.12);
border: 1px solid transparent;
border-radius: 1px;
border-radius: 2px;
}
QProgressBar#ManageLibScanProgress::chunk {
background-color: #3B82F6;
border-radius: 1px;
border-radius: 2px;
}
/* 取消扫描按钮 */
@@ -5444,7 +5461,7 @@ QToolButton#TagInputClearBtn {
border: none;
qproperty-icon: url(:/svg/light/close.svg);
qproperty-iconSize: 10px 10px;
border-radius: 5px;
border-radius: 6px;
}
QToolButton#TagInputClearBtn:hover {
@@ -5502,7 +5519,7 @@ QLineEdit#TagPresetSearchEdit:focus {
/* 弹出面板内复选框 */
QFrame#TagPresetOptionRow {
background-color: transparent;
border-radius: 5px;
border-radius: 6px;
}
QFrame#TagPresetOptionRow:hover {
@@ -5525,7 +5542,7 @@ QCheckBox#TagPresetCheckbox {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
spacing: 8px;
padding: 3px 6px;
border-radius: 5px;
border-radius: 6px;
}
QCheckBox#TagPresetCheckbox:hover {
@@ -5667,7 +5684,7 @@ QPushButton#ManageTabBtn:checked:hover {
QFrame#ManageUserCard {
background-color: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
border-radius: 14px;
}
QFrame#ManageUserCard:hover {
@@ -5679,7 +5696,7 @@ QFrame#ManageUserCard:hover {
QLabel#ManageUserAvatar {
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.25);
border-radius: 24px;
border-radius: 14px;
color: #2563EB;
font-size: 18px;
font-weight: 700;
@@ -5875,7 +5892,7 @@ QPlainTextEdit#ManageLogViewer {
QFrame#UserEditNavCard {
background-color: rgba(248, 250, 252, 0.88);
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 12px;
border-radius: 14px;
}
QTabBar#UserEditTabBar {
@@ -5908,7 +5925,7 @@ QTabBar#UserEditTabBar::tab:hover:!selected {
QFrame#UserEditSectionCard {
background-color: #FFFFFF;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 12px;
border-radius: 14px;
}
QLabel#UserEditSectionTitle {
@@ -5937,7 +5954,7 @@ QWidget#UserEditFolderListContainer {
QFrame#UserEditFooterBar {
background-color: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 12px;
border-radius: 14px;
}
/* UserEdit 滚动区 */
@@ -6118,7 +6135,7 @@ QScrollArea#DownloadRecordsArea QScrollBar:vertical {
QScrollArea#DownloadRecordsArea QScrollBar::handle:vertical {
background-color: rgba(148, 163, 184, 0.42);
border: none;
border-radius: 3px;
border-radius: 4px;
min-height: 28px;
}
@@ -6210,7 +6227,7 @@ QLabel#DownloadSectionTitle {
QLabel#DownloadLocationValue {
background-color: rgba(248, 250, 252, 0.72);
border: 1px solid rgba(148, 163, 184, 0.10);
border-radius: 12px;
border-radius: 14px;
color: #0F172A;
padding: 10px 12px;
font-size: 12px;
@@ -6242,7 +6259,7 @@ QLabel#DownloadEmptyHint {
QPushButton#DownloadIconButton {
background-color: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 11px;
border-radius: 14px;
padding: 0px;
}
@@ -6303,7 +6320,7 @@ QPushButton#DownloadIconButton:disabled {
#webdav-sync-dialog QLineEdit {
background-color: #FFFFFF;
border: 1px solid #CBD5E1;
border-radius: 7px;
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
color: #1F2937;
+29
View File
@@ -0,0 +1,29 @@
#ifndef QEMBYAPP_ANIMATION_TOKENS_H
#define QEMBYAPP_ANIMATION_TOKENS_H
#include <QEasingCurve>
namespace AnimationTokens {
namespace DurationMs {
constexpr int kMicro = 120;
constexpr int kQuick = 150; // between kMicro and kFast (small hover/decoration fades)
constexpr int kFast = 180;
constexpr int kNormal = 300;
constexpr int kSlow = 400;
constexpr int kGentle = 800;
constexpr int kShimmer = 1800;
}
namespace Curve {
inline QEasingCurve enter() { return QEasingCurve::OutCubic; }
inline QEasingCurve exit() { return QEasingCurve::InCubic; }
inline QEasingCurve bounce() { return QEasingCurve::OutBack; }
inline QEasingCurve loop() { return QEasingCurve::Linear; }
inline QEasingCurve fade() { return QEasingCurve::OutQuad; }
inline QEasingCurve oscillate() { return QEasingCurve::InOutSine; }
}
} // namespace AnimationTokens
#endif // QEMBYAPP_ANIMATION_TOKENS_H
+36
View File
@@ -0,0 +1,36 @@
#ifndef QEMBYAPP_DESIGN_TOKENS_H
#define QEMBYAPP_DESIGN_TOKENS_H
namespace DesignTokens {
namespace Radius {
constexpr int kXs = 2;
constexpr int kSm = 4;
constexpr int kMd = 6;
constexpr int kLg = 8;
constexpr int kXl = 14;
}
namespace Spacing {
constexpr int kXs = 4;
constexpr int kSm = 8;
constexpr int kMd = 12;
constexpr int kLg = 16;
constexpr int kXl = 24;
}
namespace FontSize {
constexpr int kCaption = 10;
constexpr int kSmall = 11;
constexpr int kBody = 12;
constexpr int kBodyPlus = 13;
constexpr int kHeading = 14;
constexpr int kSubtitle = 16;
constexpr int kTitle = 20;
constexpr int kDisplay = 24;
constexpr int kHero = 28;
}
} // namespace DesignTokens
#endif // QEMBYAPP_DESIGN_TOKENS_H
+121
View File
@@ -0,0 +1,121 @@
#include "fadehelper.h"
#include <QGraphicsOpacityEffect>
#include <QPropertyAnimation>
#include <QWidget>
#include "motionprefs.h"
namespace {
// Returns the opacity effect to drive, creating one if the widget has no effect.
// Sets `usable` to false when the widget already owns a *different* effect (e.g. a
// drop shadow) that we must not replace.
QGraphicsOpacityEffect *resolveOpacityEffect(QWidget *target, bool &usable)
{
if (QGraphicsEffect *existing = target->graphicsEffect())
{
auto *opacity = qobject_cast<QGraphicsOpacityEffect *>(existing);
usable = (opacity != nullptr);
return opacity;
}
auto *opacity = new QGraphicsOpacityEffect(target);
opacity->setOpacity(1.0);
target->setGraphicsEffect(opacity);
usable = true;
return opacity;
}
// Cancel any fade already in flight on this effect so overlapping calls don't fight.
void stopInFlight(QGraphicsOpacityEffect *effect)
{
const auto running = effect->findChildren<QPropertyAnimation *>();
for (QPropertyAnimation *anim : running)
{
anim->stop();
}
}
} // namespace
QPropertyAnimation *FadeHelper::fadeIn(QWidget *target, int durationMs,
const QEasingCurve &curve)
{
if (!target)
{
return nullptr;
}
bool usable = false;
QGraphicsOpacityEffect *effect = resolveOpacityEffect(target, usable);
target->show();
if (!usable || !effect)
{
return nullptr; // foreign effect present — leave it alone
}
stopInFlight(effect); // a new request always supersedes an in-flight fade
if (durationMs <= 0 || MotionPrefs::reduceMotion())
{
effect->setOpacity(1.0);
return nullptr;
}
auto *anim = new QPropertyAnimation(effect, "opacity", effect);
anim->setDuration(durationMs);
anim->setEasingCurve(curve);
anim->setStartValue(effect->opacity());
anim->setEndValue(1.0);
anim->start(QAbstractAnimation::DeleteWhenStopped);
return anim;
}
QPropertyAnimation *FadeHelper::fadeOut(QWidget *target, int durationMs,
const QEasingCurve &curve,
std::function<void()> onDone)
{
if (!target)
{
return nullptr;
}
bool usable = false;
QGraphicsOpacityEffect *effect = resolveOpacityEffect(target, usable);
if (!usable || !effect)
{
if (onDone)
{
onDone(); // can't fade a foreign-effect widget — just finish
}
return nullptr;
}
stopInFlight(effect); // a new request always supersedes an in-flight fade
if (durationMs <= 0 || MotionPrefs::reduceMotion())
{
effect->setOpacity(0.0);
if (onDone)
{
onDone();
}
return nullptr;
}
auto *anim = new QPropertyAnimation(effect, "opacity", effect);
anim->setDuration(durationMs);
anim->setEasingCurve(curve);
anim->setStartValue(effect->opacity());
anim->setEndValue(0.0);
if (onDone)
{
// `target` as context: if it's destroyed mid-fade, the callback is dropped.
QObject::connect(anim, &QAbstractAnimation::finished, target, onDone);
}
anim->start(QAbstractAnimation::DeleteWhenStopped);
return anim;
}
+44
View File
@@ -0,0 +1,44 @@
#ifndef QEMBYAPP_FADE_HELPER_H
#define QEMBYAPP_FADE_HELPER_H
#include <QEasingCurve>
#include <functional>
#include "../tokens/AnimationTokens.h"
class QWidget;
class QPropertyAnimation;
// Reusable opacity fade built on QGraphicsOpacityEffect + AnimationTokens.
// Both helpers honor the "Reduce Animations" preference (MotionPrefs::reduceMotion):
// when reduce-motion is on (or durationMs <= 0) they snap to the end state instead
// of animating.
//
// One-effect-per-widget guard: if `target` already owns a non-opacity QGraphicsEffect
// (e.g. a drop shadow), the helper will NOT clobber it — it snaps to the end state and
// returns nullptr, leaving the foreign effect intact. Callers that need to fade such a
// widget must wrap it in a container.
//
// CAUTION — the guard cannot see CHILD effects: fading a widget whose descendant
// carries its own QGraphicsEffect nests effects, which breaks rendering
// ("QPainter not active" spam, invisible content — see PlayerOverlayDialog's
// shadowed surface). Don't fade such parents; fade an effect-free ancestor level
// or skip the fade.
namespace FadeHelper {
// Ensure `target` is visible and animate its opacity up to 1.0.
// Returns the running animation, or nullptr when snapped / not applicable.
QPropertyAnimation *fadeIn(QWidget *target,
int durationMs = AnimationTokens::DurationMs::kNormal,
const QEasingCurve &curve = AnimationTokens::Curve::fade());
// Animate `target` opacity down to 0.0, invoking `onDone` when finished (or
// immediately when snapped). Returns the running animation, or nullptr when snapped.
QPropertyAnimation *fadeOut(QWidget *target,
int durationMs = AnimationTokens::DurationMs::kNormal,
const QEasingCurve &curve = AnimationTokens::Curve::fade(),
std::function<void()> onDone = {});
} // namespace FadeHelper
#endif // QEMBYAPP_FADE_HELPER_H
+50
View File
@@ -0,0 +1,50 @@
#include "hoveranimator.h"
#include <QVariantAnimation>
#include "motionprefs.h"
HoverAnimator::HoverAnimator(QObject *parent, int inMs, int outMs)
: QObject(parent), m_inMs(inMs), m_outMs(outMs)
{
m_anim = new QVariantAnimation(this);
connect(m_anim, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) { applyProgress(value.toReal()); });
}
void HoverAnimator::setHovered(bool hovered)
{
if (m_hovered == hovered)
return;
m_hovered = hovered;
const qreal target = hovered ? 1.0 : 0.0;
m_anim->stop();
if (MotionPrefs::reduceMotion() || m_progress == target) {
applyProgress(target);
return;
}
m_anim->setStartValue(m_progress);
m_anim->setEndValue(target);
m_anim->setDuration(hovered ? m_inMs : m_outMs);
m_anim->setEasingCurve(hovered ? AnimationTokens::Curve::enter()
: AnimationTokens::Curve::exit());
m_anim->start();
}
void HoverAnimator::setCallback(std::function<void(qreal)> callback)
{
m_callback = std::move(callback);
}
void HoverAnimator::applyProgress(qreal progress)
{
if (m_progress == progress)
return;
m_progress = progress;
if (m_callback)
m_callback(m_progress);
Q_EMIT progressChanged(m_progress);
}
+54
View File
@@ -0,0 +1,54 @@
#ifndef QEMBYAPP_HOVER_ANIMATOR_H
#define QEMBYAPP_HOVER_ANIMATOR_H
#include <QObject>
#include <functional>
#include "../tokens/AnimationTokens.h"
class QVariantAnimation;
// Interpolated 0..1 hover progress for paint-time hover effects, replacing
// binary isHovered repaints. Asymmetric rhythm by convention: hover-in is
// slower (kQuick / enter) than hover-out (kMicro / exit).
//
// Honors the "Reduce Animations" preference (MotionPrefs::reduceMotion):
// when reduce-motion is on, setHovered() snaps progress straight to 0/1.
// Retargeting mid-flight restarts from the current progress, so rapid
// enter/leave never jumps.
//
// Typical use in a QWidget:
// m_hover = new HoverAnimator(this);
// m_hover->setCallback([this](qreal) { update(); });
// // enterEvent -> m_hover->setHovered(true), leaveEvent -> false
// // paintEvent -> interpolate with m_hover->progress()
class HoverAnimator : public QObject
{
Q_OBJECT
public:
explicit HoverAnimator(QObject *parent = nullptr,
int inMs = AnimationTokens::DurationMs::kQuick,
int outMs = AnimationTokens::DurationMs::kMicro);
void setHovered(bool hovered);
bool isHovered() const { return m_hovered; }
qreal progress() const { return m_progress; }
// Invoked on every progress change (typically [w] { w->update(); }).
void setCallback(std::function<void(qreal)> callback);
Q_SIGNALS:
void progressChanged(qreal progress);
private:
void applyProgress(qreal progress);
QVariantAnimation *m_anim = nullptr;
std::function<void(qreal)> m_callback;
qreal m_progress = 0.0;
bool m_hovered = false;
int m_inMs;
int m_outMs;
};
#endif // QEMBYAPP_HOVER_ANIMATOR_H
+132
View File
@@ -0,0 +1,132 @@
#include "indexhoveranimator.h"
#include <QVariantAnimation>
#include "../tokens/AnimationTokens.h"
#include "motionprefs.h"
IndexHoverAnimator::IndexHoverAnimator(QObject *parent)
: QObject(parent)
{
}
void IndexHoverAnimator::setHoveredIndex(const QPersistentModelIndex &index)
{
if (m_hovered == index)
return;
const QPersistentModelIndex previous = m_hovered;
m_hovered = index;
if (MotionPrefs::reduceMotion()) {
for (Entry &entry : m_entries) {
if (entry.anim)
entry.anim->stop();
}
const QList<Entry> old = m_entries;
m_entries.clear();
for (const Entry &entry : old) {
if (entry.anim)
entry.anim->deleteLater();
repaint(entry.index);
}
if (index.isValid()) {
Entry snapped;
snapped.index = index;
snapped.progress = 1.0;
m_entries.append(snapped);
repaint(index);
}
return;
}
if (previous.isValid()) {
if (Entry *entry = entryFor(previous))
animateEntry(*entry, false);
}
if (index.isValid()) {
Entry *entry = entryFor(index);
if (!entry) {
Entry fresh;
fresh.index = index;
m_entries.append(fresh);
entry = &m_entries.last();
}
animateEntry(*entry, true);
}
}
qreal IndexHoverAnimator::progressFor(const QModelIndex &index) const
{
for (const Entry &entry : m_entries) {
if (entry.index.isValid() && entry.index == index)
return entry.progress;
}
return 0.0;
}
void IndexHoverAnimator::setRepaintCallback(
std::function<void(const QModelIndex &)> callback)
{
m_repaintCallback = std::move(callback);
}
IndexHoverAnimator::Entry *IndexHoverAnimator::entryFor(
const QPersistentModelIndex &index)
{
for (Entry &entry : m_entries) {
if (entry.index == index)
return &entry;
}
return nullptr;
}
void IndexHoverAnimator::animateEntry(Entry &entry, bool entering)
{
const QPersistentModelIndex index = entry.index;
if (!entry.anim) {
auto *anim = new QVariantAnimation(this);
entry.anim = anim;
connect(anim, &QVariantAnimation::valueChanged, this,
[this, index](const QVariant &value) {
if (Entry *e = entryFor(index)) {
e->progress = value.toReal();
repaint(index);
}
});
connect(anim, &QVariantAnimation::finished, this,
[this]() { prune(); });
}
entry.anim->stop();
entry.anim->setStartValue(entry.progress);
entry.anim->setEndValue(entering ? 1.0 : 0.0);
entry.anim->setDuration(entering ? AnimationTokens::DurationMs::kQuick
: AnimationTokens::DurationMs::kMicro);
entry.anim->setEasingCurve(entering ? AnimationTokens::Curve::enter()
: AnimationTokens::Curve::exit());
entry.anim->start();
}
void IndexHoverAnimator::repaint(const QPersistentModelIndex &index)
{
if (m_repaintCallback && index.isValid())
m_repaintCallback(index);
}
void IndexHoverAnimator::prune()
{
for (int i = m_entries.size() - 1; i >= 0; --i) {
Entry &entry = m_entries[i];
const bool finished =
!entry.anim || entry.anim->state() != QAbstractAnimation::Running;
const bool dead = !entry.index.isValid() ||
(finished && entry.progress <= 0.0 &&
entry.index != m_hovered);
if (dead) {
if (entry.anim)
entry.anim->deleteLater();
m_entries.removeAt(i);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef QEMBYAPP_INDEX_HOVER_ANIMATOR_H
#define QEMBYAPP_INDEX_HOVER_ANIMATOR_H
#include <QList>
#include <QObject>
#include <QPersistentModelIndex>
#include <functional>
class QVariantAnimation;
// Per-index hover progress for delegate-painted item views: tracks which
// QModelIndex is hovered and interpolates a 0..1 progress per index, so a
// stateless delegate can paint smooth hover transitions. When the hover
// moves between items the old index animates out while the new one animates
// in, concurrently. Same asymmetric rhythm as HoverAnimator (in kQuick/enter,
// out kMicro/exit) and the same reduce-motion snapping.
//
// The repaint callback is invoked with every index whose progress changed;
// callers should invalidate visualRect(idx) expanded by repaintMargin()
// (lift + shadow overflow). Entries are pruned automatically once they fade
// back to 0 or their index is invalidated by the model.
class IndexHoverAnimator : public QObject
{
Q_OBJECT
public:
explicit IndexHoverAnimator(QObject *parent = nullptr);
// Pass an invalid index (or call clear()) when the cursor leaves.
void setHoveredIndex(const QPersistentModelIndex &index);
void clear() { setHoveredIndex(QPersistentModelIndex()); }
QPersistentModelIndex hoveredIndex() const { return m_hovered; }
// 0.0 for untracked/invalid indexes.
qreal progressFor(const QModelIndex &index) const;
// Extra pixels the repaint region must extend beyond the item rect.
void setRepaintMargin(int px) { m_repaintMargin = px; }
int repaintMargin() const { return m_repaintMargin; }
void setRepaintCallback(std::function<void(const QModelIndex &)> callback);
private:
struct Entry {
QPersistentModelIndex index;
QVariantAnimation *anim = nullptr;
qreal progress = 0.0;
};
Entry *entryFor(const QPersistentModelIndex &index);
void animateEntry(Entry &entry, bool entering);
void repaint(const QPersistentModelIndex &index);
void prune();
QList<Entry> m_entries;
QPersistentModelIndex m_hovered;
std::function<void(const QModelIndex &)> m_repaintCallback;
int m_repaintMargin = 0;
};
#endif // QEMBYAPP_INDEX_HOVER_ANIMATOR_H
+19
View File
@@ -0,0 +1,19 @@
#ifndef QEMBYAPP_MOTION_PREFS_H
#define QEMBYAPP_MOTION_PREFS_H
#include "../../qEmbyCore/config/config_keys.h"
#include "../../qEmbyCore/config/configstore.h"
// Single source of truth for the "Reduce Animations" accessibility preference
// (ConfigKeys::UiAnimations, default false). reduceMotion() == true means the
// user wants motion suppressed: skip/snap animations to their end state.
namespace MotionPrefs {
inline bool reduceMotion()
{
return ConfigStore::instance()->get<bool>(ConfigKeys::UiAnimations, false);
}
} // namespace MotionPrefs
#endif // QEMBYAPP_MOTION_PREFS_H
+37
View File
@@ -0,0 +1,37 @@
#include "popfeedback.h"
#include <QVariantAnimation>
#include "motionprefs.h"
namespace PopFeedback {
QVariantAnimation *play(QObject *context, std::function<void(qreal)> callback,
qreal peak, int durationMs)
{
if (!callback)
return nullptr;
if (MotionPrefs::reduceMotion() || durationMs <= 0) {
callback(1.0);
return nullptr;
}
auto *anim = new QVariantAnimation(context);
anim->setStartValue(0.85);
anim->setKeyValueAt(0.55, peak);
anim->setEndValue(1.0);
anim->setDuration(durationMs);
anim->setEasingCurve(AnimationTokens::Curve::fade());
QObject::connect(anim, &QVariantAnimation::valueChanged, context,
[callback](const QVariant &value) {
callback(value.toReal());
});
QObject::connect(anim, &QVariantAnimation::finished, context,
[callback]() { callback(1.0); });
anim->start(QAbstractAnimation::DeleteWhenStopped);
return anim;
}
} // namespace PopFeedback
+27
View File
@@ -0,0 +1,27 @@
#ifndef QEMBYAPP_POP_FEEDBACK_H
#define QEMBYAPP_POP_FEEDBACK_H
#include <functional>
#include "../tokens/AnimationTokens.h"
class QObject;
class QVariantAnimation;
// One-shot scale pulse confirming a state toggle (favorite, played, ...):
// dips to ~0.85, overshoots to `peak`, settles at 1.0. The callback receives
// the current scale factor per tick and is guaranteed a final cb(1.0), so
// painting keyed on the scale always ends at rest.
//
// Honors the "Reduce Animations" preference: when reduce-motion is on the
// callback is invoked once with 1.0 and nullptr is returned. The animation
// parents to `context` and self-deletes when finished.
namespace PopFeedback {
QVariantAnimation *play(QObject *context, std::function<void(qreal)> callback,
qreal peak = 1.15,
int durationMs = AnimationTokens::DurationMs::kNormal);
} // namespace PopFeedback
#endif // QEMBYAPP_POP_FEEDBACK_H
+71
View File
@@ -0,0 +1,71 @@
#ifndef QEMBYAPP_POPUP_FADE_H
#define QEMBYAPP_POPUP_FADE_H
#include <QEvent>
#include <QObject>
#include <QPointer>
#include <QVariantAnimation>
#include <QWidget>
#include "../tokens/AnimationTokens.h"
#include "motionprefs.h"
// Entrance fade for top-level popups (frameless QMenus, combo popups):
// windowOpacity 0 -> 1 over kQuick on every Show. Closing stays instant —
// menus dismiss on action and must not linger. Best-effort: compositors that
// ignore windowOpacity simply show the popup fully opaque.
//
// Honors the "Reduce Animations" preference. For child-widget overlays use
// FadeHelper instead (windowOpacity only works on top-level windows).
namespace PopupFade {
// Fade the (already shown) top-level popup in right now. Used directly when
// the popup is opened by code that can call it (e.g. after
// QComboBox::showPopup()); attach() automates it for widgets with a Show event.
inline void run(QWidget *target)
{
if (!target || !target->isWindow() || MotionPrefs::reduceMotion())
return;
auto *anim = new QVariantAnimation(target);
anim->setStartValue(0.0);
anim->setEndValue(1.0);
anim->setDuration(AnimationTokens::DurationMs::kQuick);
anim->setEasingCurve(AnimationTokens::Curve::fade());
QObject::connect(anim, &QVariantAnimation::valueChanged, target,
[target = QPointer<QWidget>(target)](const QVariant &value) {
if (target)
target->setWindowOpacity(value.toReal());
});
target->setWindowOpacity(0.0);
anim->start(QAbstractAnimation::DeleteWhenStopped);
}
class Filter : public QObject
{
public:
explicit Filter(QWidget *target) : QObject(target), m_target(target) {}
protected:
bool eventFilter(QObject *object, QEvent *event) override
{
if (object == m_target && event->type() == QEvent::Show && m_target)
{
run(m_target);
}
return QObject::eventFilter(object, event);
}
private:
QPointer<QWidget> m_target;
};
inline void attach(QWidget *popup)
{
if (popup)
popup->installEventFilter(new Filter(popup));
}
} // namespace PopupFade
#endif // QEMBYAPP_POPUP_FADE_H
+50
View File
@@ -0,0 +1,50 @@
#include "refreshspin.h"
#include <QAbstractButton>
#include <QIcon>
#include <QPixmap>
#include <QTransform>
#include <QVariantAnimation>
#include "../tokens/AnimationTokens.h"
#include "motionprefs.h"
namespace RefreshSpin {
QVariantAnimation *start(QAbstractButton *button)
{
if (!button || MotionPrefs::reduceMotion())
return nullptr;
const QIcon originalIcon = button->icon();
const QSize iconSize = button->iconSize();
const QPixmap basePixmap = originalIcon.pixmap(iconSize);
if (iconSize.isEmpty() || basePixmap.isNull())
return nullptr;
auto *animation = new QVariantAnimation(button);
animation->setStartValue(0.0);
animation->setEndValue(360.0);
animation->setDuration(AnimationTokens::DurationMs::kGentle);
animation->setEasingCurve(AnimationTokens::Curve::enter());
QObject::connect(animation, &QVariantAnimation::valueChanged, button,
[button, basePixmap, iconSize](const QVariant &value) {
QTransform transform;
transform.translate(iconSize.width() / 2.0, iconSize.height() / 2.0);
transform.rotate(value.toReal());
transform.translate(-iconSize.width() / 2.0, -iconSize.height() / 2.0);
const QPixmap rotated =
basePixmap.transformed(transform, Qt::SmoothTransformation);
const int x = (rotated.width() - iconSize.width()) / 2;
const int y = (rotated.height() - iconSize.height()) / 2;
button->setIcon(QIcon(
rotated.copy(x, y, iconSize.width(), iconSize.height())));
});
QObject::connect(animation, &QVariantAnimation::finished, button,
[button, originalIcon]() { button->setIcon(originalIcon); });
animation->start(QAbstractAnimation::DeleteWhenStopped);
return animation;
}
} // namespace RefreshSpin
+18
View File
@@ -0,0 +1,18 @@
#ifndef QEMBYAPP_REFRESH_SPIN_H
#define QEMBYAPP_REFRESH_SPIN_H
class QAbstractButton;
class QVariantAnimation;
// One-shot 360° icon spin for manual-refresh buttons (kGentle / enter curve).
// Restores the button's original icon when the spin finishes. Honors the
// "Reduce Animations" preference (MotionPrefs::reduceMotion): when reduced,
// no animation starts, the icon stays static and nullptr is returned.
// The animation is owned by the button and self-deletes when stopped.
namespace RefreshSpin {
QVariantAnimation *start(QAbstractButton *button);
} // namespace RefreshSpin
#endif // QEMBYAPP_REFRESH_SPIN_H
+37
View File
@@ -105,4 +105,41 @@ NinePatchShadow make(int blurRadius, int cornerRadius, const QColor &color, qrea
baked.save(path, "PNG"); // best-effort cache; failure just re-bakes next time
return result;
}
void drawNinePatch(QPainter *painter, const QRect &target, const NinePatchShadow &patch)
{
if (!painter || patch.isNull())
{
return;
}
const int b = patch.border;
const int t = patch.total;
const qreal s = patch.dpr;
const QRect r = target;
// Degenerate target (smaller than the two fixed borders): nothing sensible
// to slice, skip rather than draw garbage.
if (r.width() < 2 * b || r.height() < 2 * b)
{
return;
}
// 3x3 grid: corners fixed, edges/centre stretched. Target columns/rows are
// logical; source columns/rows are physical (pixmap carries no dpr).
const int xt[4] = {r.left(), r.left() + b, r.right() + 1 - b, r.right() + 1};
const int yt[4] = {r.top(), r.top() + b, r.bottom() + 1 - b, r.bottom() + 1};
const qreal xs[4] = {0.0, b * s, (t - b) * s, t * s};
const qreal ys[4] = {0.0, b * s, (t - b) * s, t * s};
for (int i = 0; i < 3; ++i)
{
for (int j = 0; j < 3; ++j)
{
const QRectF targetCell(xt[i], yt[j], xt[i + 1] - xt[i], yt[j + 1] - yt[j]);
const QRectF sourceCell(xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j]);
painter->drawPixmap(targetCell, patch.pixmap, sourceCell);
}
}
}
} // namespace ShadowPixmap
+7
View File
@@ -27,6 +27,8 @@ struct NinePatchShadow
bool isNull() const { return pixmap.isNull(); }
};
class QPainter;
namespace ShadowPixmap
{
// Build (or load from the on-disk cache) a 9-patch shadow for a rounded rect.
@@ -34,6 +36,11 @@ namespace ShadowPixmap
// its alpha controls overall strength. Results are cached per
// (blurRadius, cornerRadius, color, dpr) under CacheLocation/shadows.
NinePatchShadow make(int blurRadius, int cornerRadius, const QColor &color, qreal dpr);
// 9-slice the baked shadow onto `target` (the shadowed body rect grown by
// patch.border on every edge): corners fixed, edges/centre stretched. Cheap
// enough for per-frame painting — no blur happens at draw time.
void drawNinePatch(QPainter *painter, const QRect &target, const NinePatchShadow &patch);
}
#endif // SHADOWPIXMAP_H
@@ -5,6 +5,8 @@
#include <QWheelEvent>
#include <cmath>
#include "motionprefs.h"
SmoothScrollController::SmoothScrollController(QScrollBar *scrollBar,
QObject *parent)
: QObject(parent), m_scrollBar(scrollBar)
@@ -179,6 +181,12 @@ void SmoothScrollController::startTicker()
return;
}
if (MotionPrefs::reduceMotion()) {
m_ticker.stop();
m_scrollBar->setValue(m_targetValue);
return;
}
m_ticker.setInterval(frameIntervalMs());
if (!m_ticker.isActive()) {
m_frameClock.restart();
+47
View File
@@ -0,0 +1,47 @@
#include "tweenedvalue.h"
#include <QVariantAnimation>
#include "motionprefs.h"
TweenedValue::TweenedValue(QObject *parent, int durationMs)
: QObject(parent), m_durationMs(durationMs)
{
m_anim = new QVariantAnimation(this);
m_anim->setDuration(m_durationMs);
m_anim->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_anim, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) { applyValue(value.toReal()); });
}
void TweenedValue::setTarget(qreal target)
{
if (m_target == target)
return;
m_target = target;
m_anim->stop();
if (MotionPrefs::reduceMotion() || m_value == target) {
applyValue(target);
return;
}
m_anim->setStartValue(m_value);
m_anim->setEndValue(target);
m_anim->start();
}
void TweenedValue::snapTo(qreal value)
{
m_anim->stop();
m_target = value;
applyValue(value);
}
void TweenedValue::applyValue(qreal value)
{
if (m_value == value)
return;
m_value = value;
Q_EMIT valueChanged(m_value);
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef QEMBYAPP_TWEENED_VALUE_H
#define QEMBYAPP_TWEENED_VALUE_H
#include <QObject>
#include "../tokens/AnimationTokens.h"
class QVariantAnimation;
// Glides a displayed value toward its data target (progress bars, arcs,
// counters) instead of jumping. setTarget() retargets mid-flight from the
// current value; snapTo() sets without animating (initialization / resets).
//
// Honors the "Reduce Animations" preference: when reduce-motion is on,
// setTarget() snaps and emits immediately.
class TweenedValue : public QObject
{
Q_OBJECT
public:
explicit TweenedValue(QObject *parent = nullptr,
int durationMs = AnimationTokens::DurationMs::kNormal);
void setTarget(qreal target);
void snapTo(qreal value);
qreal value() const { return m_value; }
qreal target() const { return m_target; }
Q_SIGNALS:
void valueChanged(qreal value);
private:
void applyValue(qreal value);
QVariantAnimation *m_anim = nullptr;
qreal m_value = 0.0;
qreal m_target = 0.0;
int m_durationMs;
};
#endif // QEMBYAPP_TWEENED_VALUE_H
+49 -1
View File
@@ -2,6 +2,8 @@
#include "librarygrid.h"
#include "../../components/libraryactionmenu.h"
#include "../../managers/thememanager.h"
#include "../../utils/hoveranimator.h"
#include "../../utils/tweenedvalue.h"
#include <QPainter>
#include <QPainterPath>
@@ -10,6 +12,16 @@
#include <QApplication>
#include <QHBoxLayout>
namespace {
QColor lerpColor(const QColor& from, const QColor& to, qreal t)
{
return QColor(qRound(from.red() + (to.red() - from.red()) * t),
qRound(from.green() + (to.green() - from.green()) * t),
qRound(from.blue() + (to.blue() - from.blue()) * t),
qRound(from.alpha() + (to.alpha() - from.alpha()) * t));
}
} // namespace
@@ -37,6 +49,13 @@ LibraryCard::LibraryCard(const VirtualFolder& folder, int index, QWidget* parent
m_btnScan->setToolTip(tr("Scan"));
m_btnDelete->setToolTip(tr("Delete"));
m_hover = new HoverAnimator(this);
m_hover->setCallback([this](qreal) { update(); });
m_scanTween = new TweenedValue(this);
connect(m_scanTween, &TweenedValue::valueChanged, this,
[this](qreal) { update(); });
updateButtonLayout();
connect(m_btnEdit, &QPushButton::clicked, this, [this]() { emit editClicked(m_folderIdx); });
@@ -75,13 +94,21 @@ void LibraryCard::setSelected(bool selected)
void LibraryCard::setScanProgress(int percent)
{
const bool wasActive = m_scanProgress >= 0;
m_scanProgress = qBound(0, percent, 100);
// First appearance snaps; later server updates glide the arc.
if (wasActive) {
m_scanTween->setTarget(m_scanProgress);
} else {
m_scanTween->snapTo(m_scanProgress);
}
update();
}
void LibraryCard::clearScanProgress()
{
m_scanProgress = -1;
m_scanTween->snapTo(0);
update();
}
@@ -135,6 +162,14 @@ void LibraryCard::paintEvent(QPaintEvent* event)
QColor typeFg = isDark ? QColor("#94A3B8") : QColor("#64748B");
const QColor accent("#3B82F6");
const qreal hp = m_hover->progress();
if (hp > 0.0 && !m_selected && !m_dragging) {
const QColor hoverBorder = isDark ? QColor(96, 165, 250, 190) : QColor(59, 130, 246, 190);
const QColor hoverBg = isDark ? QColor(51, 65, 85, 190) : QColor(255, 255, 255, 255);
border = lerpColor(border, hoverBorder, hp);
cardBg = lerpColor(cardBg, hoverBg, hp);
}
if (m_selected) {
cardBg = isDark ? QColor(30, 41, 59, 220) : QColor(239, 246, 255, 245);
border = isDark ? QColor(96, 165, 250, 220) : QColor(59, 130, 246, 220);
@@ -257,7 +292,8 @@ void LibraryCard::paintEvent(QPaintEvent* event)
p.setPen(arcPen);
int spanAngle = -static_cast<int>(m_scanProgress * 3.6 * 16);
// Arc glides via the tween; the % text shows the target value.
int spanAngle = -static_cast<int>(m_scanTween->value() * 3.6 * 16);
p.drawArc(arcRect, 90 * 16, spanAngle);
@@ -277,6 +313,18 @@ void LibraryCard::resizeEvent(QResizeEvent* event)
updateButtonLayout();
}
void LibraryCard::enterEvent(QEnterEvent* event)
{
QFrame::enterEvent(event);
m_hover->setHovered(true);
}
void LibraryCard::leaveEvent(QEvent* event)
{
QFrame::leaveEvent(event);
m_hover->setHovered(false);
}
void LibraryCard::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton) {
+7
View File
@@ -7,8 +7,11 @@
#include "../../components/librarycontextmenurequest.h"
#include <models/admin/adminmodels.h>
class HoverAnimator;
class LibraryGrid;
class TweenedValue;
class QContextMenuEvent;
class QEnterEvent;
class QMouseEvent;
class QPaintEvent;
class QResizeEvent;
@@ -52,6 +55,8 @@ protected:
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void enterEvent(QEnterEvent* event) override;
void leaveEvent(QEvent* event) override;
private:
void updateButtonLayout();
@@ -71,6 +76,8 @@ private:
QPushButton* m_btnEdit;
QPushButton* m_btnScan;
QPushButton* m_btnDelete;
HoverAnimator* m_hover = nullptr;
TweenedValue* m_scanTween = nullptr;
};
#endif
+14 -8
View File
@@ -6,6 +6,8 @@
#include <QScrollBar>
#include <QPropertyAnimation>
#include <QTimer>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/motionprefs.h"
static void stopPosAnimations(LibraryCard* card)
@@ -210,13 +212,13 @@ void LibraryGrid::relayout(bool animate)
if (card == m_dragCard) continue;
QPoint target = posForIndex(i, m_columns);
if (animate && card->pos() != target) {
if (animate && !MotionPrefs::reduceMotion() && card->pos() != target) {
stopPosAnimations(card);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(250);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(AnimationTokens::DurationMs::kNormal);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
@@ -274,11 +276,15 @@ void LibraryGrid::onCardDragEnd(LibraryCard* card)
stopPosAnimations(dragCard);
QPoint target = posForIndex(finalIndex, m_columns);
auto* anim = new QPropertyAnimation(dragCard, "pos", dragCard);
anim->setDuration(200);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
if (MotionPrefs::reduceMotion()) {
dragCard->move(target);
} else {
auto* anim = new QPropertyAnimation(dragCard, "pos", dragCard);
anim->setDuration(AnimationTokens::DurationMs::kFast);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
}
if (changed) {
QStringList newOrder;
+15 -6
View File
@@ -9,6 +9,8 @@
#include "pageusers.h"
#include "../../components/slidingstackedwidget.h"
#include "../../managers/thememanager.h"
#include "../../tokens/AnimationTokens.h"
#include "../../utils/motionprefs.h"
#include <qembycore.h>
#include <services/manager/servermanager.h>
#include <QApplication>
@@ -185,8 +187,8 @@ void ManageView::setupUi() {
m_scrollTargets.resize(m_scrollAreas.size(), 0);
for (auto *sa : m_scrollAreas) {
auto *anim = new QPropertyAnimation(sa->verticalScrollBar(), "value", this);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(450);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setDuration(AnimationTokens::DurationMs::kSlow);
m_scrollAnims.append(anim);
}
@@ -196,7 +198,10 @@ void ManageView::setupUi() {
void ManageView::setupConnections() {
connect(m_navMenu, &QListWidget::currentRowChanged, this, [this](int row) {
if (row >= 0) {
m_stack->setCurrentIndex(row);
// Vertical slide following the nav list's direction of travel.
m_stack->slideInIdx(row, row > m_stack->currentIndex()
? SlidingStackedWidget::BottomToTop
: SlidingStackedWidget::TopToBottom);
}
});
@@ -236,9 +241,13 @@ bool ManageView::eventFilter(QObject *obj, QEvent *event) {
if (newTarget != vBar->value()) {
m_scrollTargets[i] = newTarget;
anim->stop();
anim->setStartValue(vBar->value());
anim->setEndValue(newTarget);
anim->start();
if (MotionPrefs::reduceMotion()) {
vBar->setValue(newTarget);
} else {
anim->setStartValue(vBar->value());
anim->setEndValue(newTarget);
anim->start();
}
}
}
return true;
+6 -1
View File
@@ -4,6 +4,7 @@
#include <services/media/mediaservice.h>
#include "../../managers/thememanager.h"
#include "../../utils/imageutils.h"
#include "../../utils/tweenedvalue.h"
#include <QFontMetrics>
#include <QHBoxLayout>
@@ -86,6 +87,10 @@ void NowPlayingCard::setupUi(const SessionInfo& session) {
m_progressBar->setRange(0, 1000);
infoLayout->addWidget(m_progressBar);
m_progressTween = new TweenedValue(this);
connect(m_progressTween, &TweenedValue::valueChanged, this,
[this](qreal value) { m_progressBar->setValue(qRound(value)); });
infoLayout->addSpacing(2);
@@ -151,7 +156,7 @@ void NowPlayingCard::updateSession(const SessionInfo& session) {
m_stateIcon->setPixmap(stateIcon.pixmap(QSize(12, 12)));
m_progressText->setText(posStr + " / " + durStr);
m_progressBar->setValue(static_cast<int>(session.playbackProgress() * 1000));
m_progressTween->setTarget(session.playbackProgress() * 1000);
m_progressText->show();
m_stateIcon->show();
m_progressBar->show();
@@ -10,6 +10,7 @@ struct SessionInfo;
class QEmbyCore;
class QLabel;
class QProgressBar;
class TweenedValue;
class NowPlayingCard : public QFrame {
Q_OBJECT
@@ -38,6 +39,7 @@ private:
QProgressBar* m_progressBar;
QLabel* m_userLabel;
QLabel* m_streamLabel;
TweenedValue* m_progressTween = nullptr;
};
#endif
+3 -27
View File
@@ -25,6 +25,8 @@
#include <QTimeZone>
#include <QTransform>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include <QVBoxLayout>
namespace {
@@ -348,33 +350,7 @@ QCoro::Task<void> PageCollections::loadData(bool isManual) {
setLoadingState(true);
if (isManual) {
m_btnRefresh->setEnabled(false);
QIcon originalIcon = m_btnRefresh->icon();
QSize iconSz = m_btnRefresh->iconSize();
QPixmap basePx = originalIcon.pixmap(iconSz);
auto *rotAnim = new QVariantAnimation(this);
rotAnim->setStartValue(0.0);
rotAnim->setEndValue(360.0);
rotAnim->setDuration(800);
rotAnim->setEasingCurve(QEasingCurve::InOutCubic);
connect(rotAnim, &QVariantAnimation::valueChanged, this,
[this, basePx, iconSz](const QVariant &val) {
qreal angle = val.toReal();
QTransform t;
t.translate(iconSz.width() / 2.0, iconSz.height() / 2.0);
t.rotate(angle);
t.translate(-iconSz.width() / 2.0, -iconSz.height() / 2.0);
QPixmap rotated = basePx.transformed(t, Qt::SmoothTransformation);
int xOff = (rotated.width() - iconSz.width()) / 2;
int yOff = (rotated.height() - iconSz.height()) / 2;
QPixmap cropped =
rotated.copy(xOff, yOff, iconSz.width(), iconSz.height());
m_btnRefresh->setIcon(QIcon(cropped));
});
connect(rotAnim, &QVariantAnimation::finished, this,
[this, originalIcon]() { m_btnRefresh->setIcon(originalIcon); });
rotAnim->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
try {
+3 -35
View File
@@ -27,6 +27,8 @@
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
PageDashboard::PageDashboard(QEmbyCore *core, QWidget *parent) : ManagePageBase(core, tr("Dashboard"), parent)
{
@@ -222,41 +224,7 @@ QCoro::Task<void> PageDashboard::loadData(bool isManual)
if (isManual)
{
m_btnRefresh->setEnabled(false);
QIcon originalIcon = m_btnRefresh->icon();
QSize iconSz = m_btnRefresh->iconSize();
QPixmap basePx = originalIcon.pixmap(iconSz);
auto *rotAnim = new QVariantAnimation(this);
rotAnim->setStartValue(0.0);
rotAnim->setEndValue(360.0);
rotAnim->setDuration(800);
rotAnim->setEasingCurve(QEasingCurve::InOutCubic);
connect(rotAnim, &QVariantAnimation::valueChanged, this,
[this, basePx, iconSz](const QVariant &val)
{
qreal angle = val.toReal();
QTransform t;
t.translate(iconSz.width() / 2.0, iconSz.height() / 2.0);
t.rotate(angle);
t.translate(-iconSz.width() / 2.0, -iconSz.height() / 2.0);
QPixmap rotated = basePx.transformed(t, Qt::SmoothTransformation);
int xOff = (rotated.width() - iconSz.width()) / 2;
int yOff = (rotated.height() - iconSz.height()) / 2;
QPixmap cropped = rotated.copy(xOff, yOff, iconSz.width(), iconSz.height());
m_btnRefresh->setIcon(QIcon(cropped));
});
connect(rotAnim, &QVariantAnimation::finished, this,
[this, originalIcon]()
{
m_btnRefresh->setIcon(originalIcon);
});
rotAnim->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
try
+3 -27
View File
@@ -29,6 +29,8 @@
#include <QVariant>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
@@ -502,33 +504,7 @@ QCoro::Task<void> PageLibraries::loadData(bool isManual) {
setLoadingState(true);
if (isManual) {
m_btnRefresh->setEnabled(false);
QIcon originalIcon = m_btnRefresh->icon();
QSize iconSz = m_btnRefresh->iconSize();
QPixmap basePx = originalIcon.pixmap(iconSz);
auto *rotAnim = new QVariantAnimation(this);
rotAnim->setStartValue(0.0);
rotAnim->setEndValue(360.0);
rotAnim->setDuration(800);
rotAnim->setEasingCurve(QEasingCurve::InOutCubic);
connect(rotAnim, &QVariantAnimation::valueChanged, this,
[this, basePx, iconSz](const QVariant &val) {
qreal angle = val.toReal();
QTransform t;
t.translate(iconSz.width() / 2.0, iconSz.height() / 2.0);
t.rotate(angle);
t.translate(-iconSz.width() / 2.0, -iconSz.height() / 2.0);
QPixmap rotated = basePx.transformed(t, Qt::SmoothTransformation);
int xOff = (rotated.width() - iconSz.width()) / 2;
int yOff = (rotated.height() - iconSz.height()) / 2;
QPixmap cropped =
rotated.copy(xOff, yOff, iconSz.width(), iconSz.height());
m_btnRefresh->setIcon(QIcon(cropped));
});
connect(rotAnim, &QVariantAnimation::finished, this,
[this, originalIcon]() { m_btnRefresh->setIcon(originalIcon); });
rotAnim->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
try {
+3 -27
View File
@@ -36,6 +36,8 @@
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include <algorithm>
#include <functional>
@@ -529,33 +531,7 @@ QCoro::Task<void> PageLogs::loadFileList(bool isManual) {
}
if (isManual) {
const QIcon originalIcon = m_btnRefresh->icon();
const QSize iconSize = m_btnRefresh->iconSize();
const QPixmap basePixmap = originalIcon.pixmap(iconSize);
auto* rotationAnimation = new QVariantAnimation(this);
rotationAnimation->setStartValue(0.0);
rotationAnimation->setEndValue(360.0);
rotationAnimation->setDuration(800);
rotationAnimation->setEasingCurve(QEasingCurve::InOutCubic);
connect(rotationAnimation, &QVariantAnimation::valueChanged, this,
[this, basePixmap, iconSize](const QVariant& value) {
const qreal angle = value.toReal();
QTransform transform;
transform.translate(iconSize.width() / 2.0, iconSize.height() / 2.0);
transform.rotate(angle);
transform.translate(-iconSize.width() / 2.0, -iconSize.height() / 2.0);
const QPixmap rotated = basePixmap.transformed(transform, Qt::SmoothTransformation);
const int xOffset = (rotated.width() - iconSize.width()) / 2;
const int yOffset = (rotated.height() - iconSize.height()) / 2;
m_btnRefresh->setIcon(
QIcon(rotated.copy(xOffset, yOffset, iconSize.width(), iconSize.height())));
});
connect(rotationAnimation, &QVariantAnimation::finished, this,
[this, originalIcon]() { m_btnRefresh->setIcon(originalIcon); });
rotationAnimation->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
try {
+3 -27
View File
@@ -22,6 +22,8 @@
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include <algorithm>
@@ -155,33 +157,7 @@ QCoro::Task<void> PageTasks::loadData(bool isManual)
if (isManual)
{
QIcon originalIcon = m_btnRefresh->icon();
QSize iconSz = m_btnRefresh->iconSize();
QPixmap basePx = originalIcon.pixmap(iconSz);
auto *rotAnim = new QVariantAnimation(this);
rotAnim->setStartValue(0.0);
rotAnim->setEndValue(360.0);
rotAnim->setDuration(800);
rotAnim->setEasingCurve(QEasingCurve::InOutCubic);
connect(rotAnim, &QVariantAnimation::valueChanged, this,
[this, basePx, iconSz](const QVariant &val)
{
qreal angle = val.toReal();
QTransform t;
t.translate(iconSz.width() / 2.0, iconSz.height() / 2.0);
t.rotate(angle);
t.translate(-iconSz.width() / 2.0, -iconSz.height() / 2.0);
QPixmap rotated = basePx.transformed(t, Qt::SmoothTransformation);
int xOff = (rotated.width() - iconSz.width()) / 2;
int yOff = (rotated.height() - iconSz.height()) / 2;
QPixmap cropped = rotated.copy(xOff, yOff, iconSz.width(), iconSz.height());
m_btnRefresh->setIcon(QIcon(cropped));
});
connect(rotAnim, &QVariantAnimation::finished, this,
[this, originalIcon]() { m_btnRefresh->setIcon(originalIcon); });
rotAnim->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
try
+3 -29
View File
@@ -36,6 +36,8 @@
#include <QTransform>
#include <QVariantAnimation>
#include <QVBoxLayout>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include <algorithm>
#include <limits>
#include <utility>
@@ -2483,33 +2485,5 @@ bool PageTranscoding::isEmbyAdvancedMode(const QJsonObject& config) const {
}
void PageTranscoding::playRefreshAnimation() {
const QIcon originalIcon = m_btnRefresh->icon();
const QSize iconSize = m_btnRefresh->iconSize();
const QPixmap basePixmap = originalIcon.pixmap(iconSize);
if (iconSize.isEmpty() || basePixmap.isNull()) {
return;
}
auto* animation = new QVariantAnimation(this);
animation->setStartValue(0.0);
animation->setEndValue(360.0);
animation->setDuration(800);
animation->setEasingCurve(QEasingCurve::InOutCubic);
connect(animation, &QVariantAnimation::valueChanged, this,
[this, basePixmap, iconSize](const QVariant& value) {
QTransform transform;
transform.translate(iconSize.width() / 2.0, iconSize.height() / 2.0);
transform.rotate(value.toReal());
transform.translate(-iconSize.width() / 2.0, -iconSize.height() / 2.0);
const QPixmap rotated =
basePixmap.transformed(transform, Qt::SmoothTransformation);
const int x = (rotated.width() - iconSize.width()) / 2;
const int y = (rotated.height() - iconSize.height()) / 2;
m_btnRefresh->setIcon(
QIcon(rotated.copy(x, y, iconSize.width(), iconSize.height())));
});
connect(animation, &QVariantAnimation::finished, this,
[this, originalIcon]() { m_btnRefresh->setIcon(originalIcon); });
animation->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
+3 -28
View File
@@ -19,6 +19,8 @@
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
@@ -145,34 +147,7 @@ QCoro::Task<void> PageUsers::loadData(bool isManual) {
m_btnRefresh->setEnabled(false);
if (isManual) {
QIcon originalIcon = m_btnRefresh->icon();
QSize iconSz = m_btnRefresh->iconSize();
QPixmap basePx = originalIcon.pixmap(iconSz);
auto *rotAnim = new QVariantAnimation(this);
rotAnim->setStartValue(0.0);
rotAnim->setEndValue(360.0);
rotAnim->setDuration(800);
rotAnim->setEasingCurve(QEasingCurve::InOutCubic);
connect(rotAnim, &QVariantAnimation::valueChanged, this,
[this, basePx, iconSz](const QVariant &val) {
qreal angle = val.toReal();
QTransform t;
t.translate(iconSz.width() / 2.0, iconSz.height() / 2.0);
t.rotate(angle);
t.translate(-iconSz.width() / 2.0, -iconSz.height() / 2.0);
QPixmap rotated =
basePx.transformed(t, Qt::SmoothTransformation);
int xOff = (rotated.width() - iconSz.width()) / 2;
int yOff = (rotated.height() - iconSz.height()) / 2;
QPixmap cropped =
rotated.copy(xOff, yOff, iconSz.width(), iconSz.height());
m_btnRefresh->setIcon(QIcon(cropped));
});
connect(rotAnim, &QVariantAnimation::finished, this,
[this, originalIcon]() { m_btnRefresh->setIcon(originalIcon); });
rotAnim->start(QAbstractAnimation::DeleteWhenStopped);
RefreshSpin::start(m_btnRefresh);
}
try {
+10 -4
View File
@@ -1,4 +1,6 @@
#include "detailview.h"
#include "../../tokens/AnimationTokens.h"
#include "../../utils/fadehelper.h"
#include "../../components/detailactionwidget.h"
#include "../../components/detailbottominfowidget.h"
#include "../../components/detailcontentwidget.h"
@@ -463,7 +465,7 @@ void DetailView::setupUi() {
m_vScrollController =
new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this);
m_vScrollController->setDuration(170);
m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast);
m_contentWidget = new DetailContentWidget(m_mainScrollArea);
m_contentWidget->setObjectName("detail-content-container");
@@ -1037,7 +1039,9 @@ QCoro::Task<void> DetailView::loadItem(const QString &itemId,
QTimer::singleShot(380, this, [safeThis, itemId]() {
// Guard tracks the page-slide duration (kSlow) so the poster shadow / deferred
// content reveal lands as the incoming slide settles; kept in sync via the token.
QTimer::singleShot(AnimationTokens::DurationMs::kSlow, this, [safeThis, itemId]() {
if (!safeThis || safeThis->m_pendingDeferredFetchId != itemId)
return;
if (safeThis->m_posterShadow)
@@ -3253,7 +3257,8 @@ QCoro::Task<void> DetailView::executeLoadImages(QPointer<DetailView> safeThis,
if (cachedImages.logo.has_value()) {
safeThis->m_logoLabel->setPixmap(
QPixmap::fromImage(cachedImages.logo.value()));
safeThis->m_logoLabel->show();
FadeHelper::fadeIn(safeThis->m_logoLabel,
AnimationTokens::DurationMs::kNormal);
qDebug() << "[DetailView] Detail logo image cache hit"
<< "itemId=" << item.id;
}
@@ -3357,7 +3362,8 @@ QCoro::Task<void> DetailView::executeLoadImages(QPointer<DetailView> safeThis,
if (!result.isNull()) {
safeThis->m_logoLabel->setPixmap(
QPixmap::fromImage(result));
safeThis->m_logoLabel->show();
FadeHelper::fadeIn(safeThis->m_logoLabel,
AnimationTokens::DurationMs::kNormal);
saveDetailImageAsync(
cacheServerId, cacheUserId, currentItemId,
QStringLiteral("logo"), logoId, logoType, logoTag,
+342 -139
View File
@@ -2,6 +2,8 @@
#include "medialistmodel.h"
#include "../../managers/thememanager.h"
#include "../../utils/indexhoveranimator.h"
#include "../../utils/popfeedback.h"
#include <QAbstractItemView>
#include <QEvent>
#include <QFontMetrics>
@@ -11,6 +13,7 @@
#include <QPainterPath>
#include <QPixmapCache>
#include <QStyle>
#include <QVariantAnimation>
namespace
{
@@ -87,11 +90,19 @@ QRect baseImageRectForCard(MediaCardDelegate::CardStyle style, const QRect &rect
return QRect(rect.x() + padding, rect.y() + padding, imgWidth, imgHeight);
}
QRect hoverImageRect(const QRect &baseImgRect)
QRect episodeImageRect(const QRect &rect)
{
const int expandW = qRound(baseImgRect.width() * 0.035);
const int expandH = qRound(baseImgRect.height() * 0.035);
return baseImgRect.adjusted(-expandW, -expandH, expandW, expandH);
const int padding = 10;
const int imgWidth = 250;
const int imgHeight = 140;
return QRect(rect.x() + padding, rect.y() + (rect.height() - imgHeight) / 2, imgWidth, imgHeight);
}
QRect episodeFavoriteRect(const QRect &rect)
{
const int favBtnSize = 24;
const int rightMargin = 20;
return QRect(rect.right() - rightMargin - favBtnSize, rect.center().y() - favBtnSize / 2, favBtnSize, favBtnSize);
}
QRect centerPlayButtonRect(const QRect &targetImgRect)
@@ -201,6 +212,154 @@ MediaCardThemeHelper::MediaCardThemeHelper(QWidget *parent)
MediaCardDelegate::MediaCardDelegate(CardStyle style, QObject *parent)
: QStyledItemDelegate(parent), m_style(style), m_tileSize(160, 270), m_themeHelper(nullptr)
{
m_hover = new IndexHoverAnimator(this);
// Stale-region reach: 3px lift + shadow falloff + 5px downward offset.
m_hover->setRepaintMargin(24);
m_hover->setRepaintCallback([this](const QModelIndex &index) { repaintIndex(index); });
}
void MediaCardDelegate::repaintIndex(const QModelIndex &index) const
{
if (!index.isValid())
{
return;
}
const int margin = m_hover->repaintMargin();
for (QWidget *viewport : m_installedViewports)
{
auto *view = qobject_cast<QAbstractItemView *>(viewport->parentWidget());
if (view && view->model() == index.model())
{
viewport->update(view->visualRect(index).adjusted(-margin, -margin, margin, margin));
}
}
}
MediaCardDelegate::HoverControl MediaCardDelegate::hoverButtonAt(const QRect &itemRect, const MediaItem &item,
const QPoint &pos) const
{
const HoverOverlayMode overlayMode = hoverOverlayModeForItem(item, m_style, m_showMoreButtonForNonPlayableTiles);
if (overlayMode == HoverOverlayMode::None)
{
return HoverControlNone;
}
if (m_style == EpisodeList)
{
if (m_hoverControls.testFlag(HoverControlFavorite) && episodeFavoriteRect(itemRect).contains(pos))
{
return HoverControlFavorite;
}
if (m_hoverControls.testFlag(HoverControlPlay) &&
centerPlayButtonRect(episodeImageRect(itemRect)).contains(pos))
{
return HoverControlPlay;
}
return HoverControlNone;
}
const QRect baseImgRect = baseImageRectForCard(m_style, itemRect, m_contentPadding);
const bool showPlayButton = shouldShowPlayButton(overlayMode, m_hoverControls);
const bool showFavoriteButton = shouldShowFavoriteButton(overlayMode, m_hoverControls);
const bool showMoreButton = shouldShowMoreButton(overlayMode, m_hoverControls);
if (!showPlayButton && !showFavoriteButton && !showMoreButton)
{
return HoverControlNone;
}
const HoverButtonLayout layout =
buildHoverButtonLayout(baseImgRect, showPlayButton, showFavoriteButton, showMoreButton);
if (showPlayButton && layout.playRect.contains(pos))
{
return HoverControlPlay;
}
if (showFavoriteButton && layout.favoriteRect.contains(pos))
{
return HoverControlFavorite;
}
if (showMoreButton && layout.moreRect.contains(pos))
{
return HoverControlMore;
}
return HoverControlNone;
}
void MediaCardDelegate::setHoveredButton(const QPersistentModelIndex &index, HoverControl button) const
{
if (m_hoverButtonIndex == index && m_hoveredButton == button)
{
return;
}
const QPersistentModelIndex previous = m_hoverButtonIndex;
m_hoverButtonIndex = index;
m_hoveredButton = button;
if (previous.isValid() && previous != index)
{
repaintIndex(previous);
}
repaintIndex(index);
}
void MediaCardDelegate::setPressedIndex(const QPersistentModelIndex &index) const
{
if (m_pressedIndex == index)
{
return;
}
const QPersistentModelIndex previous = m_pressedIndex;
m_pressedIndex = index;
if (previous.isValid())
{
repaintIndex(previous);
}
repaintIndex(index);
}
void MediaCardDelegate::playFavoritePop(const QPersistentModelIndex &index)
{
if (!index.isValid())
{
return;
}
auto *anim = PopFeedback::play(this, [this, index](qreal scale) {
m_popScale.insert(index, scale);
repaintIndex(index);
});
if (anim)
{
connect(anim, &QObject::destroyed, this, [this, index]() {
m_popScale.remove(index);
repaintIndex(index);
});
}
else
{
m_popScale.remove(index);
}
}
qreal MediaCardDelegate::hoverProgressFor(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
// MouseMove on the viewport drives m_hover; State_MouseOver only bootstraps
// states the filter cannot see (view shown under the cursor, hover moved by
// scrolling). Never mutate animation state synchronously inside paint().
if ((option.state & QStyle::State_MouseOver) && m_hover->hoveredIndex() != index)
{
const QPersistentModelIndex persistent(index);
QMetaObject::invokeMethod(
m_hover, [hover = m_hover, persistent]() { hover->setHoveredIndex(persistent); },
Qt::QueuedConnection);
}
return m_hover->progressFor(index);
}
const NinePatchShadow &MediaCardDelegate::liftShadow(qreal dpr, int cornerRadius) const
{
if (m_liftShadow.isNull() || !qFuzzyCompare(m_liftShadow.dpr, dpr))
{
m_liftShadow = ShadowPixmap::make(14, cornerRadius, QColor(0, 0, 0, 110), dpr);
}
return m_liftShadow;
}
QSize MediaCardDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
@@ -214,109 +373,81 @@ QSize MediaCardDelegate::sizeHint(const QStyleOptionViewItem &option, const QMod
bool MediaCardDelegate::eventFilter(QObject *object, QEvent *event)
{
if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonRelease ||
event->type() == QEvent::MouseButtonDblClick)
QWidget *viewport = qobject_cast<QWidget *>(object);
QAbstractItemView *view = viewport ? qobject_cast<QAbstractItemView *>(viewport->parentWidget()) : nullptr;
if (!view)
{
return QStyledItemDelegate::eventFilter(object, event);
}
QWidget *viewport = qobject_cast<QWidget *>(object);
if (viewport)
const QEvent::Type type = event->type();
if (type == QEvent::Leave)
{
m_hover->clear();
setHoveredButton(QPersistentModelIndex(), HoverControlNone);
setPressedIndex(QPersistentModelIndex());
return QStyledItemDelegate::eventFilter(object, event);
}
if (type == QEvent::MouseMove)
{
QMouseEvent *mouseEvent = static_cast<QMouseEvent *>(event);
const QPoint pos = mouseEvent->pos();
const QModelIndex index = view->indexAt(pos);
const QPersistentModelIndex persistent(index);
m_hover->setHoveredIndex(persistent);
HoverControl button = HoverControlNone;
if (index.isValid())
{
const MediaItem item = index.data(MediaListModel::ItemDataRole).value<MediaItem>();
button = hoverButtonAt(view->visualRect(index), item, pos);
}
setHoveredButton(button == HoverControlNone ? QPersistentModelIndex() : persistent, button);
return QStyledItemDelegate::eventFilter(object, event);
}
QAbstractItemView *view = qobject_cast<QAbstractItemView *>(viewport->parentWidget());
if (view)
if (type == QEvent::MouseButtonPress || type == QEvent::MouseButtonRelease ||
type == QEvent::MouseButtonDblClick)
{
QMouseEvent *mouseEvent = static_cast<QMouseEvent *>(event);
const QPoint pos = mouseEvent->pos();
const QModelIndex index = view->indexAt(pos);
// Press feedback is instant; only the release animates.
if (type == QEvent::MouseButtonPress && mouseEvent->button() == Qt::LeftButton)
{
setPressedIndex(QPersistentModelIndex(index));
}
else if (type == QEvent::MouseButtonRelease)
{
setPressedIndex(QPersistentModelIndex());
}
if (index.isValid())
{
const MediaItem item = index.data(MediaListModel::ItemDataRole).value<MediaItem>();
const HoverControl button = hoverButtonAt(view->visualRect(index), item, pos);
if (button != HoverControlNone)
{
QMouseEvent *mouseEvent = static_cast<QMouseEvent *>(event);
QPoint pos = mouseEvent->pos();
QModelIndex index = view->indexAt(pos);
if (index.isValid())
if (type == QEvent::MouseButtonRelease)
{
MediaItem item = index.data(MediaListModel::ItemDataRole).value<MediaItem>();
const HoverOverlayMode overlayMode =
hoverOverlayModeForItem(item, m_style, m_showMoreButtonForNonPlayableTiles);
if (overlayMode != HoverOverlayMode::None)
if (button == HoverControlPlay)
{
QRect rect = view->visualRect(index);
if (m_style == EpisodeList)
{
int padding = 10;
int imgWidth = 250;
int imgHeight = 140;
QRect baseImgRect(rect.x() + padding, rect.y() + (rect.height() - imgHeight) / 2, imgWidth,
imgHeight);
int favBtnSize = 24;
int rightMargin = 20;
QRect favRect(rect.right() - rightMargin - favBtnSize, rect.center().y() - favBtnSize / 2,
favBtnSize, favBtnSize);
const bool showFavoriteButton = m_hoverControls.testFlag(HoverControlFavorite);
bool inFav = showFavoriteButton && favRect.contains(pos);
int playSize = 48;
QRect playRectCenter(baseImgRect.center().x() - playSize / 2,
baseImgRect.center().y() - playSize / 2, playSize, playSize);
const bool showPlayButton = m_hoverControls.testFlag(HoverControlPlay);
bool inThumbPlay = showPlayButton && playRectCenter.contains(pos);
if (inFav || inThumbPlay)
{
if (event->type() == QEvent::MouseButtonRelease)
{
if (inThumbPlay)
emit playRequested(item);
else if (inFav)
emit favoriteRequested(item);
}
return true;
}
}
else
{
const QRect baseImgRect = baseImageRectForCard(m_style, rect, m_contentPadding);
const QRect targetImgRect = hoverImageRect(baseImgRect);
const bool showPlayButton = shouldShowPlayButton(overlayMode, m_hoverControls);
const bool showFavoriteButton = shouldShowFavoriteButton(overlayMode, m_hoverControls);
const bool showMoreButton = shouldShowMoreButton(overlayMode, m_hoverControls);
if (!showPlayButton && !showFavoriteButton && !showMoreButton)
{
return QStyledItemDelegate::eventFilter(object, event);
}
const HoverButtonLayout buttonLayout = buildHoverButtonLayout(
targetImgRect, showPlayButton, showFavoriteButton, showMoreButton);
const bool inPlay = showPlayButton && buttonLayout.playRect.contains(pos);
const bool inFav = showFavoriteButton && buttonLayout.favoriteRect.contains(pos);
const bool inMore = showMoreButton && buttonLayout.moreRect.contains(pos);
if (inPlay || inFav || inMore)
{
if (event->type() == QEvent::MouseButtonRelease)
{
if (inPlay)
emit playRequested(item);
else if (inFav)
emit favoriteRequested(item);
else if (inMore)
emit moreMenuRequested(item, mouseEvent->globalPosition().toPoint());
}
return true;
}
}
emit playRequested(item);
}
else if (button == HoverControlFavorite)
{
playFavoritePop(QPersistentModelIndex(index));
emit favoriteRequested(item);
}
else if (button == HoverControlMore)
{
emit moreMenuRequested(item, mouseEvent->globalPosition().toPoint());
}
}
return true;
}
}
}
@@ -372,7 +503,9 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
QRect rect = option.rect;
MediaItem item = index.data(MediaListModel::ItemDataRole).value<MediaItem>();
QPixmap poster = index.data(MediaListModel::PosterPixmapRole).value<QPixmap>();
bool isHovered = option.state & QStyle::State_MouseOver;
const qreal hp = hoverProgressFor(option, index);
const bool isPressed = m_pressedIndex.isValid() && m_pressedIndex == index;
const qreal popScale = m_popScale.value(QPersistentModelIndex(index), 1.0);
const HoverOverlayMode overlayMode = hoverOverlayModeForItem(item, m_style, m_showMoreButtonForNonPlayableTiles);
const bool showPlayButton = shouldShowPlayButton(overlayMode, m_hoverControls);
const bool showFavoriteButton = shouldShowFavoriteButton(overlayMode, m_hoverControls);
@@ -384,18 +517,22 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
if (m_style == EpisodeList)
{
if (isHovered)
if (hp > 0.01)
{
QColor rowBg = isDarkTheme ? QColor(255, 255, 255, 20) : QColor(0, 0, 0, 15);
rowBg.setAlpha(qRound(rowBg.alpha() * hp));
painter->setPen(Qt::NoPen);
painter->setBrush(isDarkTheme ? QColor(255, 255, 255, 20) : QColor(0, 0, 0, 15));
painter->setBrush(rowBg);
painter->drawRoundedRect(rect.adjusted(5, 5, -5, -5), 8, 8);
}
if (isPressed)
{
painter->setPen(Qt::NoPen);
painter->setBrush(isDarkTheme ? QColor(255, 255, 255, 14) : QColor(0, 0, 0, 20));
painter->drawRoundedRect(rect.adjusted(5, 5, -5, -5), 8, 8);
}
int padding = 10;
int imgWidth = 250;
int imgHeight = 140;
QRect baseImgRect(rect.x() + padding, rect.y() + (rect.height() - imgHeight) / 2, imgWidth, imgHeight);
const QRect baseImgRect = episodeImageRect(rect);
int imgRadius = 8;
painter->setPen(Qt::NoPen);
@@ -419,17 +556,24 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
painter->drawText(baseImgRect, Qt::AlignCenter, tr("No Image"));
}
if (isHovered && showPlayButton)
if (hp > 0.01 && showPlayButton)
{
painter->setOpacity(hp);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 70));
painter->drawRoundedRect(baseImgRect, imgRadius, imgRadius);
int playSize = 48;
QRect playRectCenter(baseImgRect.center().x() - playSize / 2, baseImgRect.center().y() - playSize / 2,
playSize, playSize);
painter->setBrush(QColor(0, 0, 0, 160));
const QRect playRectCenter = centerPlayButtonRect(baseImgRect);
const bool playHovered = m_hoverButtonIndex == index && m_hoveredButton == HoverControlPlay;
painter->setBrush(QColor(0, 0, 0, playHovered ? 200 : 160));
painter->drawEllipse(playRectCenter);
if (playHovered)
{
painter->setPen(QPen(QColor(255, 255, 255, 90), 1));
painter->setBrush(Qt::NoBrush);
painter->drawEllipse(playRectCenter);
painter->setPen(Qt::NoPen);
}
QIcon playIcon(":/svg/player/play.svg");
if (!playIcon.isNull())
{
@@ -445,6 +589,7 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
painter->setBrush(Qt::white);
painter->drawPath(playPath);
}
painter->setOpacity(1.0);
}
@@ -506,15 +651,22 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
if (showFavoriteButton)
{
QRect favRect(rect.right() - rightMargin - favBtnSize, rect.center().y() - favBtnSize / 2, favBtnSize,
favBtnSize);
QRectF favRect(episodeFavoriteRect(rect));
const bool favHovered = m_hoverButtonIndex == index && m_hoveredButton == HoverControlFavorite;
const qreal favScale = popScale * (favHovered ? 1.1 : 1.0);
if (!qFuzzyCompare(favScale, 1.0))
{
const QPointF center = favRect.center();
favRect.setSize(favRect.size() * favScale);
favRect.moveCenter(center);
}
QString themeFolder = isDarkTheme ? "dark" : "light";
QString favIconPath = item.isFavorite() ? QString(":/svg/%1/heart-fill.svg").arg(themeFolder)
: QString(":/svg/%1/heart-outline.svg").arg(themeFolder);
QIcon favIcon(favIconPath);
if (!favIcon.isNull())
{
favIcon.paint(painter, favRect.adjusted(2, 2, -2, -2));
favIcon.paint(painter, favRect.toAlignedRect().adjusted(2, 2, -2, -2));
}
}
@@ -566,25 +718,24 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
yearRect = QRect(rect.x() + padding, titleRect.bottom() + 1, imgWidth, subTitleHeight);
}
// Ring + lift + fade hover: the image block rises slightly and gains a
// pre-baked soft shadow; paint and hit-testing share baseImgRect.
const int imgRadius = 8;
const int lift = qRound(3.0 * hp);
const QRect targetImgRect = baseImgRect.translated(0, -lift);
QRect targetImgRect = baseImgRect;
if (isHovered)
if (hp > 0.01)
{
targetImgRect = hoverImageRect(baseImgRect);
const int expandH = targetImgRect.bottom() - baseImgRect.bottom();
titleRect.translate(0, expandH);
yearRect.translate(0, expandH);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 60));
painter->drawRoundedRect(targetImgRect.translated(0, 6), 12, 12);
const qreal dpr = option.widget ? option.widget->devicePixelRatioF() : 1.0;
const NinePatchShadow &shadow = liftShadow(dpr, imgRadius);
painter->setOpacity(hp);
ShadowPixmap::drawNinePatch(
painter,
targetImgRect.adjusted(-shadow.border, -shadow.border, shadow.border, shadow.border).translated(0, 5),
shadow);
painter->setOpacity(1.0);
}
int imgRadius = 8;
painter->setPen(Qt::NoPen);
painter->setBrush(placeholderBg);
painter->drawRoundedRect(targetImgRect, imgRadius, imgRadius);
@@ -681,20 +832,30 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
if (isHovered && hasHoverButtons)
if (hp > 0.01 && hasHoverButtons)
{
painter->setOpacity(hp);
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 70));
painter->drawRoundedRect(targetImgRect, imgRadius, imgRadius);
const HoverButtonLayout buttonLayout =
buildHoverButtonLayout(targetImgRect, showPlayButton, showFavoriteButton, showMoreButton);
const bool buttonStateHere = m_hoverButtonIndex == index;
const QPen buttonHoverPen(QColor(255, 255, 255, 90), 1);
if (showPlayButton)
{
painter->setBrush(QColor(0, 0, 0, 160));
const bool playHovered = buttonStateHere && m_hoveredButton == HoverControlPlay;
painter->setBrush(QColor(0, 0, 0, playHovered ? 200 : 160));
painter->drawEllipse(buttonLayout.playRect);
if (playHovered)
{
painter->setPen(buttonHoverPen);
painter->setBrush(Qt::NoBrush);
painter->drawEllipse(buttonLayout.playRect);
painter->setPen(Qt::NoPen);
}
QIcon playIcon(":/svg/player/play.svg");
if (!playIcon.isNull())
@@ -715,8 +876,16 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
if (showMoreButton)
{
painter->setBrush(QColor(0, 0, 0, 160));
const bool moreHovered = buttonStateHere && m_hoveredButton == HoverControlMore;
painter->setBrush(QColor(0, 0, 0, moreHovered ? 200 : 160));
painter->drawRoundedRect(buttonLayout.moreRect, 6, 6);
if (moreHovered)
{
painter->setPen(buttonHoverPen);
painter->setBrush(Qt::NoBrush);
painter->drawRoundedRect(buttonLayout.moreRect, 6, 6);
painter->setPen(Qt::NoPen);
}
QIcon moreIcon(":/svg/dark/more-line.svg");
if (!moreIcon.isNull())
@@ -736,27 +905,61 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt
if (showFavoriteButton)
{
painter->setBrush(QColor(0, 0, 0, 160));
painter->drawRoundedRect(buttonLayout.favoriteRect, 6, 6);
const bool favHovered = buttonStateHere && m_hoveredButton == HoverControlFavorite;
QRectF favRect(buttonLayout.favoriteRect);
if (!qFuzzyCompare(popScale, 1.0))
{
const QPointF center = favRect.center();
favRect.setSize(favRect.size() * popScale);
favRect.moveCenter(center);
}
painter->setBrush(QColor(0, 0, 0, favHovered ? 200 : 160));
painter->drawRoundedRect(favRect, 6, 6);
if (favHovered)
{
painter->setPen(buttonHoverPen);
painter->setBrush(Qt::NoBrush);
painter->drawRoundedRect(favRect, 6, 6);
painter->setPen(Qt::NoPen);
}
QString favIconPath = item.isFavorite() ? ":/svg/dark/heart-fill.svg" : ":/svg/dark/heart-outline.svg";
QIcon favIcon(favIconPath);
if (!favIcon.isNull())
{
favIcon.paint(painter, buttonLayout.favoriteRect.adjusted(5, 5, -5, -5), Qt::AlignCenter);
favIcon.paint(painter, favRect.toAlignedRect().adjusted(5, 5, -5, -5), Qt::AlignCenter);
}
else
{
painter->setBrush(Qt::white);
painter->drawEllipse(buttonLayout.favoriteRect.center(), 4, 4);
painter->drawEllipse(favRect.center(), 4, 4);
}
}
painter->setOpacity(1.0);
}
// Press feedback: instant dim, no animation.
if (isPressed)
{
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0, 0, 0, 45));
painter->drawRoundedRect(targetImgRect, imgRadius, imgRadius);
}
const bool isHighlighted = !m_highlightedItemId.isEmpty() && item.id == m_highlightedItemId;
if (hp > 0.01 && !isHighlighted)
{
// Hover ring; yields to the highlight ring below.
QColor ringColor = isDarkTheme ? QColor(96, 165, 250) : QColor(59, 130, 246);
ringColor.setAlpha(qRound(200 * hp));
painter->setPen(QPen(ringColor, 1.5));
painter->setBrush(Qt::NoBrush);
painter->drawRoundedRect(QRectF(targetImgRect).adjusted(-1.5, -1.5, 1.5, 1.5), 9.5, 9.5);
painter->setPen(Qt::NoPen);
}
if (!m_highlightedItemId.isEmpty() && item.id == m_highlightedItemId)
if (isHighlighted)
{
QColor borderColor = isDarkTheme ? QColor(96, 165, 250, 220)
: QColor(59, 130, 246, 220);
@@ -5,10 +5,15 @@
#include <QWidget>
#include <QColor>
#include <QFlags>
#include <QHash>
#include <QPersistentModelIndex>
#include <QSet>
#include "../../utils/shadowpixmap.h"
struct MediaItem;
class IndexHoverAnimator;
class QAbstractItemView;
class MediaCardThemeHelper : public QWidget {
@@ -109,11 +114,26 @@ protected:
bool eventFilter(QObject *object, QEvent *event) override;
private:
// Hover button hit-testing shared by paint highlights and mouse handling.
HoverControl hoverButtonAt(const QRect &itemRect, const MediaItem &item, const QPoint &pos) const;
void setHoveredButton(const QPersistentModelIndex &index, HoverControl button) const;
void setPressedIndex(const QPersistentModelIndex &index) const;
void repaintIndex(const QModelIndex &index) const;
void playFavoritePop(const QPersistentModelIndex &index);
qreal hoverProgressFor(const QStyleOptionViewItem &option, const QModelIndex &index) const;
const NinePatchShadow &liftShadow(qreal dpr, int cornerRadius) const;
CardStyle m_style;
QSize m_tileSize;
QString m_highlightedItemId;
mutable MediaCardThemeHelper *m_themeHelper;
mutable QSet<QWidget*> m_installedViewports;
IndexHoverAnimator *m_hover = nullptr;
mutable QPersistentModelIndex m_pressedIndex;
mutable QPersistentModelIndex m_hoverButtonIndex;
mutable HoverControl m_hoveredButton = HoverControlNone;
QHash<QPersistentModelIndex, qreal> m_popScale;
mutable NinePatchShadow m_liftShadow;
bool m_showMoreButtonForNonPlayableTiles = false;
HoverControls m_hoverControls = HoverControlAll;
int m_titleFontPixelSize = 13;
+53 -10
View File
@@ -8,6 +8,7 @@
#include "../../components/playerosdlayer.h"
#include "../../components/playerstatisticsoverlay.h"
#include "../../components/playersubtitlesettingsdialog.h"
#include "../../components/seektimebubble.h"
#include "../../components/shadowoverlay.h"
#include "../../utils/mediaitemutils.h"
#include "../../utils/mediasourcepreferenceutils.h"
@@ -42,6 +43,8 @@
#include <QVBoxLayout>
#include <QWheelEvent>
#include <fileutils.h>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/motionprefs.h"
#include <QVector>
#include <QWindow>
@@ -1369,16 +1372,20 @@ void PlayerView::setupUi()
m_fadeGroup = new QParallelAnimationGroup(this);
auto *topAnim = new QPropertyAnimation(m_topOpacity, "opacity", this);
topAnim->setDuration(250);
topAnim->setDuration(AnimationTokens::DurationMs::kNormal);
topAnim->setEasingCurve(AnimationTokens::Curve::fade());
auto *bottomAnim = new QPropertyAnimation(m_bottomOpacity, "opacity", this);
bottomAnim->setDuration(250);
bottomAnim->setDuration(AnimationTokens::DurationMs::kNormal);
bottomAnim->setEasingCurve(AnimationTokens::Curve::fade());
auto *logoAnim = new QPropertyAnimation(m_logoOpacity, "opacity", this);
logoAnim->setDuration(250);
logoAnim->setDuration(AnimationTokens::DurationMs::kNormal);
logoAnim->setEasingCurve(AnimationTokens::Curve::fade());
auto *speedAnim = new QPropertyAnimation(m_speedOpacity, "opacity", this);
speedAnim->setDuration(250);
speedAnim->setDuration(AnimationTokens::DurationMs::kNormal);
speedAnim->setEasingCurve(AnimationTokens::Curve::fade());
m_fadeGroup->addAnimation(topAnim);
m_fadeGroup->addAnimation(bottomAnim);
@@ -1389,6 +1396,34 @@ void PlayerView::setupUi()
connect(m_playPauseBtn, &QPushButton::clicked, this, &PlayerView::togglePlayPause);
connect(m_progressSlider, &QSlider::sliderMoved, this, &PlayerView::onSliderMoved);
m_seekBubble = new SeekTimeBubble(this);
connect(m_progressSlider, &ModernSlider::hoverMoved, this,
[this](const QPoint &pos)
{
if (m_totalDuration <= 0)
{
m_seekBubble->hideBubble();
return;
}
// Mirror ModernSlider's track math so the bubble time matches
// what a click at this x would seek to.
const int edgePadding = qMax(m_progressSlider->normalHandleRadius(),
m_progressSlider->activeHandleRadius());
const double trackWidth = m_progressSlider->width() - 2.0 * edgePadding;
if (trackWidth <= 0)
{
return;
}
double ratio = (pos.x() - edgePadding) / trackWidth;
ratio = qBound(0.0, ratio, 1.0);
const double hoverSeconds = ratio * m_totalDuration;
m_seekBubble->setTimeText(formatTime(hoverSeconds, m_totalDuration));
const QPoint anchor = m_progressSlider->mapTo(this, QPoint(pos.x(), 0));
m_seekBubble->showAt(anchor - QPoint(0, 8));
});
connect(m_progressSlider, &ModernSlider::hoverLeft, this,
[this]() { m_seekBubble->hideBubble(); });
connect(m_rewindBtn, &QPushButton::pressed, this,
[this]()
@@ -2197,8 +2232,8 @@ void PlayerView::setupRightSidebar()
});
m_rightSidebarAnim = new QPropertyAnimation(m_rightSidebar, "pos", this);
m_rightSidebarAnim->setDuration(350);
m_rightSidebarAnim->setEasingCurve(QEasingCurve::OutCubic);
m_rightSidebarAnim->setDuration(AnimationTokens::DurationMs::kSlow);
m_rightSidebarAnim->setEasingCurve(AnimationTokens::Curve::enter());
connect(m_rightSidebarAnim, &QPropertyAnimation::finished, this,
[this]()
{
@@ -2224,7 +2259,8 @@ QCoro::Task<void> PlayerView::showRightSidebar()
const int sidebarY = m_topHUD->height();
const QPoint hiddenPos(width() + 30, sidebarY);
if (!m_rightSidebar || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject())
if (!m_rightSidebar || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject() ||
MotionPrefs::reduceMotion())
{
if (m_rightSidebar)
{
@@ -2285,7 +2321,8 @@ void PlayerView::hideRightSidebar(bool immediate)
const int sidebarY = m_topHUD ? m_topHUD->height() : 0;
const QPoint hiddenPos(width() + 30, sidebarY);
if (immediate || m_hasReportedStop || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject())
if (immediate || m_hasReportedStop || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject() ||
MotionPrefs::reduceMotion())
{
if (m_rightSidebarAnim)
{
@@ -3141,7 +3178,8 @@ void PlayerView::showControls()
stopFadeGroupSafely(m_fadeGroup);
}
bool canAnimate = true;
// Reduce-motion routes through the existing snap-to-target fallback below.
bool canAnimate = !MotionPrefs::reduceMotion();
for (int i = 0; i < m_fadeGroup->animationCount(); ++i)
{
auto *anim = qobject_cast<QPropertyAnimation *>(m_fadeGroup->animationAt(i));
@@ -3207,6 +3245,10 @@ void PlayerView::hideControls()
hideRightSidebar();
hideHudMediaSwitcher();
setPlayerChromeVisible(false);
if (m_seekBubble)
{
m_seekBubble->hideBubble();
}
if (m_activePopup)
{
@@ -3226,7 +3268,8 @@ void PlayerView::hideControls()
stopFadeGroupSafely(m_fadeGroup);
}
bool canAnimate = true;
// Reduce-motion routes through the existing snap-to-target fallback below.
bool canAnimate = !MotionPrefs::reduceMotion();
for (int i = 0; i < m_fadeGroup->animationCount(); ++i)
{
auto *anim = qobject_cast<QPropertyAnimation *>(m_fadeGroup->animationAt(i));
+2
View File
@@ -35,6 +35,7 @@ class PlayerOsdLayer;
class PlayerLongPressHandler;
class PlayerStatisticsOverlay;
class NativeDanmakuOverlay;
class SeekTimeBubble;
class PlayerView : public BaseView {
Q_OBJECT
@@ -229,6 +230,7 @@ private:
QLabel *m_currentTimeLabel;
ModernSlider *m_progressSlider;
QLabel *m_totalTimeLabel;
SeekTimeBubble *m_seekBubble = nullptr;
QPushButton *m_prevMediaBtn;
+3 -2
View File
@@ -41,6 +41,7 @@
#include <QStandardPaths>
#include <QTextStream>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include <QVBoxLayout>
#include <QtConcurrent/QtConcurrent>
#include <memory>
@@ -1047,8 +1048,8 @@ PagePlayer::PagePlayer(QEmbyCore *core, QWidget *parent)
auto *rotAnim = new QVariantAnimation(searchBtn);
rotAnim->setStartValue(0.0);
rotAnim->setEndValue(360.0);
rotAnim->setDuration(500);
rotAnim->setEasingCurve(QEasingCurve::InOutCubic);
rotAnim->setDuration(AnimationTokens::DurationMs::kSlow);
rotAnim->setEasingCurve(AnimationTokens::Curve::enter());
connect(rotAnim, &QVariantAnimation::valueChanged, searchBtn,
[searchBtn, originalPix](const QVariant &val) {
+16 -7
View File
@@ -1,4 +1,6 @@
#include "settingsview.h"
#include "../../tokens/AnimationTokens.h"
#include "../../utils/motionprefs.h"
#include "../../components/slidingstackedwidget.h"
#include "../../managers/thememanager.h"
#include "pageabout.h"
@@ -148,8 +150,8 @@ QScrollArea *SettingsView::wrapInScrollArea(QWidget *page, int row) {
auto *anim =
new QPropertyAnimation(scroll->verticalScrollBar(), "value", this);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setDuration(450);
anim->setEasingCurve(AnimationTokens::Curve::enter());
anim->setDuration(AnimationTokens::DurationMs::kSlow);
m_scrollAnims[row] = anim;
m_scrollTargets[row] = 0;
}
@@ -198,7 +200,7 @@ void SettingsView::ensurePageAt(int row) {
m_stack->insertWidget(row, scroll);
m_stack->removeWidget(placeholder);
m_stack->blockSignals(wasBlocked);
placeholder->deleteLater();
m_stack->disposeWidgetWhenSafe(placeholder);
m_pages[row] = scroll;
}
@@ -208,7 +210,10 @@ void SettingsView::setupConnections() {
connect(m_navMenu, &QListWidget::currentRowChanged, this, [this](int row) {
if (row >= 0) {
ensurePageAt(row);
m_stack->setCurrentIndex(row);
// Vertical slide following the nav list's direction of travel.
m_stack->slideInIdx(row, row > m_stack->currentIndex()
? SlidingStackedWidget::BottomToTop
: SlidingStackedWidget::TopToBottom);
}
});
@@ -261,9 +266,13 @@ bool SettingsView::eventFilter(QObject *obj, QEvent *event) {
if (newTarget != vBar->value()) {
m_scrollTargets[i] = newTarget;
anim->stop();
anim->setStartValue(vBar->value());
anim->setEndValue(newTarget);
anim->start();
if (MotionPrefs::reduceMotion()) {
vBar->setValue(newTarget);
} else {
anim->setStartValue(vBar->value());
anim->setEndValue(newTarget);
anim->start();
}
}
}
return true;
+3 -2
View File
@@ -1,4 +1,5 @@
#include "categoryview.h"
#include "../../tokens/AnimationTokens.h"
#include "../../components/elidedlabel.h"
#include "../../components/mediagridwidget.h"
#include "../../components/modernsortbutton.h"
@@ -144,10 +145,10 @@ void CategoryView::setupTopBar(QHBoxLayout *headerLayout) {
m_refreshAnimation = new QPropertyAnimation(this, QByteArray());
m_refreshAnimation->setDuration(800);
m_refreshAnimation->setDuration(AnimationTokens::DurationMs::kGentle);
m_refreshAnimation->setStartValue(0.0);
m_refreshAnimation->setEndValue(360.0);
m_refreshAnimation->setEasingCurve(QEasingCurve::Linear);
m_refreshAnimation->setEasingCurve(AnimationTokens::Curve::loop());
connect(m_refreshAnimation, &QPropertyAnimation::valueChanged, this,
[this, refreshIcon](const QVariant &value) {
+2 -1
View File
@@ -1,4 +1,5 @@
#include "dashboardview.h"
#include "../../tokens/AnimationTokens.h"
#include "../../components/horizontallistviewgallery.h"
#include "../../components/libraryactionmenu.h"
@@ -276,7 +277,7 @@ void DashboardView::setupUi()
m_vScrollController =
new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this);
m_vScrollController->setDuration(160);
m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast);
m_mainScrollArea->viewport()->installEventFilter(this);
}
+2 -1
View File
@@ -1,4 +1,5 @@
#include "favoritesview.h"
#include "../../tokens/AnimationTokens.h"
#include "../../utils/smoothscrollcontroller.h"
#include <qembycore.h>
#include <services/media/mediaservice.h>
@@ -118,7 +119,7 @@ void FavoritesView::setupUi()
m_vScrollController =
new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this);
m_vScrollController->setDuration(160);
m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast);
m_mainScrollArea->viewport()->installEventFilter(this);
}
+27 -5
View File
@@ -10,6 +10,7 @@
#include "../../managers/searchhistorymanager.h"
#include "../../managers/playbackmanager.h"
#include "../../utils/smoothscrollcontroller.h"
#include "../../tokens/AnimationTokens.h"
#include "../admin/manageview.h"
#include "../media/detailview.h"
#include "../media/libraryview.h"
@@ -713,7 +714,7 @@ void HomeView::setupSidebar()
m_libraryList->setWordWrap(false);
m_sidebarLibraryScrollController =
new SmoothScrollController(m_libraryList->verticalScrollBar(), this);
m_sidebarLibraryScrollController->setDuration(160);
m_sidebarLibraryScrollController->setDuration(AnimationTokens::DurationMs::kFast);
m_libraryList->viewport()->installEventFilter(this);
layout->addWidget(m_libraryList, 1);
@@ -877,8 +878,8 @@ void HomeView::setupSidebar()
});
m_sidebarAnim = new QPropertyAnimation(m_sidebar, "pos", this);
m_sidebarAnim->setDuration(350);
m_sidebarAnim->setEasingCurve(QEasingCurve::OutCubic);
m_sidebarAnim->setDuration(AnimationTokens::DurationMs::kSlow);
m_sidebarAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_sidebar->installEventFilter(this);
m_sidebar->setMouseTracking(true);
@@ -1113,7 +1114,15 @@ void HomeView::pushView(QWidget *view)
QMetaObject::invokeMethod(view, "scrollToTop", Qt::QueuedConnection);
m_contentSwitcher->slideInWgt(view, SlidingStackedWidget::RightToLeft);
if (view->property("routeType").toString() == QLatin1String("PlayerView"))
{
// 播放器有自己的入场动画(HUD 淡入等),瞬切避免与翻页动画叠加
m_contentSwitcher->jumpToWgt(view);
}
else
{
m_contentSwitcher->slideInWgt(view, SlidingStackedWidget::RightToLeft);
}
Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty());
}
@@ -1201,7 +1210,15 @@ void HomeView::navigateBack()
m_contentSwitcher->slideInWgt(targetView, SlidingStackedWidget::LeftToRight);
if (current && current->property("routeType").toString() == QLatin1String("PlayerView"))
{
// 与进入播放器对称:退出时瞬切,避免黑色播放器表面滑走
m_contentSwitcher->jumpToWgt(targetView);
}
else
{
m_contentSwitcher->slideInWgt(targetView, SlidingStackedWidget::LeftToRight);
}
Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty());
@@ -1672,6 +1689,8 @@ void HomeView::showSidebar()
if (m_sidebarAnim->state() == QAbstractAnimation::Running)
m_sidebarAnim->stop();
m_sidebarAnim->setDuration(AnimationTokens::DurationMs::kSlow);
m_sidebarAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_sidebarAnim->setStartValue(m_sidebar->pos());
if (m_sidebarOnRight)
@@ -1710,6 +1729,9 @@ void HomeView::hideSidebar()
m_sidebarAutoHideTimer->stop();
if (m_sidebarAnim->state() == QAbstractAnimation::Running)
m_sidebarAnim->stop();
// Dismiss reads snappier and more natural with an ease-in exit over kNormal.
m_sidebarAnim->setDuration(AnimationTokens::DurationMs::kNormal);
m_sidebarAnim->setEasingCurve(AnimationTokens::Curve::exit());
m_sidebarAnim->setStartValue(m_sidebar->pos());
if (m_sidebarOnRight)