diff --git a/src/qEmbyApp/components/collapsiblesection.cpp b/src/qEmbyApp/components/collapsiblesection.cpp index be225aa..786bd9b 100644 --- a/src/qEmbyApp/components/collapsiblesection.cpp +++ b/src/qEmbyApp/components/collapsiblesection.cpp @@ -2,11 +2,15 @@ #include #include +#include #include #include #include #include +#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(); } diff --git a/src/qEmbyApp/components/collapsiblesection.h b/src/qEmbyApp/components/collapsiblesection.h index 73f020c..ddae4ab 100644 --- a/src/qEmbyApp/components/collapsiblesection.h +++ b/src/qEmbyApp/components/collapsiblesection.h @@ -4,6 +4,7 @@ #include #include +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; }; diff --git a/src/qEmbyApp/components/collectioncard.cpp b/src/qEmbyApp/components/collectioncard.cpp index 9727e3b..dae5a08 100644 --- a/src/qEmbyApp/components/collectioncard.cpp +++ b/src/qEmbyApp/components/collectioncard.cpp @@ -1,12 +1,23 @@ #include "collectioncard.h" #include "collectiongrid.h" #include "../managers/thememanager.h" +#include "../utils/hoveranimator.h" #include #include #include #include +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; diff --git a/src/qEmbyApp/components/collectioncard.h b/src/qEmbyApp/components/collectioncard.h index fc11d44..0bc0098 100644 --- a/src/qEmbyApp/components/collectioncard.h +++ b/src/qEmbyApp/components/collectioncard.h @@ -7,6 +7,8 @@ #include 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 diff --git a/src/qEmbyApp/components/collectiongrid.cpp b/src/qEmbyApp/components/collectiongrid.cpp index 74bba97..8c8d68e 100644 --- a/src/qEmbyApp/components/collectiongrid.cpp +++ b/src/qEmbyApp/components/collectiongrid.cpp @@ -7,6 +7,8 @@ #include #include #include +#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; diff --git a/src/qEmbyApp/components/dashboardsectionorderstrip.cpp b/src/qEmbyApp/components/dashboardsectionorderstrip.cpp index 981a64e..f242388 100644 --- a/src/qEmbyApp/components/dashboardsectionorderstrip.cpp +++ b/src/qEmbyApp/components/dashboardsectionorderstrip.cpp @@ -9,6 +9,8 @@ #include #include #include +#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); } diff --git a/src/qEmbyApp/components/detailactionwidget.cpp b/src/qEmbyApp/components/detailactionwidget.cpp index 8897f1f..7f8f9ae 100644 --- a/src/qEmbyApp/components/detailactionwidget.cpp +++ b/src/qEmbyApp/components/detailactionwidget.cpp @@ -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 @@ -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); diff --git a/src/qEmbyApp/components/detailcontentwidget.cpp b/src/qEmbyApp/components/detailcontentwidget.cpp index 2789bb2..b6e1f1d 100644 --- a/src/qEmbyApp/components/detailcontentwidget.cpp +++ b/src/qEmbyApp/components/detailcontentwidget.cpp @@ -2,6 +2,9 @@ #include #include #include +#include +#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; + 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(); } @@ -72,10 +104,12 @@ void DetailContentWidget::paintEvent(QPaintEvent* event) { m_cachedWidth = w; } - - painter.drawPixmap(0, 0, m_scaledBackdrop); - + painter.setOpacity(m_backdropOpacity); + painter.drawPixmap(0, 0, m_scaledBackdrop); + painter.setOpacity(1.0); + + QLinearGradient gradient(0, 0, 0, targetHeight); int r = m_gradientColor.red(); int g = m_gradientColor.green(); diff --git a/src/qEmbyApp/components/detailcontentwidget.h b/src/qEmbyApp/components/detailcontentwidget.h index 3c4b44c..3fac663 100644 --- a/src/qEmbyApp/components/detailcontentwidget.h +++ b/src/qEmbyApp/components/detailcontentwidget.h @@ -5,24 +5,30 @@ #include #include +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); - + void setBackdrop(const QPixmap& pix); - + QColor baseColor() const; void setBaseColor(const QColor& color); QColor gradientColor() const; void setGradientColor(const QColor& color); + qreal backdropOpacity() const; + void setBackdropOpacity(qreal opacity); + protected: void paintEvent(QPaintEvent* event) override; @@ -31,9 +37,12 @@ private: QPixmap m_scaledBackdrop; int m_cachedWidth; - + QColor m_baseColor; QColor m_gradientColor; + + qreal m_backdropOpacity = 1.0; + QPropertyAnimation* m_backdropFade = nullptr; }; #endif diff --git a/src/qEmbyApp/components/horizontallistviewgallery.cpp b/src/qEmbyApp/components/horizontallistviewgallery.cpp index 3335fef..38e4e53 100644 --- a/src/qEmbyApp/components/horizontallistviewgallery.cpp +++ b/src/qEmbyApp/components/horizontallistviewgallery.cpp @@ -8,6 +8,28 @@ #include #include #include +#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 #include #include @@ -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& 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() diff --git a/src/qEmbyApp/components/horizontalwidgetgallery.cpp b/src/qEmbyApp/components/horizontalwidgetgallery.cpp index bca8d8a..8a018b6 100644 --- a/src/qEmbyApp/components/horizontalwidgetgallery.cpp +++ b/src/qEmbyApp/components/horizontalwidgetgallery.cpp @@ -6,6 +6,28 @@ #include #include #include +#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 #include #include @@ -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()); } diff --git a/src/qEmbyApp/components/libraryactionmenu.cpp b/src/qEmbyApp/components/libraryactionmenu.cpp index db86632..e829842 100644 --- a/src/qEmbyApp/components/libraryactionmenu.cpp +++ b/src/qEmbyApp/components/libraryactionmenu.cpp @@ -1,6 +1,7 @@ #include "libraryactionmenu.h" #include "../managers/thememanager.h" +#include "../utils/popupfade.h" #include #include #include @@ -16,6 +17,8 @@ LibraryActionMenu::LibraryActionMenu(const VirtualFolder& folder, Qt::NoDropShadowWindowHint); setAttribute(Qt::WA_TranslucentBackground); + PopupFade::attach(this); + setupMenu(); } diff --git a/src/qEmbyApp/components/loadingoverlay.cpp b/src/qEmbyApp/components/loadingoverlay.cpp index 8f993cf..6b679b0 100644 --- a/src/qEmbyApp/components/loadingoverlay.cpp +++ b/src/qEmbyApp/components/loadingoverlay.cpp @@ -1,8 +1,9 @@ #include "loadingoverlay.h" +#include "../tokens/AnimationTokens.h" +#include "../utils/motionprefs.h" #include #include #include -#include #include #include #include @@ -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(); diff --git a/src/qEmbyApp/components/mediaactionmenu.cpp b/src/qEmbyApp/components/mediaactionmenu.cpp index 01375b2..acf91f2 100644 --- a/src/qEmbyApp/components/mediaactionmenu.cpp +++ b/src/qEmbyApp/components/mediaactionmenu.cpp @@ -4,6 +4,7 @@ #include "../utils/mediaidentifyutils.h" #include "../utils/mediaitemutils.h" #include "../utils/playlistutils.h" +#include "../utils/popupfade.h" #include #include #include @@ -30,9 +31,11 @@ MediaActionMenu::MediaActionMenu(const MediaItem& item, QEmbyCore* core, setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint); - + setAttribute(Qt::WA_TranslucentBackground); + PopupFade::attach(this); + setupMenu(); } diff --git a/src/qEmbyApp/components/mediagridwidget.cpp b/src/qEmbyApp/components/mediagridwidget.cpp index a35a285..33d07e6 100644 --- a/src/qEmbyApp/components/mediagridwidget.cpp +++ b/src/qEmbyApp/components/mediagridwidget.cpp @@ -15,6 +15,8 @@ #include #include #include +#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& 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( diff --git a/src/qEmbyApp/components/moderncombobox.cpp b/src/qEmbyApp/components/moderncombobox.cpp index e735caf..b587b0a 100644 --- a/src/qEmbyApp/components/moderncombobox.cpp +++ b/src/qEmbyApp/components/moderncombobox.cpp @@ -1,4 +1,6 @@ #include "moderncombobox.h" +#include "../utils/fadehelper.h" +#include "../utils/popupfade.h" #include #include #include @@ -132,6 +134,8 @@ void ModernComboBox::showPopup() { return; } + PopupFade::run(popupContainer); + auto *vScrollBar = popupView->verticalScrollBar(); @@ -263,10 +267,12 @@ void ModernComboBox::showEmbeddedPopup() { listFrame->setGeometry(popupX, popupY, popupW, popupH); - + overlay->show(); overlay->raise(); listFrame->show(); + // Child overlay (not a top-level window) — FadeHelper, not PopupFade. + FadeHelper::fadeIn(listFrame, AnimationTokens::DurationMs::kQuick); listWidget->setFocus(); diff --git a/src/qEmbyApp/components/modernmenubutton.cpp b/src/qEmbyApp/components/modernmenubutton.cpp index 5af1fd7..1e9785b 100644 --- a/src/qEmbyApp/components/modernmenubutton.cpp +++ b/src/qEmbyApp/components/modernmenubutton.cpp @@ -1,4 +1,5 @@ #include "modernmenubutton.h" +#include "../utils/popupfade.h" #include #include #include @@ -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); diff --git a/src/qEmbyApp/components/modernslider.cpp b/src/qEmbyApp/components/modernslider.cpp index 9ce93b0..321f088 100644 --- a/src/qEmbyApp/components/modernslider.cpp +++ b/src/qEmbyApp/components/modernslider.cpp @@ -1,4 +1,5 @@ #include "modernslider.h" +#include "../utils/hoveranimator.h" #include #include @@ -6,7 +7,10 @@ ModernSlider::ModernSlider(Qt::Orientation orientation, QWidget *parent) : QSlider(orientation, parent) { setCursor(Qt::PointingHandCursor); setMouseTracking(true); - setAttribute(Qt::WA_Hover, 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; diff --git a/src/qEmbyApp/components/modernslider.h b/src/qEmbyApp/components/modernslider.h index fe55b37..266fa75 100644 --- a/src/qEmbyApp/components/modernslider.h +++ b/src/qEmbyApp/components/modernslider.h @@ -8,6 +8,8 @@ #include #include +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"); diff --git a/src/qEmbyApp/components/modernsortbutton.cpp b/src/qEmbyApp/components/modernsortbutton.cpp index f574bf9..96d7a32 100644 --- a/src/qEmbyApp/components/modernsortbutton.cpp +++ b/src/qEmbyApp/components/modernsortbutton.cpp @@ -1,10 +1,12 @@ #include "modernsortbutton.h" +#include "../utils/popupfade.h" #include ModernSortButton::ModernSortButton(QWidget *parent) : QPushButton(parent), m_currentIndex(-1), m_descending(true) { m_menu = new QMenu(this); + PopupFade::attach(m_menu); diff --git a/src/qEmbyApp/components/modernswitch.cpp b/src/qEmbyApp/components/modernswitch.cpp index baed8a6..ebe5178 100644 --- a/src/qEmbyApp/components/modernswitch.cpp +++ b/src/qEmbyApp/components/modernswitch.cpp @@ -1,4 +1,6 @@ #include "modernswitch.h" +#include "../tokens/AnimationTokens.h" +#include "../utils/motionprefs.h" #include 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(); diff --git a/src/qEmbyApp/components/moderntoast.cpp b/src/qEmbyApp/components/moderntoast.cpp index 0ddafc8..5783f0a 100644 --- a/src/qEmbyApp/components/moderntoast.cpp +++ b/src/qEmbyApp/components/moderntoast.cpp @@ -7,6 +7,8 @@ #include #include #include +#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,8 +118,21 @@ 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(); + update(); if (graphicsEffect()) { @@ -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); diff --git a/src/qEmbyApp/components/mpvcontroller.cpp b/src/qEmbyApp/components/mpvcontroller.cpp index 7aa8b8d..a1c51a5 100644 --- a/src/qEmbyApp/components/mpvcontroller.cpp +++ b/src/qEmbyApp/components/mpvcontroller.cpp @@ -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"); diff --git a/src/qEmbyApp/components/playerlongpresshandler.cpp b/src/qEmbyApp/components/playerlongpresshandler.cpp index 7629b8c..5a5d7b2 100644 --- a/src/qEmbyApp/components/playerlongpresshandler.cpp +++ b/src/qEmbyApp/components/playerlongpresshandler.cpp @@ -13,6 +13,8 @@ #include #include #include +#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(); diff --git a/src/qEmbyApp/components/playerosdlayer.cpp b/src/qEmbyApp/components/playerosdlayer.cpp index a3a846e..2691cca 100644 --- a/src/qEmbyApp/components/playerosdlayer.cpp +++ b/src/qEmbyApp/components/playerosdlayer.cpp @@ -1,4 +1,6 @@ #include "playerosdlayer.h" +#include "../tokens/AnimationTokens.h" +#include "../utils/motionprefs.h" #include #include #include @@ -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() diff --git a/src/qEmbyApp/components/playeroverlaydialog.cpp b/src/qEmbyApp/components/playeroverlaydialog.cpp index dd00d9f..005e061 100644 --- a/src/qEmbyApp/components/playeroverlaydialog.cpp +++ b/src/qEmbyApp/components/playeroverlaydialog.cpp @@ -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(); diff --git a/src/qEmbyApp/components/searchhistorychip.cpp b/src/qEmbyApp/components/searchhistorychip.cpp index 17bd448..c6e58fd 100644 --- a/src/qEmbyApp/components/searchhistorychip.cpp +++ b/src/qEmbyApp/components/searchhistorychip.cpp @@ -14,6 +14,7 @@ #include #include #include +#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(); diff --git a/src/qEmbyApp/components/searchhistorypopup.cpp b/src/qEmbyApp/components/searchhistorypopup.cpp index 47bf9f3..4c5d539 100644 --- a/src/qEmbyApp/components/searchhistorypopup.cpp +++ b/src/qEmbyApp/components/searchhistorypopup.cpp @@ -22,6 +22,7 @@ #include #include #include +#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()); } } diff --git a/src/qEmbyApp/components/seektimebubble.cpp b/src/qEmbyApp/components/seektimebubble.cpp new file mode 100644 index 0000000..6c563a4 --- /dev/null +++ b/src/qEmbyApp/components/seektimebubble.cpp @@ -0,0 +1,80 @@ +#include "seektimebubble.h" + +#include +#include +#include + +#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); +} diff --git a/src/qEmbyApp/components/seektimebubble.h b/src/qEmbyApp/components/seektimebubble.h new file mode 100644 index 0000000..9b13903 --- /dev/null +++ b/src/qEmbyApp/components/seektimebubble.h @@ -0,0 +1,37 @@ +#ifndef SEEKTIMEBUBBLE_H +#define SEEKTIMEBUBBLE_H + +#include + +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 diff --git a/src/qEmbyApp/components/serverwheelview.cpp b/src/qEmbyApp/components/serverwheelview.cpp index 04120fc..445f148 100644 --- a/src/qEmbyApp/components/serverwheelview.cpp +++ b/src/qEmbyApp/components/serverwheelview.cpp @@ -10,6 +10,8 @@ #include #include #include +#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(); diff --git a/src/qEmbyApp/components/settingssubpanel.cpp b/src/qEmbyApp/components/settingssubpanel.cpp index d3e7d63..21868e7 100644 --- a/src/qEmbyApp/components/settingssubpanel.cpp +++ b/src/qEmbyApp/components/settingssubpanel.cpp @@ -1,8 +1,8 @@ #include "settingssubpanel.h" #include "../managers/thememanager.h" +#include "../tokens/AnimationTokens.h" #include #include -#include #include #include #include @@ -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]() { diff --git a/src/qEmbyApp/components/shimmerwidget.cpp b/src/qEmbyApp/components/shimmerwidget.cpp index f96e37e..c4a79d4 100644 --- a/src/qEmbyApp/components/shimmerwidget.cpp +++ b/src/qEmbyApp/components/shimmerwidget.cpp @@ -1,5 +1,7 @@ #include "shimmerwidget.h" #include "../managers/thememanager.h" +#include "../tokens/AnimationTokens.h" +#include "../utils/motionprefs.h" #include #include #include @@ -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() diff --git a/src/qEmbyApp/components/slidingstackedwidget.cpp b/src/qEmbyApp/components/slidingstackedwidget.cpp index e8e536e..148de13 100644 --- a/src/qEmbyApp/components/slidingstackedwidget.cpp +++ b/src/qEmbyApp/components/slidingstackedwidget.cpp @@ -1,4 +1,5 @@ #include "slidingstackedwidget.h" +#include "../tokens/AnimationTokens.h" #include #include #include @@ -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()) @@ -55,21 +85,16 @@ void SlidingStackedWidget::slideInIdx(int idx, SlideDirection direction) return; - + if (m_isAnimating) { m_slideTimer.stop(); - animationDoneSlot(); + animationDoneSlot(); } - + bool reduceAnimations = ConfigStore::instance()->get(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; } diff --git a/src/qEmbyApp/components/slidingstackedwidget.h b/src/qEmbyApp/components/slidingstackedwidget.h index 5e25ef2..b258b6d 100644 --- a/src/qEmbyApp/components/slidingstackedwidget.h +++ b/src/qEmbyApp/components/slidingstackedwidget.h @@ -30,9 +30,13 @@ public: void setSpeed(int speed); void setEasingCurve(QEasingCurve::Type curveType); - + 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: diff --git a/src/qEmbyApp/components/splitplayerbutton.cpp b/src/qEmbyApp/components/splitplayerbutton.cpp index ffc379c..177af0f 100644 --- a/src/qEmbyApp/components/splitplayerbutton.cpp +++ b/src/qEmbyApp/components/splitplayerbutton.cpp @@ -1,5 +1,6 @@ #include "splitplayerbutton.h" #include "../managers/externalplayerdetector.h" +#include "../utils/popupfade.h" #include #include #include @@ -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 &players, diff --git a/src/qEmbyApp/components/taskeditdialog.cpp b/src/qEmbyApp/components/taskeditdialog.cpp index a645f88..6fe0053 100644 --- a/src/qEmbyApp/components/taskeditdialog.cpp +++ b/src/qEmbyApp/components/taskeditdialog.cpp @@ -22,6 +22,7 @@ #include #include #include +#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(); diff --git a/src/qEmbyApp/components/transcodingcodeclistwidget.cpp b/src/qEmbyApp/components/transcodingcodeclistwidget.cpp index a0fbb4a..0abd3a9 100644 --- a/src/qEmbyApp/components/transcodingcodeclistwidget.cpp +++ b/src/qEmbyApp/components/transcodingcodeclistwidget.cpp @@ -7,6 +7,8 @@ #include #include #include +#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 { diff --git a/src/qEmbyApp/components/usereditdialog.cpp b/src/qEmbyApp/components/usereditdialog.cpp index 8730a05..4b68138 100644 --- a/src/qEmbyApp/components/usereditdialog.cpp +++ b/src/qEmbyApp/components/usereditdialog.cpp @@ -28,6 +28,7 @@ #include #include #include +#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(); diff --git a/src/qEmbyApp/mainwindow.cpp b/src/qEmbyApp/mainwindow.cpp index b069399..5284e24 100644 --- a/src/qEmbyApp/mainwindow.cpp +++ b/src/qEmbyApp/mainwindow.cpp @@ -1,4 +1,5 @@ #include "mainwindow.h" +#include "tokens/AnimationTokens.h" #include #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); diff --git a/src/qEmbyApp/resources/qss/dark-style.qss b/src/qEmbyApp/resources/qss/dark-style.qss index e2b84bc..5912bb4 100644 --- a/src/qEmbyApp/resources/qss/dark-style.qss +++ b/src/qEmbyApp/resources/qss/dark-style.qss @@ -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; diff --git a/src/qEmbyApp/resources/qss/light-style.qss b/src/qEmbyApp/resources/qss/light-style.qss index a0ff75b..176f224 100644 --- a/src/qEmbyApp/resources/qss/light-style.qss +++ b/src/qEmbyApp/resources/qss/light-style.qss @@ -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; diff --git a/src/qEmbyApp/tokens/AnimationTokens.h b/src/qEmbyApp/tokens/AnimationTokens.h new file mode 100644 index 0000000..58a35ee --- /dev/null +++ b/src/qEmbyApp/tokens/AnimationTokens.h @@ -0,0 +1,29 @@ +#ifndef QEMBYAPP_ANIMATION_TOKENS_H +#define QEMBYAPP_ANIMATION_TOKENS_H + +#include + +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 diff --git a/src/qEmbyApp/tokens/DesignTokens.h b/src/qEmbyApp/tokens/DesignTokens.h new file mode 100644 index 0000000..45f4c55 --- /dev/null +++ b/src/qEmbyApp/tokens/DesignTokens.h @@ -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 diff --git a/src/qEmbyApp/utils/fadehelper.cpp b/src/qEmbyApp/utils/fadehelper.cpp new file mode 100644 index 0000000..89fbd13 --- /dev/null +++ b/src/qEmbyApp/utils/fadehelper.cpp @@ -0,0 +1,121 @@ +#include "fadehelper.h" + +#include +#include +#include + +#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(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(); + 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 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; +} diff --git a/src/qEmbyApp/utils/fadehelper.h b/src/qEmbyApp/utils/fadehelper.h new file mode 100644 index 0000000..e57fb3f --- /dev/null +++ b/src/qEmbyApp/utils/fadehelper.h @@ -0,0 +1,44 @@ +#ifndef QEMBYAPP_FADE_HELPER_H +#define QEMBYAPP_FADE_HELPER_H + +#include +#include + +#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 onDone = {}); + +} // namespace FadeHelper + +#endif // QEMBYAPP_FADE_HELPER_H diff --git a/src/qEmbyApp/utils/hoveranimator.cpp b/src/qEmbyApp/utils/hoveranimator.cpp new file mode 100644 index 0000000..90fdf36 --- /dev/null +++ b/src/qEmbyApp/utils/hoveranimator.cpp @@ -0,0 +1,50 @@ +#include "hoveranimator.h" + +#include + +#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 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); +} diff --git a/src/qEmbyApp/utils/hoveranimator.h b/src/qEmbyApp/utils/hoveranimator.h new file mode 100644 index 0000000..4d32ff1 --- /dev/null +++ b/src/qEmbyApp/utils/hoveranimator.h @@ -0,0 +1,54 @@ +#ifndef QEMBYAPP_HOVER_ANIMATOR_H +#define QEMBYAPP_HOVER_ANIMATOR_H + +#include +#include + +#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 callback); + +Q_SIGNALS: + void progressChanged(qreal progress); + +private: + void applyProgress(qreal progress); + + QVariantAnimation *m_anim = nullptr; + std::function m_callback; + qreal m_progress = 0.0; + bool m_hovered = false; + int m_inMs; + int m_outMs; +}; + +#endif // QEMBYAPP_HOVER_ANIMATOR_H diff --git a/src/qEmbyApp/utils/indexhoveranimator.cpp b/src/qEmbyApp/utils/indexhoveranimator.cpp new file mode 100644 index 0000000..ed554ed --- /dev/null +++ b/src/qEmbyApp/utils/indexhoveranimator.cpp @@ -0,0 +1,132 @@ +#include "indexhoveranimator.h" + +#include + +#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 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 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); + } + } +} diff --git a/src/qEmbyApp/utils/indexhoveranimator.h b/src/qEmbyApp/utils/indexhoveranimator.h new file mode 100644 index 0000000..7491c55 --- /dev/null +++ b/src/qEmbyApp/utils/indexhoveranimator.h @@ -0,0 +1,60 @@ +#ifndef QEMBYAPP_INDEX_HOVER_ANIMATOR_H +#define QEMBYAPP_INDEX_HOVER_ANIMATOR_H + +#include +#include +#include +#include + +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 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 m_entries; + QPersistentModelIndex m_hovered; + std::function m_repaintCallback; + int m_repaintMargin = 0; +}; + +#endif // QEMBYAPP_INDEX_HOVER_ANIMATOR_H diff --git a/src/qEmbyApp/utils/motionprefs.h b/src/qEmbyApp/utils/motionprefs.h new file mode 100644 index 0000000..5e50802 --- /dev/null +++ b/src/qEmbyApp/utils/motionprefs.h @@ -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(ConfigKeys::UiAnimations, false); +} + +} // namespace MotionPrefs + +#endif // QEMBYAPP_MOTION_PREFS_H diff --git a/src/qEmbyApp/utils/popfeedback.cpp b/src/qEmbyApp/utils/popfeedback.cpp new file mode 100644 index 0000000..abd0c93 --- /dev/null +++ b/src/qEmbyApp/utils/popfeedback.cpp @@ -0,0 +1,37 @@ +#include "popfeedback.h" + +#include + +#include "motionprefs.h" + +namespace PopFeedback { + +QVariantAnimation *play(QObject *context, std::function 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 diff --git a/src/qEmbyApp/utils/popfeedback.h b/src/qEmbyApp/utils/popfeedback.h new file mode 100644 index 0000000..fc48c27 --- /dev/null +++ b/src/qEmbyApp/utils/popfeedback.h @@ -0,0 +1,27 @@ +#ifndef QEMBYAPP_POP_FEEDBACK_H +#define QEMBYAPP_POP_FEEDBACK_H + +#include + +#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 callback, + qreal peak = 1.15, + int durationMs = AnimationTokens::DurationMs::kNormal); + +} // namespace PopFeedback + +#endif // QEMBYAPP_POP_FEEDBACK_H diff --git a/src/qEmbyApp/utils/popupfade.h b/src/qEmbyApp/utils/popupfade.h new file mode 100644 index 0000000..25f65ac --- /dev/null +++ b/src/qEmbyApp/utils/popupfade.h @@ -0,0 +1,71 @@ +#ifndef QEMBYAPP_POPUP_FADE_H +#define QEMBYAPP_POPUP_FADE_H + +#include +#include +#include +#include +#include + +#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(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 m_target; +}; + +inline void attach(QWidget *popup) +{ + if (popup) + popup->installEventFilter(new Filter(popup)); +} + +} // namespace PopupFade + +#endif // QEMBYAPP_POPUP_FADE_H diff --git a/src/qEmbyApp/utils/refreshspin.cpp b/src/qEmbyApp/utils/refreshspin.cpp new file mode 100644 index 0000000..c82260a --- /dev/null +++ b/src/qEmbyApp/utils/refreshspin.cpp @@ -0,0 +1,50 @@ +#include "refreshspin.h" + +#include +#include +#include +#include +#include + +#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 diff --git a/src/qEmbyApp/utils/refreshspin.h b/src/qEmbyApp/utils/refreshspin.h new file mode 100644 index 0000000..cf49999 --- /dev/null +++ b/src/qEmbyApp/utils/refreshspin.h @@ -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 diff --git a/src/qEmbyApp/utils/shadowpixmap.cpp b/src/qEmbyApp/utils/shadowpixmap.cpp index 71e58ef..4513a59 100644 --- a/src/qEmbyApp/utils/shadowpixmap.cpp +++ b/src/qEmbyApp/utils/shadowpixmap.cpp @@ -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 diff --git a/src/qEmbyApp/utils/shadowpixmap.h b/src/qEmbyApp/utils/shadowpixmap.h index f8c4909..0efe377 100644 --- a/src/qEmbyApp/utils/shadowpixmap.h +++ b/src/qEmbyApp/utils/shadowpixmap.h @@ -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 diff --git a/src/qEmbyApp/utils/smoothscrollcontroller.cpp b/src/qEmbyApp/utils/smoothscrollcontroller.cpp index 324acb7..e7b168e 100644 --- a/src/qEmbyApp/utils/smoothscrollcontroller.cpp +++ b/src/qEmbyApp/utils/smoothscrollcontroller.cpp @@ -5,6 +5,8 @@ #include #include +#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(); diff --git a/src/qEmbyApp/utils/tweenedvalue.cpp b/src/qEmbyApp/utils/tweenedvalue.cpp new file mode 100644 index 0000000..153e5fa --- /dev/null +++ b/src/qEmbyApp/utils/tweenedvalue.cpp @@ -0,0 +1,47 @@ +#include "tweenedvalue.h" + +#include + +#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); +} diff --git a/src/qEmbyApp/utils/tweenedvalue.h b/src/qEmbyApp/utils/tweenedvalue.h new file mode 100644 index 0000000..4522fbd --- /dev/null +++ b/src/qEmbyApp/utils/tweenedvalue.h @@ -0,0 +1,40 @@ +#ifndef QEMBYAPP_TWEENED_VALUE_H +#define QEMBYAPP_TWEENED_VALUE_H + +#include + +#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 diff --git a/src/qEmbyApp/views/admin/librarycard.cpp b/src/qEmbyApp/views/admin/librarycard.cpp index 42717d6..2d6a592 100644 --- a/src/qEmbyApp/views/admin/librarycard.cpp +++ b/src/qEmbyApp/views/admin/librarycard.cpp @@ -2,6 +2,8 @@ #include "librarygrid.h" #include "../../components/libraryactionmenu.h" #include "../../managers/thememanager.h" +#include "../../utils/hoveranimator.h" +#include "../../utils/tweenedvalue.h" #include #include @@ -10,6 +12,16 @@ #include #include +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(m_scanProgress * 3.6 * 16); + // Arc glides via the tween; the % text shows the target value. + int spanAngle = -static_cast(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) { diff --git a/src/qEmbyApp/views/admin/librarycard.h b/src/qEmbyApp/views/admin/librarycard.h index 74c958b..05243a8 100644 --- a/src/qEmbyApp/views/admin/librarycard.h +++ b/src/qEmbyApp/views/admin/librarycard.h @@ -7,8 +7,11 @@ #include "../../components/librarycontextmenurequest.h" #include +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 diff --git a/src/qEmbyApp/views/admin/librarygrid.cpp b/src/qEmbyApp/views/admin/librarygrid.cpp index 48fa352..72c51ad 100644 --- a/src/qEmbyApp/views/admin/librarygrid.cpp +++ b/src/qEmbyApp/views/admin/librarygrid.cpp @@ -6,6 +6,8 @@ #include #include #include +#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; diff --git a/src/qEmbyApp/views/admin/manageview.cpp b/src/qEmbyApp/views/admin/manageview.cpp index e0b3477..d2aff18 100644 --- a/src/qEmbyApp/views/admin/manageview.cpp +++ b/src/qEmbyApp/views/admin/manageview.cpp @@ -9,6 +9,8 @@ #include "pageusers.h" #include "../../components/slidingstackedwidget.h" #include "../../managers/thememanager.h" +#include "../../tokens/AnimationTokens.h" +#include "../../utils/motionprefs.h" #include #include #include @@ -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; diff --git a/src/qEmbyApp/views/admin/nowplayingcard.cpp b/src/qEmbyApp/views/admin/nowplayingcard.cpp index 86f75c7..6b675e3 100644 --- a/src/qEmbyApp/views/admin/nowplayingcard.cpp +++ b/src/qEmbyApp/views/admin/nowplayingcard.cpp @@ -4,6 +4,7 @@ #include #include "../../managers/thememanager.h" #include "../../utils/imageutils.h" +#include "../../utils/tweenedvalue.h" #include #include @@ -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(session.playbackProgress() * 1000)); + m_progressTween->setTarget(session.playbackProgress() * 1000); m_progressText->show(); m_stateIcon->show(); m_progressBar->show(); diff --git a/src/qEmbyApp/views/admin/nowplayingcard.h b/src/qEmbyApp/views/admin/nowplayingcard.h index 4a9acb0..8bf4294 100644 --- a/src/qEmbyApp/views/admin/nowplayingcard.h +++ b/src/qEmbyApp/views/admin/nowplayingcard.h @@ -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 diff --git a/src/qEmbyApp/views/admin/pagecollections.cpp b/src/qEmbyApp/views/admin/pagecollections.cpp index 20fdf37..5a78a2a 100644 --- a/src/qEmbyApp/views/admin/pagecollections.cpp +++ b/src/qEmbyApp/views/admin/pagecollections.cpp @@ -25,6 +25,8 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/refreshspin.h" #include namespace { @@ -348,33 +350,7 @@ QCoro::Task 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 { diff --git a/src/qEmbyApp/views/admin/pagedashboard.cpp b/src/qEmbyApp/views/admin/pagedashboard.cpp index 9e435cf..8bd1a01 100644 --- a/src/qEmbyApp/views/admin/pagedashboard.cpp +++ b/src/qEmbyApp/views/admin/pagedashboard.cpp @@ -27,6 +27,8 @@ #include #include #include +#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 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 diff --git a/src/qEmbyApp/views/admin/pagelibraries.cpp b/src/qEmbyApp/views/admin/pagelibraries.cpp index c2febff..1396915 100644 --- a/src/qEmbyApp/views/admin/pagelibraries.cpp +++ b/src/qEmbyApp/views/admin/pagelibraries.cpp @@ -29,6 +29,8 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/refreshspin.h" @@ -502,33 +504,7 @@ QCoro::Task 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 { diff --git a/src/qEmbyApp/views/admin/pagelogs.cpp b/src/qEmbyApp/views/admin/pagelogs.cpp index bc536f6..5a996b0 100644 --- a/src/qEmbyApp/views/admin/pagelogs.cpp +++ b/src/qEmbyApp/views/admin/pagelogs.cpp @@ -36,6 +36,8 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/refreshspin.h" #include #include @@ -529,33 +531,7 @@ QCoro::Task 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 { diff --git a/src/qEmbyApp/views/admin/pagetasks.cpp b/src/qEmbyApp/views/admin/pagetasks.cpp index 8541f12..7dcb27c 100644 --- a/src/qEmbyApp/views/admin/pagetasks.cpp +++ b/src/qEmbyApp/views/admin/pagetasks.cpp @@ -22,6 +22,8 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/refreshspin.h" #include @@ -155,33 +157,7 @@ QCoro::Task 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 diff --git a/src/qEmbyApp/views/admin/pagetranscoding.cpp b/src/qEmbyApp/views/admin/pagetranscoding.cpp index 988df0b..788f8e0 100644 --- a/src/qEmbyApp/views/admin/pagetranscoding.cpp +++ b/src/qEmbyApp/views/admin/pagetranscoding.cpp @@ -36,6 +36,8 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/refreshspin.h" #include #include #include @@ -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); } diff --git a/src/qEmbyApp/views/admin/pageusers.cpp b/src/qEmbyApp/views/admin/pageusers.cpp index 8093c4f..b4f53c8 100644 --- a/src/qEmbyApp/views/admin/pageusers.cpp +++ b/src/qEmbyApp/views/admin/pageusers.cpp @@ -19,6 +19,8 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/refreshspin.h" @@ -145,34 +147,7 @@ QCoro::Task 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 { diff --git a/src/qEmbyApp/views/media/detailview.cpp b/src/qEmbyApp/views/media/detailview.cpp index c1d8857..d45636d 100644 --- a/src/qEmbyApp/views/media/detailview.cpp +++ b/src/qEmbyApp/views/media/detailview.cpp @@ -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 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 DetailView::executeLoadImages(QPointer 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 DetailView::executeLoadImages(QPointer 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, diff --git a/src/qEmbyApp/views/media/mediacarddelegate.cpp b/src/qEmbyApp/views/media/mediacarddelegate.cpp index 5175022..2a0ba1b 100644 --- a/src/qEmbyApp/views/media/mediacarddelegate.cpp +++ b/src/qEmbyApp/views/media/mediacarddelegate.cpp @@ -2,6 +2,8 @@ #include "medialistmodel.h" #include "../../managers/thememanager.h" +#include "../../utils/indexhoveranimator.h" +#include "../../utils/popfeedback.h" #include #include #include @@ -11,6 +13,7 @@ #include #include #include +#include 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(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,113 +373,85 @@ 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(object); + QAbstractItemView *view = viewport ? qobject_cast(viewport->parentWidget()) : nullptr; + if (!view) { + return QStyledItemDelegate::eventFilter(object, event); + } - QWidget *viewport = qobject_cast(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(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()) { - - QAbstractItemView *view = qobject_cast(viewport->parentWidget()); - if (view) + const MediaItem item = index.data(MediaListModel::ItemDataRole).value(); + button = hoverButtonAt(view->visualRect(index), item, pos); + } + setHoveredButton(button == HoverControlNone ? QPersistentModelIndex() : persistent, button); + return QStyledItemDelegate::eventFilter(object, event); + } + + if (type == QEvent::MouseButtonPress || type == QEvent::MouseButtonRelease || + type == QEvent::MouseButtonDblClick) + { + QMouseEvent *mouseEvent = static_cast(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(); + const HoverControl button = hoverButtonAt(view->visualRect(index), item, pos); + if (button != HoverControlNone) { - QMouseEvent *mouseEvent = static_cast(event); - QPoint pos = mouseEvent->pos(); - QModelIndex index = view->indexAt(pos); - - if (index.isValid()) + if (type == QEvent::MouseButtonRelease) { - MediaItem item = index.data(MediaListModel::ItemDataRole).value(); - 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; } } } - + return QStyledItemDelegate::eventFilter(object, event); } @@ -372,7 +503,9 @@ void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt QRect rect = option.rect; MediaItem item = index.data(MediaListModel::ItemDataRole).value(); QPixmap poster = index.data(MediaListModel::PosterPixmapRole).value(); - 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); } - - QRect targetImgRect = baseImgRect; + // 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); - 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,30 +905,64 @@ 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); } - - - - if (!m_highlightedItemId.isEmpty() && item.id == m_highlightedItemId) + // Press feedback: instant dim, no animation. + if (isPressed) { - QColor borderColor = isDarkTheme ? QColor(96, 165, 250, 220) - : QColor(59, 130, 246, 220); + 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 (isHighlighted) + { + QColor borderColor = isDarkTheme ? QColor(96, 165, 250, 220) + : QColor(59, 130, 246, 220); QPen highlightPen(borderColor, 2.0); painter->setPen(highlightPen); painter->setBrush(Qt::NoBrush); diff --git a/src/qEmbyApp/views/media/mediacarddelegate.h b/src/qEmbyApp/views/media/mediacarddelegate.h index b8e952b..762ed94 100644 --- a/src/qEmbyApp/views/media/mediacarddelegate.h +++ b/src/qEmbyApp/views/media/mediacarddelegate.h @@ -5,10 +5,15 @@ #include #include #include +#include +#include #include +#include "../../utils/shadowpixmap.h" struct MediaItem; +class IndexHoverAnimator; +class QAbstractItemView; class MediaCardThemeHelper : public QWidget { @@ -105,15 +110,30 @@ signals: void favoriteRequested(const MediaItem &item); 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 m_installedViewports; + QString m_highlightedItemId; + mutable MediaCardThemeHelper *m_themeHelper; + mutable QSet m_installedViewports; + IndexHoverAnimator *m_hover = nullptr; + mutable QPersistentModelIndex m_pressedIndex; + mutable QPersistentModelIndex m_hoverButtonIndex; + mutable HoverControl m_hoveredButton = HoverControlNone; + QHash m_popScale; + mutable NinePatchShadow m_liftShadow; bool m_showMoreButtonForNonPlayableTiles = false; HoverControls m_hoverControls = HoverControlAll; int m_titleFontPixelSize = 13; diff --git a/src/qEmbyApp/views/media/playerview.cpp b/src/qEmbyApp/views/media/playerview.cpp index a2ef5e6..fb73ae5 100644 --- a/src/qEmbyApp/views/media/playerview.cpp +++ b/src/qEmbyApp/views/media/playerview.cpp @@ -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 #include #include +#include "../../tokens/AnimationTokens.h" +#include "../../utils/motionprefs.h" #include #include @@ -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 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(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(m_fadeGroup->animationAt(i)); diff --git a/src/qEmbyApp/views/media/playerview.h b/src/qEmbyApp/views/media/playerview.h index 08427ed..5164fb5 100644 --- a/src/qEmbyApp/views/media/playerview.h +++ b/src/qEmbyApp/views/media/playerview.h @@ -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; diff --git a/src/qEmbyApp/views/settings/pageplayer.cpp b/src/qEmbyApp/views/settings/pageplayer.cpp index 9bcc57a..b0b018b 100644 --- a/src/qEmbyApp/views/settings/pageplayer.cpp +++ b/src/qEmbyApp/views/settings/pageplayer.cpp @@ -41,6 +41,7 @@ #include #include #include +#include "../../tokens/AnimationTokens.h" #include #include #include @@ -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) { diff --git a/src/qEmbyApp/views/settings/settingsview.cpp b/src/qEmbyApp/views/settings/settingsview.cpp index 1c6d9a0..dc6d0f2 100644 --- a/src/qEmbyApp/views/settings/settingsview.cpp +++ b/src/qEmbyApp/views/settings/settingsview.cpp @@ -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; } @@ -195,10 +197,10 @@ void SettingsView::ensurePageAt(int row) { QWidget *placeholder = m_stack->widget(row); const bool wasBlocked = m_stack->blockSignals(true); - m_stack->insertWidget(row, scroll); + 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; diff --git a/src/qEmbyApp/views/user/categoryview.cpp b/src/qEmbyApp/views/user/categoryview.cpp index 2a95ecb..05a5afb 100644 --- a/src/qEmbyApp/views/user/categoryview.cpp +++ b/src/qEmbyApp/views/user/categoryview.cpp @@ -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) { diff --git a/src/qEmbyApp/views/user/dashboardview.cpp b/src/qEmbyApp/views/user/dashboardview.cpp index a517c59..6bcfea4 100644 --- a/src/qEmbyApp/views/user/dashboardview.cpp +++ b/src/qEmbyApp/views/user/dashboardview.cpp @@ -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); } diff --git a/src/qEmbyApp/views/user/favoritesview.cpp b/src/qEmbyApp/views/user/favoritesview.cpp index 1cadf60..6aaceed 100644 --- a/src/qEmbyApp/views/user/favoritesview.cpp +++ b/src/qEmbyApp/views/user/favoritesview.cpp @@ -1,4 +1,5 @@ #include "favoritesview.h" +#include "../../tokens/AnimationTokens.h" #include "../../utils/smoothscrollcontroller.h" #include #include @@ -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); } diff --git a/src/qEmbyApp/views/user/homeview.cpp b/src/qEmbyApp/views/user/homeview.cpp index f6e549b..7e8276f 100644 --- a/src/qEmbyApp/views/user/homeview.cpp +++ b/src/qEmbyApp/views/user/homeview.cpp @@ -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) @@ -1707,9 +1726,12 @@ void HomeView::hideSidebar() return; } - m_sidebarAutoHideTimer->stop(); + 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)