diff --git a/src/qEmbyApp/components/shadowoverlay.cpp b/src/qEmbyApp/components/shadowoverlay.cpp new file mode 100644 index 0000000..6f468fb --- /dev/null +++ b/src/qEmbyApp/components/shadowoverlay.cpp @@ -0,0 +1,90 @@ +#include "shadowoverlay.h" + +#include + +ShadowOverlay::ShadowOverlay(QWidget *parent) : QWidget(parent) +{ + setAttribute(Qt::WA_TransparentForMouseEvents, true); + setAttribute(Qt::WA_NoSystemBackground, true); + setFocusPolicy(Qt::NoFocus); +} + +void ShadowOverlay::setShadow(int blurRadius, int cornerRadius, const QColor &color) +{ + m_blurRadius = blurRadius; + m_cornerRadius = cornerRadius; + m_color = color; + m_patch = NinePatchShadow{}; // force rebuild on next paint + m_builtDpr = 0.0; + update(); +} + +void ShadowOverlay::setBodyCutout(bool on) +{ + if (m_bodyCutout == on) + { + return; + } + m_bodyCutout = on; + update(); +} + +void ShadowOverlay::ensurePixmap(qreal dpr) +{ + if (!m_patch.isNull() && qFuzzyCompare(m_builtDpr, dpr)) + { + return; + } + m_patch = ShadowPixmap::make(m_blurRadius, m_cornerRadius, m_color, dpr); + m_builtDpr = dpr; +} + +void ShadowOverlay::paintEvent(QPaintEvent *) +{ + const qreal dpr = devicePixelRatioF(); + ensurePixmap(dpr); + if (m_patch.isNull()) + { + return; + } + + const int b = m_patch.border; + const int t = m_patch.total; + const qreal s = m_patch.dpr; + const QRect r = rect(); + + // 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}; + + QPainter p(this); + if (m_bodyCutout) + { + // Paint only the ring outside the target's footprint. + const int reachPx = reach(); + const QRect body = r.adjusted(reachPx, reachPx, -reachPx, -reachPx); + if (body.isValid()) + { + p.setClipRegion(QRegion(r).subtracted(QRegion(body))); + } + } + for (int i = 0; i < 3; ++i) + { + for (int j = 0; j < 3; ++j) + { + const QRectF target(xt[i], yt[j], xt[i + 1] - xt[i], yt[j + 1] - yt[j]); + const QRectF source(xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j]); + p.drawPixmap(target, m_patch.pixmap, source); + } + } +} diff --git a/src/qEmbyApp/components/shadowoverlay.h b/src/qEmbyApp/components/shadowoverlay.h new file mode 100644 index 0000000..b1a129c --- /dev/null +++ b/src/qEmbyApp/components/shadowoverlay.h @@ -0,0 +1,51 @@ +#ifndef SHADOWOVERLAY_H +#define SHADOWOVERLAY_H + +#include +#include + +#include "../utils/shadowpixmap.h" + +// A mouse-transparent sibling widget that paints a pre-baked drop shadow as a +// 9-slice stretch. Use it in place of QGraphicsDropShadowEffect on widgets that +// animate: position the overlay just under the target (geometry expanded by +// reach() on every side) and it follows with no per-frame blur cost. +// +// The baked pixmap is built lazily on first paint and rebuilt only when the +// device pixel ratio changes, so HiDPI / monitor moves stay crisp. +class ShadowOverlay : public QWidget +{ + Q_OBJECT +public: + explicit ShadowOverlay(QWidget *parent = nullptr); + + // Configure the shadow. blurRadius/cornerRadius are logical pixels; color + // carries the tint and its alpha sets strength. Invalidates any baked pixmap. + void setShadow(int blurRadius, int cornerRadius, const QColor &color); + + // How far the shadow extends beyond the target on each side. Callers expand + // the target geometry by this amount to get the overlay geometry. + int reach() const { return m_blurRadius + 1; } + + // When enabled, the target's footprint (the overlay rect inset by reach()) is + // clipped out, so the shadow is painted only as a ring around the target. + // Required for semi-transparent targets, where the shadow would otherwise + // bleed through the body and darken it. + void setBodyCutout(bool on); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + void ensurePixmap(qreal dpr); + + int m_blurRadius = 0; + int m_cornerRadius = 0; + QColor m_color; + bool m_bodyCutout = false; + + NinePatchShadow m_patch; + qreal m_builtDpr = 0.0; +}; + +#endif // SHADOWOVERLAY_H diff --git a/src/qEmbyApp/utils/shadowpixmap.cpp b/src/qEmbyApp/utils/shadowpixmap.cpp new file mode 100644 index 0000000..71e58ef --- /dev/null +++ b/src/qEmbyApp/utils/shadowpixmap.cpp @@ -0,0 +1,108 @@ +#include "shadowpixmap.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Width of the uniform, stretchable interior between the rounded corners. +// Kept small (the centre is a flat solid colour, so 4 logical px is plenty). +constexpr int kCenter = 4; + +int borderFor(int blurRadius, int cornerRadius) +{ + // Falloff reach beyond the object edge, plus the corner radius which lives + // inside the fixed (non-stretched) border so corners never distort. + return (blurRadius + 1) + cornerRadius; +} + +int totalFor(int blurRadius, int cornerRadius) +{ + return 2 * borderFor(blurRadius, cornerRadius) + kCenter; +} + +QString cacheFilePath(int blurRadius, int cornerRadius, const QColor &color, qreal dpr) +{ + const QString dir = + QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/shadows"; + QDir().mkpath(dir); + // dpr folded in at x100 so e.g. 1.5 -> 150; colour as rrggbbaa. + const QString name = QString("shadow_b%1_c%2_%3_d%4.png") + .arg(blurRadius) + .arg(cornerRadius) + .arg(static_cast(color.rgba()), 8, 16, QLatin1Char('0')) + .arg(qRound(dpr * 100)); + return dir + '/' + name; +} + +// Render the rounded rect, then blur it off-screen via QGraphicsBlurEffect. +// Done once per cache key; the cost here is irrelevant to runtime frame rate. +QImage bake(int blurRadius, int cornerRadius, const QColor &color, qreal dpr) +{ + const int total = totalFor(blurRadius, cornerRadius); + const int border = borderFor(blurRadius, cornerRadius); + const qreal s = dpr; + const int physTotal = qMax(1, qRound(total * s)); + + QImage shape(physTotal, physTotal, QImage::Format_ARGB32_Premultiplied); + shape.fill(Qt::transparent); + { + QPainter p(&shape); + p.setRenderHint(QPainter::Antialiasing, true); + p.setPen(Qt::NoPen); + p.setBrush(color); + // Object footprint: inset by the falloff reach (border - corner) so the + // blur has room to fade on every side. + const qreal reach = (border - cornerRadius) * s; + const qreal objSide = (total - 2 * (border - cornerRadius)) * s; + p.drawRoundedRect(QRectF(reach, reach, objSide, objSide), + cornerRadius * s, cornerRadius * s); + } + + QGraphicsScene scene; + auto *item = scene.addPixmap(QPixmap::fromImage(shape)); + auto *blur = new QGraphicsBlurEffect; + blur->setBlurRadius(blurRadius * s); + blur->setBlurHints(QGraphicsBlurEffect::QualityHint); + item->setGraphicsEffect(blur); + + QImage out(physTotal, physTotal, QImage::Format_ARGB32_Premultiplied); + out.fill(Qt::transparent); + { + QPainter op(&out); + scene.render(&op, QRectF(0, 0, physTotal, physTotal), + QRectF(0, 0, physTotal, physTotal)); + } + return out; +} +} // namespace + +namespace ShadowPixmap +{ +NinePatchShadow make(int blurRadius, int cornerRadius, const QColor &color, qreal dpr) +{ + NinePatchShadow result; + result.border = borderFor(blurRadius, cornerRadius); + result.total = totalFor(blurRadius, cornerRadius); + result.dpr = dpr; + + const QString path = cacheFilePath(blurRadius, cornerRadius, color, dpr); + + QPixmap cached; + if (cached.load(path, "PNG") && !cached.isNull()) + { + result.pixmap = cached; + return result; + } + + const QImage baked = bake(blurRadius, cornerRadius, color, dpr); + result.pixmap = QPixmap::fromImage(baked); + baked.save(path, "PNG"); // best-effort cache; failure just re-bakes next time + return result; +} +} // namespace ShadowPixmap diff --git a/src/qEmbyApp/utils/shadowpixmap.h b/src/qEmbyApp/utils/shadowpixmap.h new file mode 100644 index 0000000..f8c4909 --- /dev/null +++ b/src/qEmbyApp/utils/shadowpixmap.h @@ -0,0 +1,39 @@ +#ifndef SHADOWPIXMAP_H +#define SHADOWPIXMAP_H + +#include +#include + +// Pre-baked, blurred drop-shadow rendered as a 9-patch pixmap. +// +// Drawing a QGraphicsDropShadowEffect forces the host widget through an +// off-screen render + software Gaussian blur on every repaint, which is the +// main cause of jank while a shadowed widget is animated. Instead we bake the +// blur exactly once (cached to disk as PNG), then render it at runtime as a +// cheap 9-slice stretch with zero per-frame blur cost. +// +// The baked image represents a rounded rectangle of side (total - 2*border) +// surrounded by `border` pixels of blurred falloff on every edge. `border` is +// expressed in logical (device-independent) pixels; the pixmap itself is baked +// at `dpr` physical pixels per logical pixel and carries NO devicePixelRatio, +// so callers must scale source rectangles by `dpr` when slicing. +struct NinePatchShadow +{ + QPixmap pixmap; // physical-resolution bitmap, devicePixelRatio() == 1 + int border = 0; // fixed (non-stretched) edge width, logical px + int total = 0; // full pixmap side, logical px + qreal dpr = 1.0; // physical-per-logical scale the pixmap was baked at + + bool isNull() const { return pixmap.isNull(); } +}; + +namespace ShadowPixmap +{ +// Build (or load from the on-disk cache) a 9-patch shadow for a rounded rect. +// blurRadius/cornerRadius are logical pixels; color carries the shadow tint and +// 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); +} + +#endif // SHADOWPIXMAP_H diff --git a/src/qEmbyApp/views/media/playerview.cpp b/src/qEmbyApp/views/media/playerview.cpp index 1276963..beebeb9 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/shadowoverlay.h" #include "../../utils/mediaitemutils.h" #include "../../utils/mediasourcepreferenceutils.h" #include "../../utils/playerpreferenceutils.h" @@ -26,7 +27,6 @@ #include #include #include -#include #include #include #include @@ -1611,6 +1611,8 @@ void PlayerView::setupUi() updateDanmakuButtonState(); updateMediaSwitcherButton(); m_rightSidebar->raise(); + if (m_rightSidebarShadow) + m_rightSidebarShadow->stackUnder(m_rightSidebar); } @@ -1633,9 +1635,11 @@ void PlayerView::updateLoadingState() m_topHUD->raise(); m_bottomHUD->raise(); - m_logoLabel->raise(); - m_networkSpeedLabel->raise(); + m_logoLabel->raise(); + m_networkSpeedLabel->raise(); m_rightSidebar->raise(); + if (m_rightSidebarShadow) + m_rightSidebarShadow->stackUnder(m_rightSidebar); m_rightTrigger->raise(); if (m_activePopup) m_activePopup->raise(); @@ -2224,11 +2228,15 @@ void PlayerView::setupRightSidebar() m_rightSidebar->setFixedWidth(220); m_rightSidebar->setGeometry(20000, 0, 220, 100); - auto *shadow = new QGraphicsDropShadowEffect(this); - shadow->setBlurRadius(35); - shadow->setColor(QColor(0, 0, 0, 150)); - shadow->setOffset(-5, 0); - m_rightSidebar->setGraphicsEffect(shadow); + // Drop shadow via a pre-baked 9-patch overlay instead of + // QGraphicsDropShadowEffect, which would re-run a software Gaussian blur on + // every frame of the 350ms slide. Body cutout is on because the sidebar + // background is semi-transparent (the shadow must not bleed through it). + // The overlay tracks the sidebar's geometry in syncRightSidebarShadow(). + m_rightSidebarShadow = new ShadowOverlay(this); + m_rightSidebarShadow->setShadow(35, 0, QColor(0, 0, 0, 150)); + m_rightSidebarShadow->setBodyCutout(true); + m_rightSidebarShadow->hide(); auto *layout = new QVBoxLayout(m_rightSidebar); layout->setContentsMargins(0, 15, 0, 15); @@ -2374,6 +2382,26 @@ void PlayerView::hideRightSidebar(bool immediate) m_rightSidebarAnim->start(); } +void PlayerView::syncRightSidebarShadow() +{ + if (!m_rightSidebarShadow || !m_rightSidebar) + { + return; + } + + if (!m_rightSidebar->isVisible()) + { + m_rightSidebarShadow->hide(); + return; + } + + const int reach = m_rightSidebarShadow->reach(); + m_rightSidebarShadow->setGeometry( + m_rightSidebar->geometry().adjusted(-reach, -reach, reach, reach)); + m_rightSidebarShadow->stackUnder(m_rightSidebar); + m_rightSidebarShadow->show(); +} + void PlayerView::stopAndReport() @@ -2874,6 +2902,16 @@ bool PlayerView::eventFilter(QObject *watched, QEvent *event) return BaseView::eventFilter(watched, event); } + // Keep the drop-shadow overlay glued to the sidebar. The slide animation + // and layout both move/resize it, landing here as Move/Resize events, so the + // shadow follows every frame with no per-frame blur. + if (watched == m_rightSidebar && + (event->type() == QEvent::Move || event->type() == QEvent::Resize || + event->type() == QEvent::Show || event->type() == QEvent::Hide)) + { + syncRightSidebarShadow(); + } + if (watched == m_mpvWidget || watched == this || watched == m_topHUD || watched == m_titleLabel || watched == m_bottomHUD) { diff --git a/src/qEmbyApp/views/media/playerview.h b/src/qEmbyApp/views/media/playerview.h index 288342f..537547d 100644 --- a/src/qEmbyApp/views/media/playerview.h +++ b/src/qEmbyApp/views/media/playerview.h @@ -29,6 +29,7 @@ class QEmbyCore; class PlayerOverlayDialog; +class ShadowOverlay; class PlayerMediaSwitcherPanel; class PlayerOsdLayer; class PlayerLongPressHandler; @@ -138,6 +139,7 @@ private: long long startPositionTicks); void hideRightSidebar(bool immediate = false); + void syncRightSidebarShadow(); void setEffectivePlaybackSpeed(double speed); void handlePointerActivity(const QPoint &globalPos); void setCursorHidden(bool hidden); @@ -207,6 +209,7 @@ private: QWidget *m_rightSidebar; + ShadowOverlay *m_rightSidebarShadow = nullptr; QWidget *m_rightTrigger; QPropertyAnimation *m_rightSidebarAnim; diff --git a/src/qEmbyApp/views/user/homeview.cpp b/src/qEmbyApp/views/user/homeview.cpp index 0fb9518..f6e549b 100644 --- a/src/qEmbyApp/views/user/homeview.cpp +++ b/src/qEmbyApp/views/user/homeview.cpp @@ -3,6 +3,7 @@ #include "../../components/downloadmanagerdialog.h" #include "../../components/elidedlabel.h" #include "../../components/moderntoast.h" +#include "../../components/shadowoverlay.h" #include "../../components/slidingstackedwidget.h" #include "../../components/webdavsyncdialog.h" #include "../../managers/thememanager.h" @@ -31,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -607,15 +607,15 @@ void HomeView::setupSidebar() m_sidebar->setObjectName("floating-sidebar"); m_sidebar->setProperty("sidebarSide", m_sidebarOnRight ? "right" : "left"); - - if (!m_sidebarPinned) - { - auto *shadow = new QGraphicsDropShadowEffect(this); - shadow->setBlurRadius(25); - shadow->setColor(QColor(0, 0, 0, 30)); - shadow->setOffset(m_sidebarOnRight ? -4 : 4, 0); - m_sidebar->setGraphicsEffect(shadow); - } + + // Drop shadow as a mouse-transparent sibling painted via a pre-baked + // 9-patch, instead of QGraphicsDropShadowEffect. The effect would force the + // sidebar through an off-screen software blur on every frame of its slide + // animation; the overlay just follows the sidebar's geometry (see + // syncSidebarShadow) with no per-frame blur cost. + m_sidebarShadow = new ShadowOverlay(this); + m_sidebarShadow->setShadow(25, 0, QColor(0, 0, 0, 30)); + m_sidebarShadow->hide(); auto *layout = new QVBoxLayout(m_sidebar); layout->setContentsMargins(16, 20, 0, 20); @@ -1616,6 +1616,16 @@ bool HomeView::eventFilter(QObject *watched, QEvent *event) if (watched == m_sidebar) { + // The drop-shadow overlay tracks the sidebar's geometry. move()/ + // setGeometry() anywhere (resize, slide animation, reposition) lands + // here as a Move/Resize event, so the shadow follows every frame + // without any per-frame blur. + if (event->type() == QEvent::Move || event->type() == QEvent::Resize || + event->type() == QEvent::Show || event->type() == QEvent::Hide) + { + syncSidebarShadow(); + } + if (event->type() == QEvent::Leave) { QPoint globalPos = QCursor::pos(); @@ -1988,6 +1998,30 @@ void HomeView::syncSidebarVisibility() m_edgeTrigger->show(); } +void HomeView::syncSidebarShadow() +{ + if (!m_sidebarShadow || !m_sidebar) + { + return; + } + + // No floating shadow when pinned (docked in layout) or while the sidebar is + // hidden (e.g. immersive views). The sidebar staying on-screen but slid off + // to a negative/overflow position keeps the overlay shown but equally + // off-screen, so it correctly tracks slide-in/out. + if (m_sidebarPinned || !m_sidebar->isVisible()) + { + m_sidebarShadow->hide(); + return; + } + + const int reach = m_sidebarShadow->reach(); + m_sidebarShadow->setGeometry( + m_sidebar->geometry().adjusted(-reach, -reach, reach, reach)); + m_sidebarShadow->stackUnder(m_sidebar); + m_sidebarShadow->show(); +} + void HomeView::applySidebarPosition() { m_sidebar->setProperty("sidebarSide", m_sidebarOnRight ? "right" : "left"); @@ -2012,13 +2046,8 @@ void HomeView::applySidebarPosition() const int sidebarW = m_sidebar->width(); - - if (auto *shadow = qobject_cast(m_sidebar->graphicsEffect())) - { - shadow->setOffset(m_sidebarOnRight ? -4 : 4, 0); - } - - + // Shadow side follows the sidebar automatically (overlay tracks geometry in + // syncSidebarShadow); the baked shadow is symmetric, so no per-side offset. if (m_sidebarOnRight) { m_edgeTrigger->setGeometry(width() - kRightEdgeTriggerWidth, 0, kRightEdgeTriggerWidth, height()); @@ -2056,10 +2085,11 @@ void HomeView::applySidebarPinned(bool pinned) m_sidebarAnim->stop(); m_sidebarAutoHideTimer->stop(); - - m_sidebar->setGraphicsEffect(nullptr); + // Pinned sidebar is docked into the layout and casts no floating shadow. + if (m_sidebarShadow) + m_sidebarShadow->hide(); + - m_sidebar->setProperty("pinned", true); m_sidebar->style()->unpolish(m_sidebar); m_sidebar->style()->polish(m_sidebar); @@ -2085,16 +2115,12 @@ void HomeView::applySidebarPinned(bool pinned) { m_contentLayout->removeWidget(m_sidebar); - m_sidebar->setParent(this); + m_sidebar->setParent(this); + + // Floating sidebar regains its drop shadow (painted by the overlay + // sibling; geometry is synced in syncSidebarShadow / eventFilter). - - auto *shadow = new QGraphicsDropShadowEffect(this); - shadow->setBlurRadius(25); - shadow->setColor(QColor(0, 0, 0, 30)); - shadow->setOffset(m_sidebarOnRight ? -4 : 4, 0); - m_sidebar->setGraphicsEffect(shadow); - m_sidebar->setProperty("pinned", false); m_sidebar->style()->unpolish(m_sidebar); m_sidebar->style()->polish(m_sidebar); diff --git a/src/qEmbyApp/views/user/homeview.h b/src/qEmbyApp/views/user/homeview.h index 8de42ae..c9e8ac7 100644 --- a/src/qEmbyApp/views/user/homeview.h +++ b/src/qEmbyApp/views/user/homeview.h @@ -13,6 +13,7 @@ class QTimer; class QEmbyCore; class SlidingStackedWidget; +class ShadowOverlay; class QLabel; class QPushButton; class QAction; @@ -102,6 +103,7 @@ private: void applySidebarIcons(); void applySidebarCustomVisibility(); void syncSidebarVisibility(); + void syncSidebarShadow(); void openCloudSyncDialog(); void setupSearchHistory(); void updateSearchCompleter(const QString &text = QString()); @@ -137,6 +139,7 @@ private: FavoritesView* m_favoritesView = nullptr; QWidget* m_sidebar = nullptr; + ShadowOverlay* m_sidebarShadow = nullptr; QWidget* m_edgeTrigger = nullptr; QPropertyAnimation* m_sidebarAnim = nullptr; QTimer* m_sidebarAutoHideTimer = nullptr;