#include "playerlongpresshandler.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../tokens/AnimationTokens.h" #include "../utils/motionprefs.h" namespace { void stopPropertyAnimationSafely(QPropertyAnimation *animation) { if (!animation || !animation->targetObject() || animation->state() == QAbstractAnimation::Stopped) { return; } animation->stop(); } } PlayerLongPressHandler::PlayerLongPressHandler(QWidget *parent) : QObject(parent), m_parentWidget(parent) { m_keyLongPressTimer = new QTimer(this); connect(m_keyLongPressTimer, &QTimer::timeout, this, &PlayerLongPressHandler::onKeyLongPressTimeout); m_mouseEdgeTimer = new QTimer(this); connect(m_mouseEdgeTimer, &QTimer::timeout, this, &PlayerLongPressHandler::onMouseEdgeLongPressTimeout); m_pulseTimer = new QTimer(this); m_pulseTimer->setInterval(260); connect(m_pulseTimer, &QTimer::timeout, this, &PlayerLongPressHandler::updateIndicatorPulse); m_leftIndicator = createIndicator( QStringLiteral(":/svg/player/fast-rewind.svg"), m_leftIndicatorIcon, m_leftIndicatorText, m_leftIndicatorOpacity, m_leftIndicatorOpacityAnim, m_leftIndicatorGeometryAnim, m_leftIndicatorIconAnim); m_rightIndicator = createIndicator( QStringLiteral(":/svg/player/fast-forward.svg"), m_rightIndicatorIcon, m_rightIndicatorText, m_rightIndicatorOpacity, m_rightIndicatorOpacityAnim, m_rightIndicatorGeometryAnim, m_rightIndicatorIconAnim); } QWidget *PlayerLongPressHandler::createIndicator( const QString &iconPath, QLabel *&iconLabel, QLabel *&textLabel, QGraphicsOpacityEffect *&opacityEffect, QPropertyAnimation *&opacityAnim, QPropertyAnimation *&geometryAnim, QPropertyAnimation *&iconAnim) { auto *overlay = new QWidget(m_parentWidget); overlay->setObjectName("playerMouseEdgeIndicator"); overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); overlay->setAttribute(Qt::WA_TranslucentBackground, true); overlay->setProperty("gestureActive", false); overlay->setProperty("indicatorVisible", false); overlay->hide(); auto *layout = new QHBoxLayout(overlay); layout->setContentsMargins(18, 12, 18, 12); layout->setSpacing(12); layout->setAlignment(Qt::AlignCenter); iconLabel = new QLabel(overlay); iconLabel->setObjectName("playerMouseEdgeIndicatorIcon"); iconLabel->setFixedSize(18, 18); iconLabel->setAlignment(Qt::AlignCenter); iconLabel->setPixmap(QIcon(iconPath).pixmap(18, 18)); layout->addWidget(iconLabel, 0, Qt::AlignCenter); textLabel = new QLabel(overlay); textLabel->setObjectName("playerMouseEdgeIndicatorText"); textLabel->setAlignment(Qt::AlignCenter); textLabel->hide(); layout->addWidget(textLabel, 0, Qt::AlignCenter); opacityEffect = new QGraphicsOpacityEffect(overlay); opacityEffect->setOpacity(0.0); overlay->setGraphicsEffect(opacityEffect); opacityAnim = new QPropertyAnimation(opacityEffect, "opacity", this); opacityAnim->setEasingCurve(AnimationTokens::Curve::enter()); geometryAnim = new QPropertyAnimation(overlay, "geometry", this); geometryAnim->setEasingCurve(AnimationTokens::Curve::enter()); iconAnim = new QPropertyAnimation(iconLabel, "pos", this); iconAnim->setDuration(AnimationTokens::DurationMs::kNormal); iconAnim->setEasingCurve(AnimationTokens::Curve::oscillate()); connect(opacityAnim, &QPropertyAnimation::finished, overlay, [overlay, opacityEffect]() { if (!overlay->property("indicatorVisible").toBool() && opacityEffect && opacityEffect->opacity() <= 0.01) { overlay->hide(); } }); return overlay; } QString PlayerLongPressHandler::longPressMode() const { const QString mode = ConfigStore::instance()->get(ConfigKeys::PlayerLongPressMode, "seek"); return mode == QStringLiteral("speed") ? mode : QStringLiteral("seek"); } int PlayerLongPressHandler::longPressTriggerMs() const { const int triggerMs = ConfigStore::instance()->get(ConfigKeys::PlayerLongPressTriggerMs, "500").toInt(); return triggerMs > 0 ? triggerMs : 500; } double PlayerLongPressHandler::nextPlaybackRate(double currentRate) const { static const QList kSpeedSteps = {2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}; for (double rate : kSpeedSteps) { if (rate > currentRate + 0.001) { return rate; } } return kSpeedSteps.constLast(); } bool PlayerLongPressHandler::canStartPropertyAnimation(const QPropertyAnimation *animation) const { return animation && animation->targetObject() && !MotionPrefs::reduceMotion(); } bool PlayerLongPressHandler::isMouseEdgeLongPressEnabled() const { return ConfigStore::instance()->get(ConfigKeys::PlayerMouseEdgeLongPress, true); } QString PlayerLongPressHandler::formatSpeedText(double speed) { QString text = QString::number(speed, 'f', 2); while (text.contains('.') && text.endsWith('0')) { text.chop(1); } if (text.endsWith('.')) { text.append('0'); } return text + "X"; } QString PlayerLongPressHandler::formatLongPressSeekText(double seconds) { QString text = QString::number(seconds, 'f', seconds >= 10.0 ? 0 : 1); while (text.contains('.') && text.endsWith('0')) { text.chop(1); } if (text.endsWith('.')) { text.chop(1); } return text + "s"; } void PlayerLongPressHandler::startKeyLongPress(int direction, bool silent) { m_teardown = false; stopKeyLongPress(false); m_keyDirection = direction; m_keySeekRate = 2.0; m_keyRestoreSpeed = 1.0; m_keyCurrentSpeed = 1.0; m_keySpeedTick = 0; m_keyTriggered = false; m_keySilent = silent; m_keySpeedActive = false; m_keyActiveMode = longPressMode(); const double step = ConfigStore::instance()->get(ConfigKeys::PlayerSeekStep, "10").toDouble(); const bool longPressEnabled = ConfigStore::instance()->get(ConfigKeys::PlayerLongPressSeek, true); if (!longPressEnabled) { Q_EMIT seekRequested(direction * step, silent); m_keyDirection = 0; m_keySilent = false; m_keyActiveMode.clear(); return; } if (m_keyActiveMode == QStringLiteral("seek")) { Q_EMIT seekRequested(direction * step, silent); } qDebug() << "[PlayerLongPressHandler] Direction key pressed" << "| direction:" << direction << "| mode:" << m_keyActiveMode << "| triggerMs:" << longPressTriggerMs(); m_keyLongPressTimer->setInterval(longPressTriggerMs()); m_keyLongPressTimer->start(); } void PlayerLongPressHandler::stopKeyLongPress(bool applyShortPressFallback) { if (m_keyLongPressTimer) { m_keyLongPressTimer->stop(); } if (m_keyDirection == 0) { return; } if (m_keyActiveMode == QStringLiteral("speed")) { if (!m_keySpeedActive && applyShortPressFallback) { const double step = ConfigStore::instance() ->get(ConfigKeys::PlayerSeekStep, "10") .toDouble(); Q_EMIT seekRequested(m_keyDirection * step, m_keySilent); } } m_keyDirection = 0; m_keySeekRate = 2.0; m_keyRestoreSpeed = 1.0; m_keyCurrentSpeed = 1.0; m_keySpeedTick = 0; m_keyTriggered = false; m_keySilent = false; m_keySpeedActive = false; m_keyActiveMode.clear(); updateIndicators(); } void PlayerLongPressHandler::onKeyLongPressTimeout() { if (m_keyDirection == 0 || m_keyActiveMode.isEmpty()) { return; } if (m_keyActiveMode == QStringLiteral("speed")) { if (!m_keyTriggered) { m_keyTriggered = true; m_keySpeedActive = true; m_keyCurrentSpeed = nextPlaybackRate(1.0); m_keySpeedTick = 0; const QString speedText = formatSpeedText(m_keyCurrentSpeed); updateIndicators(); Q_EMIT toastRequested(tr("Speed: %1X").arg(speedText.chopped(1))); qDebug() << "[PlayerLongPressHandler] Long press speed activated" << "| direction:" << m_keyDirection << "| targetSpeed:" << m_keyCurrentSpeed; } else if (m_keySpeedTick > 0 && m_keySpeedTick % 2 == 0) { const double nextSpeed = nextPlaybackRate(m_keyCurrentSpeed); if (std::abs(nextSpeed - m_keyCurrentSpeed) > 0.001) { m_keyCurrentSpeed = nextSpeed; const QString speedText = formatSpeedText(m_keyCurrentSpeed); updateIndicators(); Q_EMIT toastRequested(tr("Speed: %1X").arg(speedText.chopped(1))); qDebug() << "[PlayerLongPressHandler] Long press speed increased" << "| direction:" << m_keyDirection << "| targetSpeed:" << m_keyCurrentSpeed; } } const double deltaSeconds = m_keyDirection * m_keyCurrentSpeed * 0.25; Q_EMIT seekRequested(deltaSeconds, true); ++m_keySpeedTick; m_keyLongPressTimer->setInterval(250); return; } const bool wasTriggered = m_keyTriggered; m_keyTriggered = true; m_keyLongPressTimer->setInterval(100); Q_EMIT seekRequested(m_keyDirection * m_keySeekRate, m_keySilent); updateIndicators(); if (!wasTriggered) { qDebug() << "[PlayerLongPressHandler] Long press seek activated" << "| direction:" << m_keyDirection; } m_keySeekRate *= 1.15; if (m_keySeekRate > 30.0) { m_keySeekRate = 30.0; } } void PlayerLongPressHandler::startMouseEdgeLongPress(int direction) { m_teardown = false; stopMouseEdgeLongPress(); if (!isMouseEdgeLongPressEnabled()) { return; } m_mouseEdgeDirection = direction; m_mouseEdgeTick = 0; m_mouseEdgeSpeed = 1.0; m_mouseEdgeActive = false; qDebug() << "[PlayerLongPressHandler] Mouse edge long press armed" << "| direction:" << direction << "| triggerMs:" << longPressTriggerMs(); if (m_mouseEdgeTimer) { m_mouseEdgeTimer->setInterval(longPressTriggerMs()); m_mouseEdgeTimer->start(); } } bool PlayerLongPressHandler::stopMouseEdgeLongPress() { if (m_mouseEdgeTimer) { m_mouseEdgeTimer->stop(); } const bool wasActive = m_mouseEdgeActive; if (wasActive) { qDebug() << "[PlayerLongPressHandler] Mouse edge long press stopped" << "| direction:" << m_mouseEdgeDirection << "| speed:" << m_mouseEdgeSpeed; } m_mouseEdgeDirection = 0; m_mouseEdgeTick = 0; m_mouseEdgeSpeed = 1.0; m_mouseEdgeActive = false; updateIndicators(); return wasActive; } void PlayerLongPressHandler::onMouseEdgeLongPressTimeout() { if (m_mouseEdgeDirection == 0) { return; } if (!m_mouseEdgeActive) { m_mouseEdgeActive = true; m_mouseEdgeSpeed = nextPlaybackRate(1.0); updateIndicators(); qDebug() << "[PlayerLongPressHandler] Mouse edge long press activated" << "| direction:" << m_mouseEdgeDirection << "| speed:" << m_mouseEdgeSpeed; } else if (m_mouseEdgeTick > 0 && m_mouseEdgeTick % 2 == 0) { const double nextSpeed = nextPlaybackRate(m_mouseEdgeSpeed); if (std::abs(nextSpeed - m_mouseEdgeSpeed) > 0.001) { m_mouseEdgeSpeed = nextSpeed; updateIndicators(); qDebug() << "[PlayerLongPressHandler] Mouse edge long press accelerated" << "| direction:" << m_mouseEdgeDirection << "| speed:" << m_mouseEdgeSpeed; } } const double deltaSeconds = m_mouseEdgeDirection * m_mouseEdgeSpeed * 0.25; Q_EMIT seekRequested(deltaSeconds, true); ++m_mouseEdgeTick; if (m_mouseEdgeTimer) { m_mouseEdgeTimer->setInterval(250); } } bool PlayerLongPressHandler::isMouseEdgeActive() const { return m_mouseEdgeActive; } int PlayerLongPressHandler::mouseEdgeLongPressDirection() const { return m_mouseEdgeDirection; } bool PlayerLongPressHandler::isKeyLongPressTriggered() const { return m_keyTriggered; } int PlayerLongPressHandler::keyLongPressDirection() const { return m_keyDirection; } int PlayerLongPressHandler::activeLongPressDirection() const { if (m_mouseEdgeActive && m_mouseEdgeDirection != 0) { return m_mouseEdgeDirection; } if (m_keyTriggered && m_keyDirection != 0) { return m_keyDirection; } return 0; } void PlayerLongPressHandler::setTeardown(bool teardown) { m_teardown = teardown; if (!m_teardown) { return; } if (m_keyLongPressTimer) { m_keyLongPressTimer->stop(); } if (m_mouseEdgeTimer) { m_mouseEdgeTimer->stop(); } stopIndicatorAnimations(); } QRect PlayerLongPressHandler::indicatorGeometry(int direction, bool active) const { const int w = m_parentWidget ? m_parentWidget->width() : 800; const int h = m_parentWidget ? m_parentWidget->height() : 600; const int indicatorWidth = active ? 128 : 60; const int indicatorHeight = 54; const int indicatorMargin = 28; const int indicatorY = qMax(m_topHudHeight + 24, (h - indicatorHeight) / 2); const int indicatorX = direction < 0 ? indicatorMargin : w - indicatorWidth - indicatorMargin; return QRect(indicatorX, indicatorY, indicatorWidth, indicatorHeight); } void PlayerLongPressHandler::animateIndicator( QWidget *overlay, int direction, bool active, bool visible) { if (!overlay) { return; } QGraphicsOpacityEffect *opacityEffect = direction < 0 ? m_leftIndicatorOpacity : m_rightIndicatorOpacity; QPropertyAnimation *opacityAnim = direction < 0 ? m_leftIndicatorOpacityAnim : m_rightIndicatorOpacityAnim; QPropertyAnimation *geometryAnim = direction < 0 ? m_leftIndicatorGeometryAnim : m_rightIndicatorGeometryAnim; const QRect targetGeometry = indicatorGeometry(direction, active); if (!opacityEffect || !opacityAnim || !geometryAnim) { overlay->setGeometry(targetGeometry); overlay->setVisible(visible); return; } if (!opacityAnim->targetObject() || !geometryAnim->targetObject() || m_teardown) { overlay->setProperty("indicatorVisible", visible); overlay->setGeometry(targetGeometry); opacityEffect->setOpacity(visible ? (active ? 1.0 : 0.62) : 0.0); overlay->setVisible(visible); return; } stopPropertyAnimationSafely(opacityAnim); stopPropertyAnimationSafely(geometryAnim); if (visible) { const bool wasVisible = overlay->isVisible(); const QRect startGeometry = wasVisible ? overlay->geometry() : targetGeometry.translated( direction < 0 ? -14 : 14, 0); const qreal startOpacity = wasVisible ? opacityEffect->opacity() : 0.0; const qreal targetOpacity = active ? 1.0 : 0.62; overlay->setProperty("indicatorVisible", true); overlay->setGeometry(startGeometry); overlay->show(); overlay->raise(); opacityEffect->setOpacity(startOpacity); 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); } else { if (!overlay->isVisible()) { opacityEffect->setOpacity(0.0); return; } const QRect currentGeometry = overlay->geometry(); overlay->setProperty("indicatorVisible", false); opacityAnim->setDuration(AnimationTokens::DurationMs::kMicro); opacityAnim->setStartValue(opacityEffect->opacity()); opacityAnim->setEndValue(0.0); geometryAnim->setDuration(AnimationTokens::DurationMs::kMicro); geometryAnim->setStartValue(currentGeometry); geometryAnim->setEndValue( currentGeometry.translated(direction < 0 ? -10 : 10, 0)); } if (canStartPropertyAnimation(opacityAnim)) { opacityAnim->start(); } 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)) { geometryAnim->start(); } else { overlay->setGeometry(visible ? targetGeometry : overlay->geometry().translated( direction < 0 ? -10 : 10, 0)); } } void PlayerLongPressHandler::animateIndicatorIcon( QLabel *iconLabel, int direction, bool active, bool immediate) { if (!iconLabel) { return; } QPropertyAnimation *iconAnim = direction < 0 ? m_leftIndicatorIconAnim : m_rightIndicatorIconAnim; if (!iconAnim) { return; } const QPoint basePos = iconLabel->property("basePos").toPoint(); const int offset = (active && m_pulseForward) ? (direction < 0 ? -4 : 4) : 0; const QPoint targetPos = basePos + QPoint(offset, 0); if (immediate || !iconAnim->targetObject() || m_teardown) { iconLabel->move(targetPos); return; } stopPropertyAnimationSafely(iconAnim); iconAnim->setStartValue(iconLabel->pos()); iconAnim->setEndValue(targetPos); if (canStartPropertyAnimation(iconAnim)) { iconAnim->start(); } else { iconLabel->move(targetPos); } } void PlayerLongPressHandler::setIndicatorState( QWidget *overlay, QLabel *iconLabel, QLabel *textLabel, int direction, bool active, const QString &speedText) { if (!overlay || !iconLabel || !textLabel) { return; } overlay->setProperty("gestureActive", active); overlay->style()->unpolish(overlay); overlay->style()->polish(overlay); overlay->update(); if (speedText.isEmpty()) { textLabel->clear(); textLabel->hide(); } else { textLabel->setText(speedText); textLabel->show(); } if (overlay->layout()) { overlay->layout()->activate(); } iconLabel->setProperty("basePos", iconLabel->pos()); animateIndicatorIcon(iconLabel, direction, active, true); animateIndicator(overlay, direction, active, true); } void PlayerLongPressHandler::updateIndicatorPulse() { if (m_teardown) { stopIndicatorAnimations(); return; } const int activeDirection = activeLongPressDirection(); if (activeDirection == 0) { if (m_pulseTimer) { m_pulseTimer->stop(); } m_pulseForward = false; animateIndicatorIcon(m_leftIndicatorIcon, -1, false, true); animateIndicatorIcon(m_rightIndicatorIcon, 1, false, true); return; } m_pulseForward = !m_pulseForward; animateIndicatorIcon(m_leftIndicatorIcon, -1, activeDirection < 0); animateIndicatorIcon(m_rightIndicatorIcon, 1, activeDirection > 0); } void PlayerLongPressHandler::updateIndicators() { if (m_teardown) { stopIndicatorAnimations(); if (m_leftIndicator) { m_leftIndicator->hide(); } if (m_rightIndicator) { m_rightIndicator->hide(); } animateIndicatorIcon(m_leftIndicatorIcon, -1, false, true); animateIndicatorIcon(m_rightIndicatorIcon, 1, false, true); return; } const int activeDirection = activeLongPressDirection(); if (activeDirection == 0) { if (m_leftIndicator) { m_leftIndicator->setProperty("gestureActive", false); m_leftIndicator->style()->unpolish(m_leftIndicator); m_leftIndicator->style()->polish(m_leftIndicator); animateIndicator(m_leftIndicator, -1, false, false); } if (m_rightIndicator) { m_rightIndicator->setProperty("gestureActive", false); m_rightIndicator->style()->unpolish(m_rightIndicator); m_rightIndicator->style()->polish(m_rightIndicator); animateIndicator(m_rightIndicator, 1, false, false); } updateIndicatorPulse(); return; } const bool fromMouse = m_mouseEdgeActive && m_mouseEdgeDirection != 0; const QString indicatorText = fromMouse ? formatSpeedText(m_mouseEdgeSpeed) : (m_keyActiveMode == QStringLiteral("speed") ? formatSpeedText(m_keyCurrentSpeed) : formatLongPressSeekText(m_keySeekRate)); const bool leftActive = activeDirection < 0; const bool rightActive = activeDirection > 0; setIndicatorState( m_leftIndicator, m_leftIndicatorIcon, m_leftIndicatorText, -1, leftActive, leftActive ? indicatorText : QString()); setIndicatorState( m_rightIndicator, m_rightIndicatorIcon, m_rightIndicatorText, 1, rightActive, rightActive ? indicatorText : QString()); if (m_pulseTimer && !m_pulseTimer->isActive() && !MotionPrefs::reduceMotion()) { m_pulseForward = false; updateIndicatorPulse(); m_pulseTimer->start(); } } void PlayerLongPressHandler::stopIndicatorAnimations() { if (m_pulseTimer) { m_pulseTimer->stop(); } const QList animations = { m_leftIndicatorOpacityAnim, m_rightIndicatorOpacityAnim, m_leftIndicatorGeometryAnim, m_rightIndicatorGeometryAnim, m_leftIndicatorIconAnim, m_rightIndicatorIconAnim}; for (QPropertyAnimation *animation : animations) { stopPropertyAnimationSafely(animation); } } void PlayerLongPressHandler::stopAllAnimations() { stopIndicatorAnimations(); } void PlayerLongPressHandler::updateGeometry(int parentWidth, int parentHeight, int topHudHeight) { Q_UNUSED(parentHeight); m_topHudHeight = topHudHeight; auto syncGeometry = [this](QWidget *overlay, QPropertyAnimation *animation, int direction) { if (!overlay) { return; } const QRect targetGeometry = indicatorGeometry( direction, overlay->property("gestureActive").toBool()); if (animation && animation->state() == QAbstractAnimation::Running) { animation->setEndValue(targetGeometry); return; } overlay->setGeometry(targetGeometry); }; syncGeometry(m_leftIndicator, m_leftIndicatorGeometryAnim, -1); syncGeometry(m_rightIndicator, m_rightIndicatorGeometryAnim, 1); }