Files
qemby/src/qEmbyApp/views/media/playerview.cpp
T
dela 907de1feeb perf(ui): replace animated-sidebar drop shadows with pre-baked 9-patch overlay
QGraphicsDropShadowEffect forces the host widget through an off-screen
software Gaussian blur on every repaint. On the floating sidebar (HomeView,
blur 25) and the player right sidebar (PlayerView, blur 35), the blur was
re-rasterized on every frame of the 350ms slide animation, the main source
of UI jank.

Bake the blur once into a 9-patch PNG (cached to CacheLocation/shadows via
QGraphicsBlurEffect) and render it at runtime as a cheap 9-slice stretch with
zero per-frame blur cost:

- utils/shadowpixmap: off-screen bake + on-disk cache, dpr-aware.
- components/shadowoverlay: mouse-transparent sibling widget, lazy-baked,
  paints the 9-patch; optional body cutout for semi-transparent targets so
  the shadow does not bleed through the body.
- homeview/playerview: drop the effect; the overlay tracks the sidebar
  geometry via the existing eventFilter (Move/Resize/Show/Hide), so every
  setGeometry/move site and the slide animation drive it for free.
2026-06-18 17:03:57 +08:00

5441 lines
166 KiB
C++

#include "playerview.h"
#include "../../components/modernscrollpanel.h"
#include "../../components/nativedanmakuoverlay.h"
#include "../../components/playerdanmakuidentifydialog.h"
#include "../../components/playerdanmakusettingsdialog.h"
#include "../../components/playerlongpresshandler.h"
#include "../../components/playermediaswitcherpanel.h"
#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"
#include "../../utils/powerinhibitutils.h"
#include "../../utils/subtitlestyleutils.h"
#include "qembycore.h"
#include <QAbstractAnimation>
#include <QApplication>
#include <QCursor>
#include <QDebug>
#include <QDialog>
#include <QDir>
#include <QEasingCurve>
#include <QElapsedTimer>
#include <QEvent>
#include <QFileDialog>
#include <QFileInfo>
#include <QFontMetrics>
#include <QHBoxLayout>
#include <QListWidget>
#include <QMainWindow>
#include <QMouseEvent>
#include <QPalette>
#include <QPointer>
#include <QSettings>
#include <QSignalBlocker>
#include <QStyle>
#include <QTime>
#include <QUrl>
#include <QUrlQuery>
#include <QVBoxLayout>
#include <QWheelEvent>
#include <fileutils.h>
#include <QVector>
#include <QWindow>
#include <cmath>
#include <config/config_keys.h>
#include <config/configstore.h>
#include <initializer_list>
#include <models/media/playbackinfo.h>
#include <models/media/playerlaunchcontext.h>
#include <models/profile/serverprofile.h>
#include <services/danmaku/danmakuservice.h>
#include <services/manager/servermanager.h>
#include <services/media/mediaservice.h>
namespace
{
constexpr int kHudAutoHideDelayMs = 1800;
bool isDanmakuEnabledConfigKey(const QString &key)
{
static const QString kDanmakuEnabledSuffix =
QStringLiteral("/") + QString::fromLatin1(ConfigKeys::PlayerDanmakuEnabled);
return key == QLatin1String(ConfigKeys::PlayerDanmakuEnabled) || key.endsWith(kDanmakuEnabledSuffix);
}
QString trimOrDash(QString value)
{
value = value.trimmed();
return value.isEmpty() ? QStringLiteral("-") : value;
}
QString firstNonEmpty(const QStringList &values)
{
for (QString value : values)
{
value = value.trimmed();
if (!value.isEmpty())
{
return value;
}
}
return {};
}
QString joinNonEmpty(const QStringList &values, const QString &separator)
{
QStringList filtered;
for (QString value : values)
{
value = value.trimmed();
if (!value.isEmpty())
{
filtered.append(value);
}
}
return filtered.join(separator);
}
QString stripTrailingZeros(QString text)
{
if (!text.contains(QLatin1Char('.')))
{
return text;
}
while (text.endsWith(QLatin1Char('0')))
{
text.chop(1);
}
if (text.endsWith(QLatin1Char('.')))
{
text.chop(1);
}
return text;
}
QString formatBitrateValue(qint64 bitsPerSecond)
{
if (bitsPerSecond <= 0)
{
return {};
}
if (bitsPerSecond < 1000000)
{
return QStringLiteral("%1 kbps").arg(stripTrailingZeros(QString::number(bitsPerSecond / 1000.0, 'f', 1)));
}
return QStringLiteral("%1 Mbps").arg(stripTrailingZeros(QString::number(bitsPerSecond / 1000000.0, 'f', 2)));
}
QString formatDataRateValue(qint64 bytesPerSecond)
{
if (bytesPerSecond <= 0)
{
return QStringLiteral("0.0 KB/s");
}
if (bytesPerSecond < 1024 * 1024)
{
return QStringLiteral("%1 KB/s").arg(stripTrailingZeros(QString::number(bytesPerSecond / 1024.0, 'f', 1)));
}
return QStringLiteral("%1 MB/s").arg(stripTrailingZeros(QString::number(bytesPerSecond / 1048576.0, 'f', 1)));
}
QString formatFrameRateValue(double fps)
{
if (!std::isfinite(fps) || fps <= 0.0)
{
return {};
}
return QStringLiteral("%1 fps").arg(stripTrailingZeros(QString::number(fps, 'f', fps >= 100.0 ? 1 : 3)));
}
QString formatDurationValue(double seconds)
{
if (!std::isfinite(seconds) || seconds <= 0.0)
{
return {};
}
return QStringLiteral("%1 s").arg(stripTrailingZeros(QString::number(seconds, 'f', seconds >= 100.0 ? 0 : 1)));
}
QString formatAvSyncValue(double seconds)
{
if (!std::isfinite(seconds))
{
return {};
}
const double milliseconds = seconds * 1000.0;
return QStringLiteral("%1%2 ms")
.arg(milliseconds >= 0.0 ? QStringLiteral("+") : QString())
.arg(stripTrailingZeros(QString::number(milliseconds, 'f', 1)));
}
QString formatLevelValue(int level)
{
if (level <= 0)
{
return {};
}
if (level % 10 == 0)
{
return QString::number(level / 10);
}
return QStringLiteral("%1.%2").arg(level / 10).arg(level % 10);
}
QString formatDimensionValue(int width, int height)
{
if (width <= 0 || height <= 0)
{
return {};
}
return QStringLiteral("%1 x %2").arg(width).arg(height);
}
QString formatAspectValue(double width, double height, QString explicitAspect = {})
{
explicitAspect = explicitAspect.trimmed();
if (!explicitAspect.isEmpty())
{
return explicitAspect;
}
if (!std::isfinite(width) || !std::isfinite(height) || width <= 0.0 || height <= 0.0)
{
return {};
}
const double ratio = width / height;
struct KnownRatio
{
double value;
const char *label;
};
static const KnownRatio knownRatios[] = {
{4.0 / 3.0, "4:3"}, {16.0 / 9.0, "16:9"}, {21.0 / 9.0, "21:9"}, {1.0, "1:1"}};
for (const KnownRatio &known : knownRatios)
{
if (std::fabs(ratio - known.value) < 0.03)
{
return QString::fromLatin1(known.label);
}
}
return QStringLiteral("%1:1").arg(stripTrailingZeros(QString::number(ratio, 'f', 2)));
}
qint64 variantToLongLong(const QVariant &value)
{
bool ok = false;
const qint64 result = value.toLongLong(&ok);
return ok ? result : 0;
}
double variantToDouble(const QVariant &value)
{
bool ok = false;
const double result = value.toDouble(&ok);
return ok ? result : 0.0;
}
const MediaStreamInfo *findFirstStreamByType(const MediaSourceInfo &source, const QString &type)
{
for (const MediaStreamInfo &stream : source.mediaStreams)
{
if (stream.type.compare(type, Qt::CaseInsensitive) == 0)
{
return &stream;
}
}
return nullptr;
}
QVariantMap findSelectedTrackByType(const QVariantList &tracks, const QString &type)
{
for (const QVariant &trackValue : tracks)
{
const QVariantMap track = trackValue.toMap();
if (track.value(QStringLiteral("type")).toString() == type && track.value(QStringLiteral("selected")).toBool())
{
return track;
}
}
return {};
}
bool hasValidAnimationTarget(const QPropertyAnimation *animation)
{
return animation && animation->targetObject();
}
bool fadeGroupHasValidTargets(const QParallelAnimationGroup *group)
{
if (!group)
{
return false;
}
for (int i = 0; i < group->animationCount(); ++i)
{
const auto *animation = qobject_cast<QPropertyAnimation *>(group->animationAt(i));
if (!hasValidAnimationTarget(animation))
{
return false;
}
}
return true;
}
void stopPropertyAnimationSafely(QPropertyAnimation *animation)
{
if (!hasValidAnimationTarget(animation) || animation->state() == QAbstractAnimation::Stopped)
{
return;
}
animation->stop();
}
void stopFadeGroupSafely(QParallelAnimationGroup *group)
{
if (!group || group->state() == QAbstractAnimation::Stopped || !fadeGroupHasValidTargets(group))
{
return;
}
group->stop();
}
void detachPropertyAnimationTarget(QPropertyAnimation *animation)
{
if (!animation)
{
return;
}
if (animation->state() != QAbstractAnimation::Stopped)
{
animation->stop();
}
animation->setTargetObject(nullptr);
}
void detachFadeGroupTargets(QParallelAnimationGroup *group)
{
if (!group)
{
return;
}
if (group->state() != QAbstractAnimation::Stopped)
{
group->stop();
}
for (int i = 0; i < group->animationCount(); ++i)
{
auto *animation = qobject_cast<QPropertyAnimation *>(group->animationAt(i));
detachPropertyAnimationTarget(animation);
}
}
}
PlayerView::PlayerView(QEmbyCore *core, QWidget *parent)
: BaseView(core, parent), m_isPlaying(false), m_currentPosition(0.0), m_totalDuration(0.0), m_activePopup(nullptr)
{
setProperty("isImmersive", true);
setMouseTracking(true);
setAutoFillBackground(true);
setFocusPolicy(Qt::StrongFocus);
m_isRightSidebarVisible = false;
m_hideTimer = new QTimer(this);
m_hideTimer->setSingleShot(true);
connect(m_hideTimer, &QTimer::timeout, this, &PlayerView::hideControls);
m_reportTimer = new QTimer(this);
m_reportTimer->setInterval(10000);
connect(m_reportTimer, &QTimer::timeout, this, &PlayerView::reportProgressToServer);
m_longPressHandler = new PlayerLongPressHandler(this);
connect(m_longPressHandler, &PlayerLongPressHandler::seekRequested, this, &PlayerView::seekRelative);
connect(m_longPressHandler, &PlayerLongPressHandler::toastRequested, this, &PlayerView::showToast);
m_bufferTimer = new QTimer(this);
m_bufferTimer->setInterval(500);
connect(m_bufferTimer, &QTimer::timeout, this,
[this]()
{
if (m_totalDuration > 0 && m_mpvWidget && m_mpvWidget->controller())
{
double bufferedPos = m_currentPosition;
QVariant stateVar = m_mpvWidget->controller()->getProperty("demuxer-cache-state");
if (stateVar.isValid() && stateVar.metaType().id() == QMetaType::QVariantMap)
{
auto ranges = stateVar.toMap()["seekable-ranges"].toList();
if (!ranges.isEmpty())
{
bufferedPos = ranges.last().toMap()["end"].toDouble();
}
}
else
{
double cacheDuration =
m_mpvWidget->controller()->getProperty("demuxer-cache-duration").toDouble();
if (cacheDuration > 0)
{
bufferedPos = m_currentPosition + cacheDuration;
}
}
if (bufferedPos > m_totalDuration)
{
bufferedPos = m_totalDuration;
}
m_progressSlider->setBufferValue(static_cast<int>(bufferedPos));
}
if (m_showStatisticsOverlay && m_statisticsOverlay)
{
updateStatisticsDisplay();
}
});
m_mousePollTimer = new QTimer(this);
m_mousePollTimer->setInterval(100);
connect(m_mousePollTimer, &QTimer::timeout, this,
[this]()
{
if (!m_isPlaying)
{
if (m_topOpacity->opacity() < 1.0)
{
showControls();
}
return;
}
if (areControlsFullyVisible() && this->cursor().shape() != Qt::BlankCursor &&
(!m_mpvWidget || m_mpvWidget->cursor().shape() != Qt::BlankCursor))
{
return;
}
QPoint currentPos = QCursor::pos();
if (currentPos == m_lastMousePos)
{
return;
}
m_lastMousePos = currentPos;
if (this->window()->isMinimized() || !this->isVisible())
{
return;
}
QPoint localPos = this->mapFromGlobal(currentPos);
bool isMouseInside = this->rect().contains(localPos);
if (isMouseInside)
{
handlePointerActivity(currentPos);
}
});
m_toastTimer = new QTimer(this);
m_toastTimer->setSingleShot(true);
connect(m_toastTimer, &QTimer::timeout, this, [this]() { m_toastLabel->hide(); });
m_singleClickTimer = new QTimer(this);
m_singleClickTimer->setSingleShot(true);
m_singleClickTimer->setInterval(200);
connect(m_singleClickTimer, &QTimer::timeout, this, &PlayerView::togglePlayPause);
setupUi();
connect(ConfigStore::instance(), &ConfigStore::valueChanged, this,
[this](const QString &key, const QVariant &value)
{
Q_UNUSED(value);
if (isDanmakuEnabledConfigKey(key))
{
updateDanmakuButtonState();
}
if (key == ConfigKeys::PlayerMediaSwitcherMode)
{
applyMediaSwitcherMode();
}
if (SubtitleStyleUtils::isSubtitleStyleKey(key))
{
applySubtitleStyleSettings();
}
});
applyMediaSwitcherMode();
applySubtitleStyleSettings();
this->installEventFilter(this);
}
PlayerView::~PlayerView()
{
disconnect(this, &PlayerView::playerChromeVisibilityChanged, nullptr, nullptr);
beginViewTeardown();
stopAndReport();
}
void PlayerView::prepareForStackLeave()
{
if (m_isViewTearingDown && m_hasReportedStop)
{
return;
}
qDebug() << "[PlayerView] Prepare for stack leave"
<< "| mediaId=" << m_currentMediaId << "| isTearingDown=" << m_isViewTearingDown
<< "| hasReportedStop=" << m_hasReportedStop;
beginViewTeardown();
stopAndReport();
}
void PlayerView::clearMediaSwitcherCache()
{
m_switcherCacheMediaId.clear();
m_switcherCacheReady = false;
m_switcherResumeItems.clear();
m_switcherSeriesSeasons.clear();
m_switcherSeasonEpisodes.clear();
}
bool PlayerView::useHudMediaSwitcher() const
{
return ConfigStore::instance()->get<QString>(ConfigKeys::PlayerMediaSwitcherMode, "sidebar") ==
QStringLiteral("hud");
}
void PlayerView::applyMediaSwitcherMode()
{
const bool hudMode = useHudMediaSwitcher();
if (m_mediaSwitchBtn)
{
m_mediaSwitchBtn->setVisible(hudMode);
}
if (m_rightTrigger)
{
m_rightTrigger->setVisible(!hudMode);
}
if (hudMode)
{
hideRightSidebar();
}
if (!hudMode)
{
hideHudMediaSwitcher();
}
updateMediaSwitcherButton();
}
void PlayerView::updateMediaSwitcherButton()
{
if (!m_mediaSwitchBtn)
{
return;
}
m_mediaSwitchBtn->setProperty("drawerActive",
m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible() && useHudMediaSwitcher());
m_mediaSwitchBtn->style()->unpolish(m_mediaSwitchBtn);
m_mediaSwitchBtn->style()->polish(m_mediaSwitchBtn);
m_mediaSwitchBtn->update();
const QString tooltip = m_isSeriesMode ? tr("Switch season and episode") : tr("Switch media");
m_mediaSwitchBtn->setToolTip(tooltip);
}
QString PlayerView::formatMediaSwitcherPlaybackTitle(const MediaItem &item) const
{
return MediaItemUtils::playbackTitle(item, m_seriesName);
}
void PlayerView::showHudMediaSwitcher()
{
if (m_isViewTearingDown || !useHudMediaSwitcher() || !m_mediaSwitchDrawer)
{
return;
}
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
closeActivePlayerDialog();
if (m_switcherCacheReady && m_switcherCacheMediaId == m_currentMediaId)
{
m_mediaSwitchDrawer->show();
syncHudMediaSwitcherContent();
updateMediaSwitcherButton();
updateOverlayLayout();
showControls();
return;
}
m_mediaSwitchDrawer->setLoadingState(m_isSeriesMode ? (m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName)
: tr("Continue Watching"),
tr("Loading..."), m_isSeriesMode);
m_mediaSwitchDrawer->show();
updateMediaSwitcherButton();
updateOverlayLayout();
showControls();
ensureMediaSwitcherDataLoaded();
}
void PlayerView::hideHudMediaSwitcher()
{
if (m_isViewTearingDown)
{
if (m_mediaSwitchDrawer)
{
m_mediaSwitchDrawer->hide();
}
return;
}
if (!m_mediaSwitchDrawer || !m_mediaSwitchDrawer->isVisible())
{
return;
}
m_mediaSwitchDrawer->hide();
updateMediaSwitcherButton();
updateOverlayLayout();
}
void PlayerView::syncHudMediaSwitcherContent()
{
if (!m_mediaSwitchDrawer)
{
return;
}
if (m_isSeriesMode && !m_seriesId.isEmpty())
{
m_mediaSwitchDrawer->setSeriesData(m_seriesName, m_switcherSeriesSeasons, m_switcherSeasonEpisodes,
m_currentMediaId);
}
else
{
m_mediaSwitchDrawer->setMovieItems(m_switcherResumeItems, m_currentMediaId);
}
updateMediaSwitcherButton();
updateOverlayLayout();
}
bool PlayerView::findAdjacentMediaFromCache(int direction, QString &mediaId, QString &title,
long long &startPositionTicks) const
{
mediaId.clear();
title.clear();
startPositionTicks = 0;
if (!m_switcherCacheReady || m_switcherCacheMediaId != m_currentMediaId)
{
return false;
}
if (m_isSeriesMode && !m_seriesId.isEmpty())
{
const QString currentId = m_currentMediaId;
MediaItem previousItem;
MediaItem nextItem;
bool foundCurrent = false;
for (const MediaItem &season : m_switcherSeriesSeasons)
{
const QList<MediaItem> episodes = m_switcherSeasonEpisodes.value(season.id);
for (const MediaItem &episode : episodes)
{
if (episode.id == currentId)
{
foundCurrent = true;
}
else if (!foundCurrent)
{
previousItem = episode;
}
else if (foundCurrent && nextItem.id.isEmpty())
{
nextItem = episode;
break;
}
}
if (foundCurrent && !nextItem.id.isEmpty())
{
break;
}
}
const MediaItem targetItem = direction < 0 ? previousItem : nextItem;
if (targetItem.id.isEmpty())
{
return false;
}
mediaId = targetItem.id;
title = formatMediaSwitcherPlaybackTitle(targetItem);
startPositionTicks = targetItem.userData.playbackPositionTicks;
return true;
}
if (m_switcherResumeItems.isEmpty())
{
return false;
}
QVector<int> filteredIndexes;
filteredIndexes.reserve(m_switcherResumeItems.size());
int currentFilteredIndex = -1;
const QString currentType = m_currentMediaItem.type;
for (int i = 0; i < m_switcherResumeItems.size(); ++i)
{
const MediaItem &item = m_switcherResumeItems.at(i);
if (!currentType.isEmpty() && item.type != currentType)
{
continue;
}
const int filteredIndex = filteredIndexes.size();
filteredIndexes.append(i);
if (item.id == m_currentMediaId)
{
currentFilteredIndex = filteredIndex;
}
}
if (currentFilteredIndex < 0)
{
return false;
}
const int step = direction < 0 ? -1 : 1;
const int targetFilteredIndex = currentFilteredIndex + step;
if (targetFilteredIndex < 0 || targetFilteredIndex >= filteredIndexes.size())
{
return false;
}
const MediaItem &targetItem = m_switcherResumeItems.at(filteredIndexes.at(targetFilteredIndex));
if (targetItem.id.isEmpty())
{
return false;
}
mediaId = targetItem.id;
title = formatMediaSwitcherPlaybackTitle(targetItem);
startPositionTicks = targetItem.userData.playbackPositionTicks;
return true;
}
bool PlayerView::findNextResumeMediaFromCache(QString &mediaId,
QString &title,
long long &startPositionTicks,
bool skipCurrentSeries) const
{
mediaId.clear();
title.clear();
startPositionTicks = 0;
if (!m_switcherCacheReady || m_switcherCacheMediaId != m_currentMediaId || m_switcherResumeItems.isEmpty())
{
return false;
}
const auto isEligible = [this, skipCurrentSeries](const MediaItem &item)
{
if (item.id.isEmpty() || item.id == m_currentMediaId)
{
return false;
}
if (skipCurrentSeries && !m_seriesId.isEmpty() &&
(item.seriesId == m_seriesId || item.id == m_seriesId))
{
return false;
}
return true;
};
int currentIndex = -1;
for (int i = 0; i < m_switcherResumeItems.size(); ++i)
{
if (m_switcherResumeItems.at(i).id == m_currentMediaId)
{
currentIndex = i;
break;
}
}
const int startIndex = currentIndex >= 0 ? currentIndex + 1 : 0;
for (int i = startIndex; i < m_switcherResumeItems.size(); ++i)
{
const MediaItem &item = m_switcherResumeItems.at(i);
if (!isEligible(item))
{
continue;
}
mediaId = item.id;
title = MediaItemUtils::playbackTitle(item);
if (title.isEmpty())
{
title = item.name;
}
startPositionTicks = item.userData.playbackPositionTicks;
return true;
}
return false;
}
bool PlayerView::isMediaPlaying() const
{
return m_isPlaying;
}
void PlayerView::updatePowerInhibition()
{
const bool shouldHold = !m_isViewTearingDown && !m_hasReportedStop && m_isPlaying && !m_currentMediaId.isEmpty();
if (shouldHold == m_powerInhibitionHeld)
{
return;
}
if (shouldHold)
{
m_powerInhibitionHeld = PowerInhibitUtils::acquirePlaybackInhibition(QStringLiteral("qEmby video playback"));
}
else
{
PowerInhibitUtils::releasePlaybackInhibition();
m_powerInhibitionHeld = false;
}
}
QCoro::Task<void> PlayerView::autoPlayNextMediaIfEnabled()
{
if (m_isViewTearingDown || m_hasReportedStop || m_currentMediaId.isEmpty())
{
co_return;
}
if (!ConfigStore::instance()->get<bool>(ConfigKeys::PlayerContinuousPlay, true))
{
qInfo().noquote() << "[PlayerView] Continuous playback disabled"
<< "| mediaId:" << m_currentMediaId;
co_return;
}
if (m_autoPlayAdvanceInProgress)
{
qDebug().noquote() << "[PlayerView] Continuous playback already advancing"
<< "| mediaId:" << m_currentMediaId;
co_return;
}
m_autoPlayAdvanceInProgress = true;
QPointer<PlayerView> guard(this);
const QString finishedMediaId = m_currentMediaId;
const bool finishedSeriesMode = m_isSeriesMode && !m_seriesId.isEmpty();
qInfo().noquote() << "[PlayerView] Continuous playback resolving next item"
<< "| mediaId:" << finishedMediaId
<< "| seriesMode:" << finishedSeriesMode
<< "| seriesId:" << m_seriesId;
if (!m_switcherCacheReady || m_switcherCacheMediaId != finishedMediaId)
{
co_await ensureMediaSwitcherDataLoaded();
if (!guard || guard->m_currentMediaId != finishedMediaId)
{
co_return;
}
}
QString targetId;
QString targetTitle;
long long startTicks = 0;
bool hasTarget = guard->findAdjacentMediaFromCache(1, targetId, targetTitle, startTicks);
if (!hasTarget)
{
hasTarget = guard->findNextResumeMediaFromCache(targetId, targetTitle, startTicks, finishedSeriesMode);
}
if (!hasTarget)
{
qInfo().noquote() << "[PlayerView] Continuous playback has no next item"
<< "| mediaId:" << finishedMediaId
<< "| seriesMode:" << finishedSeriesMode;
guard->m_autoPlayAdvanceInProgress = false;
co_return;
}
qInfo().noquote() << "[PlayerView] Continuous playback switching item"
<< "| from:" << finishedMediaId
<< "| to:" << targetId
<< "| title:" << targetTitle
<< "| startTicks:" << startTicks;
co_await guard->switchFromMediaSwitcher(targetId, targetTitle, startTicks);
if (guard && !guard->m_hasReportedStop && guard->m_currentMediaId == finishedMediaId)
{
guard->m_autoPlayAdvanceInProgress = false;
}
}
QCoro::Task<void> PlayerView::requestIntroDBSegments()
{
QPointer<IntroDBService> introDB = m_core ? m_core->introDBService() : nullptr;
QPointer<MediaService> mediaService = m_core ? m_core->mediaService() : nullptr;
if (m_segmentsRequested || !introDB)
co_return;
const bool skipIntro = ConfigStore::instance()->get<bool>(ConfigKeys::PlayerSkipIntro, false);
const bool skipOutro = ConfigStore::instance()->get<bool>(ConfigKeys::PlayerSkipOutro, false);
if (!skipIntro && !skipOutro)
{
qInfo("IntroDB: both skip intro and skip outro disabled, skipping");
co_return;
}
const QString currentMediaId = m_currentMediaId;
const MediaItem currentItem = m_currentMediaItem;
const int season = currentItem.parentIndexNumber;
const int episode = currentItem.indexNumber;
if (season <= 0 || episode <= 0)
{
qInfo("IntroDB: invalid season/episode: S%02dE%02d", season, episode);
co_return;
}
m_segmentsRequested = true;
QPointer<PlayerView> safeThis(this);
const auto providerIdValue = [](const QVariantMap &providerIds, std::initializer_list<const char *> keys)
{
for (const char *key : keys)
{
const QString value = providerIds.value(QString::fromLatin1(key)).toString().trimmed();
if (!value.isEmpty())
{
return value;
}
}
return QString();
};
const QString episodeImdbId = providerIdValue(currentItem.providerIds, {"Imdb", "IMDb", "imdb", "imdbid"});
QString imdbId;
if (mediaService && !currentItem.seriesId.trimmed().isEmpty())
{
try
{
const MediaItem seriesDetail = co_await mediaService->getItemDetail(currentItem.seriesId);
if (!safeThis || safeThis->m_currentMediaId != currentMediaId)
co_return;
imdbId = providerIdValue(seriesDetail.providerIds, {"Imdb", "IMDb", "imdb", "imdbid"});
if (!imdbId.isEmpty())
{
qInfo("IntroDB: using series IMDb ID imdb=%s seriesId=%s", qPrintable(imdbId),
qPrintable(currentItem.seriesId));
}
}
catch (const std::exception &e)
{
if (!safeThis || safeThis->m_currentMediaId != currentMediaId)
co_return;
qWarning("IntroDB: failed to fetch series detail for IMDb ID: %s", e.what());
}
}
if (imdbId.isEmpty())
{
imdbId = episodeImdbId;
if (!imdbId.isEmpty())
{
qInfo("IntroDB: using episode IMDb ID fallback imdb=%s", qPrintable(imdbId));
}
}
if (imdbId.isEmpty())
{
qInfo("IntroDB: no IMDb ID in providerIds, episodeKeys=%s",
qPrintable(currentItem.providerIds.keys().join(", ")));
co_return;
}
qInfo("IntroDB: requesting segments imdb=%s S%02dE%02d", qPrintable(imdbId), season, episode);
if (!introDB)
co_return;
const IntroDBService::EpisodeSegments segments = co_await introDB->fetchSegments(imdbId, season, episode);
if (!safeThis || safeThis->m_currentMediaId != currentMediaId)
co_return;
safeThis->m_episodeSegments = segments;
qInfo("IntroDB: segments fetched=%d notFound=%d intro=[%.1f-%.1f] outro=[%.1f-%.1f]",
safeThis->m_episodeSegments.fetched, safeThis->m_episodeSegments.notFound,
safeThis->m_episodeSegments.intro.startSec, safeThis->m_episodeSegments.intro.endSec,
safeThis->m_episodeSegments.outro.startSec, safeThis->m_episodeSegments.outro.endSec);
}
void PlayerView::checkAndSkipSegment(double position)
{
if (!m_episodeSegments.fetched || m_episodeSegments.notFound)
return;
const bool skipIntro = ConfigStore::instance()->get<bool>(ConfigKeys::PlayerSkipIntro, false);
const bool skipOutro = ConfigStore::instance()->get<bool>(ConfigKeys::PlayerSkipOutro, false);
const auto skipIfInRange =
[this, position](const IntroDBService::SegmentInfo &seg, const char *label, bool enabled, bool &flag)
{
if (!enabled || flag || !m_mpvWidget || !std::isfinite(seg.startSec) || !std::isfinite(seg.endSec) ||
seg.startSec < 0 || seg.endSec <= seg.startSec)
return;
if (position >= seg.startSec && position < seg.endSec)
{
qInfo("IntroDB: skipping %s [%.1f-%.1f] at pos=%.1f", label, seg.startSec, seg.endSec, position);
m_mpvWidget->seek(seg.endSec);
flag = true;
}
};
skipIfInRange(m_episodeSegments.intro, "intro", skipIntro, m_introSkipped);
skipIfInRange(m_episodeSegments.outro, "outro", skipOutro, m_outroSkipped);
}
void PlayerView::pausePlayback()
{
if (m_mpvWidget)
m_mpvWidget->pause();
}
void PlayerView::resumePlayback()
{
if (!m_mpvWidget || m_currentMediaId.isEmpty())
return;
QString newStreamUrl = m_originalStreamUrl;
if (m_currentSourceInfoVar.isValid())
{
MediaSourceInfo sourceInfo;
if (m_currentSourceInfoVar.canConvert<PlayerLaunchContext>())
{
sourceInfo = m_currentSourceInfoVar.value<PlayerLaunchContext>().selectedSource;
}
else if (m_currentSourceInfoVar.canConvert<MediaSourceInfo>())
{
sourceInfo = m_currentSourceInfoVar.value<MediaSourceInfo>();
}
QString directUrl = m_core->mediaService()->getStreamUrl(m_currentMediaId, sourceInfo);
if (!directUrl.isEmpty())
{
newStreamUrl = directUrl;
}
}
m_pendingSeekSeconds = m_currentPosition;
m_isBuffering = true;
updateLoadingState();
const QString activeServerId = m_core->serverManager()->activeProfile().id;
m_mpvWidget->loadMedia(newStreamUrl, activeServerId);
m_mpvWidget->play();
}
QPushButton *PlayerView::createHudButton(const QString &iconPath, const QSize &size)
{
auto *btn = new QPushButton(this);
btn->setProperty("class", "player-hud-btn");
btn->setIcon(QIcon(iconPath));
btn->setIconSize(size);
btn->setCursor(Qt::PointingHandCursor);
btn->setFocusPolicy(Qt::NoFocus);
btn->setFixedSize(size.width() + 16, size.height() + 16);
return btn;
}
void PlayerView::setupUi()
{
m_mpvWidget = new MpvWidget(this);
m_mpvWidget->setAutoFillBackground(true);
m_mpvWidget->installEventFilter(this);
m_mpvWidget->setMouseTracking(true);
m_nativeDanmakuOverlay = new NativeDanmakuOverlay(m_mpvWidget->controller(), this);
m_danmakuController = new PlayerDanmakuController(m_core, m_mpvWidget, m_nativeDanmakuOverlay, this);
connect(m_danmakuController, &PlayerDanmakuController::toastRequested, this,
[this](const QString &message) { showToast(message); });
connect(m_danmakuController, &PlayerDanmakuController::stateChanged, this, &PlayerView::updateDanmakuButtonState);
m_loadingOverlay = new LoadingOverlay(this);
m_loadingOverlay->setObjectName("playerLoadingOverlay");
m_loadingOverlay->setImmersive(true);
m_loadingOverlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
connect(m_mpvWidget->controller(), &MpvController::fileLoaded, this,
[this]()
{
m_isBuffering = false;
updateLoadingState();
applySubtitleStyleSettings();
});
m_osdLayer = new PlayerOsdLayer(this);
setupRightSidebar();
m_rightTrigger = new QWidget(this);
m_rightTrigger->setFixedWidth(15);
m_rightTrigger->setCursor(Qt::PointingHandCursor);
m_rightTrigger->setAttribute(Qt::WA_TransparentForMouseEvents, false);
m_rightTrigger->installEventFilter(this);
m_logoLabel = new QLabel(this);
m_logoLabel->setObjectName("playerLogoLabel");
m_logoLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
m_logoLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_logoLabel->hide();
m_networkSpeedLabel = new QLabel("0.0 KB/s", this);
m_networkSpeedLabel->setObjectName("playerNetworkSpeed");
m_networkSpeedLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
m_networkSpeedLabel->setAlignment(Qt::AlignRight | Qt::AlignTop);
m_topHUD = new QWidget(this);
m_topHUD->setObjectName("playerTopHUD");
m_topHUD->setFixedHeight(32);
m_topHUD->installEventFilter(this);
auto *topLayout = new QHBoxLayout(m_topHUD);
topLayout->setContentsMargins(0, 0, 0, 0);
topLayout->setSpacing(0);
m_backBtn = new QPushButton(m_topHUD);
m_backBtn->setObjectName("hud-back-btn");
m_backBtn->setIcon(QIcon(":/svg/player/back.svg"));
m_backBtn->setIconSize(QSize(16, 16));
m_backBtn->setFixedSize(38, 28);
m_backBtn->setFocusPolicy(Qt::NoFocus);
m_backBtn->setToolTip(tr("Back"));
connect(m_backBtn, &QPushButton::clicked, this, &PlayerView::onBackClicked);
m_titleLabel = new QLabel(m_topHUD);
m_titleLabel->setObjectName("playerTitleLabel");
m_titleLabel->setMinimumWidth(0);
m_titleLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
m_titleLabel->setAlignment(Qt::AlignCenter);
m_titleLabel->installEventFilter(this);
auto *sysLayout = new QHBoxLayout();
sysLayout->setSpacing(0);
sysLayout->setContentsMargins(0, 0, 0, 0);
m_minBtn = new QPushButton(m_topHUD);
m_minBtn->setObjectName("hud-min-btn");
m_minBtn->setIcon(QIcon(":/svg/player/min.svg"));
m_minBtn->setIconSize(QSize(12, 12));
m_minBtn->setFixedSize(38, 28);
m_minBtn->setFocusPolicy(Qt::NoFocus);
m_minBtn->setToolTip(tr("Minimize"));
m_maxBtn = new QPushButton(m_topHUD);
m_maxBtn->setObjectName("hud-max-btn");
m_maxBtn->setIcon(QIcon(":/svg/player/max.svg"));
m_maxBtn->setIconSize(QSize(12, 12));
m_maxBtn->setFixedSize(38, 28);
m_maxBtn->setFocusPolicy(Qt::NoFocus);
m_maxBtn->setToolTip(tr("Maximize"));
m_closeBtn = new QPushButton(m_topHUD);
m_closeBtn->setObjectName("hud-close-btn");
m_closeBtn->setIcon(QIcon(":/svg/player/close.svg"));
m_closeBtn->setIconSize(QSize(12, 12));
m_closeBtn->setFixedSize(38, 28);
m_closeBtn->setFocusPolicy(Qt::NoFocus);
m_closeBtn->setToolTip(tr("Close"));
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
m_minBtn->hide();
m_maxBtn->hide();
m_closeBtn->hide();
#else
connect(m_minBtn, &QPushButton::clicked, this, [this]() { window()->showMinimized(); });
connect(m_maxBtn, &QPushButton::clicked, this,
[this]()
{
if (window()->isMaximized())
{
window()->showNormal();
}
else
{
window()->showMaximized();
}
});
connect(m_closeBtn, &QPushButton::clicked, this,
[this]()
{
beginViewTeardown();
stopAndReport();
window()->close();
});
#endif
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
delete sysLayout;
#else
sysLayout->addWidget(m_minBtn, 0, Qt::AlignTop);
sysLayout->addWidget(m_maxBtn, 0, Qt::AlignTop);
sysLayout->addWidget(m_closeBtn, 0, Qt::AlignTop);
#endif
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
auto *macLeftTitlebarArea = new QWidget(m_topHUD);
macLeftTitlebarArea->setObjectName("playerMacTitlebarLeftArea");
macLeftTitlebarArea->setFixedWidth(118);
auto *macRightTitlebarArea = new QWidget(m_topHUD);
macRightTitlebarArea->setObjectName("playerMacTitlebarRightArea");
macRightTitlebarArea->setFixedWidth(118);
auto *macRightLayout = new QHBoxLayout(macRightTitlebarArea);
macRightLayout->setContentsMargins(0, 0, 0, 0);
macRightLayout->setSpacing(0);
macRightLayout->addStretch();
macRightLayout->addWidget(m_backBtn, 0, Qt::AlignTop);
topLayout->addWidget(macLeftTitlebarArea, 0, Qt::AlignTop);
topLayout->addWidget(m_titleLabel, 1, Qt::AlignTop);
topLayout->addWidget(macRightTitlebarArea, 0, Qt::AlignTop);
#else
topLayout->addWidget(m_backBtn, 0, Qt::AlignTop);
topLayout->addWidget(m_titleLabel, 1, Qt::AlignTop);
topLayout->addLayout(sysLayout, 0);
#endif
m_bottomHUD = new QWidget(this);
m_bottomHUD->setObjectName("playerBottomHUD");
m_bottomHUD->setFixedHeight(m_bottomHudBaseHeight);
m_bottomHUD->installEventFilter(this);
auto *bottomLayout = new QVBoxLayout(m_bottomHUD);
bottomLayout->setContentsMargins(32, 10, 32, 16);
bottomLayout->setSpacing(8);
m_mediaSwitchDrawer = new PlayerMediaSwitcherPanel(m_core, m_bottomHUD);
m_mediaSwitchDrawer->hide();
connect(m_mediaSwitchDrawer, &PlayerMediaSwitcherPanel::playRequested, this,
[this](QString mediaId, QString title, long long startPositionTicks) -> QCoro::Task<void>
{ co_await switchFromMediaSwitcher(mediaId, title, startPositionTicks); });
bottomLayout->addWidget(m_mediaSwitchDrawer);
auto *progressLayout = new QHBoxLayout();
progressLayout->setSpacing(16);
m_currentTimeLabel = new QLabel("00:00", m_bottomHUD);
m_currentTimeLabel->setObjectName("playerTimeLabel");
m_progressSlider = new ModernSlider(Qt::Horizontal, m_bottomHUD);
m_progressSlider->setObjectName("playerSlider");
m_progressSlider->setFocusPolicy(Qt::NoFocus);
m_progressSlider->setCursor(Qt::PointingHandCursor);
m_progressSlider->setSingleStep(1);
m_progressSlider->setPageStep(10);
m_progressSlider->setRange(0, 1000);
m_totalTimeLabel = new QLabel("00:00", m_bottomHUD);
m_totalTimeLabel->setObjectName("playerTimeLabel");
progressLayout->addWidget(m_currentTimeLabel);
progressLayout->addWidget(m_progressSlider, 1);
progressLayout->addWidget(m_totalTimeLabel);
auto *controlLayout = new QHBoxLayout();
controlLayout->setSpacing(12);
m_prevMediaBtn = createHudButton(":/svg/player/prev.svg");
m_prevMediaBtn->setToolTip(tr("Previous"));
m_rewindBtn = createHudButton(":/svg/player/fast-rewind.svg");
m_rewindBtn->setToolTip(tr("Rewind"));
m_playPauseBtn = createHudButton(":/svg/player/pause.svg", QSize(36, 36));
m_playPauseBtn->setToolTip(tr("Play/Pause"));
m_forwardBtn = createHudButton(":/svg/player/fast-forward.svg");
m_forwardBtn->setToolTip(tr("Forward"));
m_nextMediaBtn = createHudButton(":/svg/player/next.svg");
m_nextMediaBtn->setToolTip(tr("Next"));
m_volumeBtn = createHudButton(":/svg/player/volume.svg");
m_volumeBtn->setToolTip(tr("Mute/Unmute"));
m_volumeBtn->installEventFilter(this);
m_volumeSlider = new ModernSlider(Qt::Horizontal, m_bottomHUD);
m_volumeSlider->setObjectName("volumeSlider");
m_volumeSlider->setFocusPolicy(Qt::NoFocus);
m_volumeSlider->setCursor(Qt::PointingHandCursor);
m_volumeSlider->setRange(0, 100);
m_volumeSlider->setValue(100);
m_volumeSlider->setFixedWidth(80);
m_volumeSlider->installEventFilter(this);
m_speedBtn = new QPushButton("1.0X", m_bottomHUD);
m_speedBtn->setObjectName("playerSpeedBtn");
m_speedBtn->setCursor(Qt::PointingHandCursor);
m_speedBtn->setFocusPolicy(Qt::NoFocus);
m_speedBtn->setFixedHeight(40);
m_speedBtn->setToolTip(tr("Playback Speed"));
m_mediaSwitchBtn = createHudButton(":/svg/player/media-switcher.svg");
m_mediaSwitchBtn->setObjectName("playerMediaSwitchBtn");
m_mediaSwitchBtn->setToolTip(tr("Media Switcher"));
m_audioBtn = createHudButton(":/svg/player/audio.svg");
m_audioBtn->setToolTip(tr("Audio Track"));
m_subtitleBtn = createHudButton(":/svg/player/subtitle.svg");
m_subtitleBtn->setToolTip(tr("Subtitles"));
m_danmakuBtn = createHudButton(":/svg/player/danmaku.svg");
m_danmakuBtn->setToolTip(tr("Danmaku"));
m_settingsBtn = createHudButton(":/svg/player/settings.svg");
m_settingsBtn->setToolTip(tr("Settings"));
m_scaleBtn = createHudButton(":/svg/player/scale-fit.svg");
m_scaleBtn->setToolTip(tr("Aspect Ratio"));
m_fullscreenBtn = createHudButton(":/svg/player/fullscreen.svg");
m_fullscreenBtn->setToolTip(tr("Fullscreen"));
m_toastLabel = new QLabel("", m_bottomHUD);
m_toastLabel->setObjectName("playerToastLabel");
m_toastLabel->setAlignment(Qt::AlignCenter);
m_toastLabel->hide();
controlLayout->addWidget(m_prevMediaBtn);
controlLayout->addWidget(m_rewindBtn);
controlLayout->addWidget(m_playPauseBtn);
controlLayout->addWidget(m_forwardBtn);
controlLayout->addWidget(m_nextMediaBtn);
controlLayout->addSpacing(20);
controlLayout->addWidget(m_volumeBtn);
controlLayout->addWidget(m_volumeSlider);
controlLayout->addStretch();
controlLayout->addWidget(m_toastLabel);
controlLayout->addStretch();
controlLayout->addWidget(m_mediaSwitchBtn);
controlLayout->addWidget(m_speedBtn);
controlLayout->addWidget(m_audioBtn);
controlLayout->addWidget(m_subtitleBtn);
controlLayout->addWidget(m_danmakuBtn);
controlLayout->addWidget(m_settingsBtn);
controlLayout->addWidget(m_scaleBtn);
controlLayout->addWidget(m_fullscreenBtn);
bottomLayout->addLayout(progressLayout);
bottomLayout->addLayout(controlLayout);
m_statisticsOverlay = new PlayerStatisticsOverlay(this);
m_statisticsOverlay->setObjectName("playerStatisticsOverlay");
m_statisticsOverlay->hide();
m_topOpacity = new QGraphicsOpacityEffect(m_topHUD);
m_bottomOpacity = new QGraphicsOpacityEffect(m_bottomHUD);
m_logoOpacity = new QGraphicsOpacityEffect(m_logoLabel);
m_speedOpacity = new QGraphicsOpacityEffect(m_networkSpeedLabel);
m_topHUD->setGraphicsEffect(m_topOpacity);
m_bottomHUD->setGraphicsEffect(m_bottomOpacity);
m_logoLabel->setGraphicsEffect(m_logoOpacity);
m_networkSpeedLabel->setGraphicsEffect(m_speedOpacity);
m_fadeGroup = new QParallelAnimationGroup(this);
auto *topAnim = new QPropertyAnimation(m_topOpacity, "opacity", this);
topAnim->setDuration(250);
auto *bottomAnim = new QPropertyAnimation(m_bottomOpacity, "opacity", this);
bottomAnim->setDuration(250);
auto *logoAnim = new QPropertyAnimation(m_logoOpacity, "opacity", this);
logoAnim->setDuration(250);
auto *speedAnim = new QPropertyAnimation(m_speedOpacity, "opacity", this);
speedAnim->setDuration(250);
m_fadeGroup->addAnimation(topAnim);
m_fadeGroup->addAnimation(bottomAnim);
m_fadeGroup->addAnimation(logoAnim);
m_fadeGroup->addAnimation(speedAnim);
connect(m_playPauseBtn, &QPushButton::clicked, this, &PlayerView::togglePlayPause);
connect(m_progressSlider, &QSlider::sliderMoved, this, &PlayerView::onSliderMoved);
connect(m_rewindBtn, &QPushButton::pressed, this,
[this]()
{
if (!m_longPressHandler)
{
double step = ConfigStore::instance()->get<QString>(ConfigKeys::PlayerSeekStep, "10").toDouble();
seekRelative(-step);
return;
}
bool isHudVisible = (m_topOpacity && m_topOpacity->opacity() > 0.0);
m_longPressHandler->startKeyLongPress(-1, !isHudVisible);
});
connect(m_forwardBtn, &QPushButton::pressed, this,
[this]()
{
if (!m_longPressHandler)
{
double step = ConfigStore::instance()->get<QString>(ConfigKeys::PlayerSeekStep, "10").toDouble();
seekRelative(step);
return;
}
bool isHudVisible = (m_topOpacity && m_topOpacity->opacity() > 0.0);
m_longPressHandler->startKeyLongPress(1, !isHudVisible);
});
connect(m_rewindBtn, &QPushButton::released, this,
[this]()
{
if (m_longPressHandler)
{
m_longPressHandler->stopKeyLongPress();
}
});
connect(m_forwardBtn, &QPushButton::released, this,
[this]()
{
if (m_longPressHandler)
{
m_longPressHandler->stopKeyLongPress();
}
});
connect(m_prevMediaBtn, &QPushButton::clicked, this,
[this]() -> QCoro::Task<void>
{
QString targetId;
QString targetTitle;
long long startTicks = 0;
if (!findAdjacentMediaFromCache(-1, targetId, targetTitle, startTicks))
{
showToast(m_isSeriesMode ? tr("No previous episode") : tr("No previous media"));
ensureMediaSwitcherDataLoaded();
co_return;
}
co_await switchFromMediaSwitcher(targetId, targetTitle, startTicks);
});
connect(m_nextMediaBtn, &QPushButton::clicked, this,
[this]() -> QCoro::Task<void>
{
QString targetId;
QString targetTitle;
long long startTicks = 0;
if (!findAdjacentMediaFromCache(1, targetId, targetTitle, startTicks))
{
showToast(m_isSeriesMode ? tr("No next episode") : tr("No next media"));
ensureMediaSwitcherDataLoaded();
co_return;
}
co_await switchFromMediaSwitcher(targetId, targetTitle, startTicks);
});
connect(m_volumeBtn, &QPushButton::clicked, this, &PlayerView::toggleMute);
connect(m_volumeSlider, &QSlider::valueChanged, this, &PlayerView::onVolumeSliderMoved);
connect(m_speedBtn, &QPushButton::clicked, this, &PlayerView::showSpeedMenu);
connect(m_mediaSwitchBtn, &QPushButton::clicked, this,
[this]()
{
if (m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible())
{
hideHudMediaSwitcher();
return;
}
showHudMediaSwitcher();
});
connect(m_audioBtn, &QPushButton::clicked, this, &PlayerView::showAudioMenu);
connect(m_subtitleBtn, &QPushButton::clicked, this, &PlayerView::showSubtitleMenu);
connect(m_danmakuBtn, &QPushButton::clicked, this, &PlayerView::showDanmakuMenu);
connect(m_settingsBtn, &QPushButton::clicked, this, &PlayerView::showSettingsMenu);
connect(m_scaleBtn, &QPushButton::clicked, this, &PlayerView::cycleVideoScale);
connect(m_fullscreenBtn, &QPushButton::clicked, this, &PlayerView::toggleFullscreenWindow);
connect(m_mpvWidget, &MpvWidget::positionChanged, this, &PlayerView::onPositionChanged);
connect(m_mpvWidget, &MpvWidget::durationChanged, this, &PlayerView::onDurationChanged);
connect(m_mpvWidget, &MpvWidget::playbackStateChanged, this, &PlayerView::onPlaybackStateChanged);
connect(m_mpvWidget, &MpvWidget::relayActiveChanged, this,
[this](bool active)
{
m_useRelayNetworkSpeed = active;
if (!active && m_networkSpeedLabel)
{
m_effectiveNetworkSpeed = 0;
m_networkSpeedLabel->setText(formatDataRateValue(0));
}
});
connect(m_mpvWidget, &MpvWidget::networkSpeedChanged, this,
[this](qint64 bytesPerSecond)
{
if (m_useRelayNetworkSpeed && m_networkSpeedLabel)
{
m_effectiveNetworkSpeed = bytesPerSecond;
m_networkSpeedLabel->setText(formatDataRateValue(bytesPerSecond));
}
});
connect(m_mpvWidget->controller(), &MpvController::propertyChanged, this, &PlayerView::onMpvPropertyChanged);
connect(m_mpvWidget->controller(), &MpvController::endOfFile, this,
[this](const QString &reason)
{
qDebug() << "[PlayerView] MPV end of file"
<< "| reason=" << reason;
m_isPlaybackFinished = (reason == QLatin1String("eof"));
m_isPlaying = false;
m_isBuffering = false;
m_isSeeking = false;
updatePowerInhibition();
updateLoadingState();
if (m_playPauseBtn)
{
m_playPauseBtn->setIcon(QIcon(":/svg/player/play.svg"));
}
if (m_isPlaybackFinished)
{
autoPlayNextMediaIfEnabled();
}
});
setScaleIcon();
updateDanmakuButtonState();
updateMediaSwitcherButton();
m_rightSidebar->raise();
if (m_rightSidebarShadow)
m_rightSidebarShadow->stackUnder(m_rightSidebar);
}
void PlayerView::updateLoadingState()
{
if (m_isViewTearingDown)
{
if (m_loadingOverlay)
{
m_loadingOverlay->forceStop();
}
return;
}
if (m_isBuffering || m_isSeeking)
{
m_loadingOverlay->start();
m_topHUD->raise();
m_bottomHUD->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();
}
else
{
m_loadingOverlay->stop();
}
}
QString PlayerView::formatDanmakuProviderLabel(QString provider) const
{
provider = provider.trimmed();
if (provider == QLatin1String("local-file"))
{
return tr("Local File");
}
if (provider == QLatin1String("dandanplay"))
{
return tr("DandanPlay");
}
return provider.isEmpty() ? tr("Unknown Source") : provider;
}
QString PlayerView::formatDanmakuSourceServiceLabel(QString provider, QString serverName) const
{
provider = provider.trimmed();
serverName = serverName.trimmed();
if (!serverName.isEmpty())
{
return tr("Server: %1").arg(serverName);
}
return formatDanmakuProviderLabel(provider);
}
QString PlayerView::buildDanmakuSummaryText() const
{
if (!m_danmakuController)
{
return tr("No danmaku loaded");
}
if (m_danmakuController->isLoading())
{
return tr("Loading danmaku...");
}
if (!m_danmakuController->hasPreparedDanmaku())
{
return tr("No danmaku loaded");
}
const QString sourceTitle = m_danmakuController->sourceTitle().trimmed().isEmpty()
? tr("Unknown Danmaku")
: m_danmakuController->sourceTitle().trimmed();
const QString countText = m_danmakuController->commentCount() > 0
? tr("%1 comments").arg(m_danmakuController->commentCount())
: tr("Comment count unavailable");
const QString sourceService =
formatDanmakuSourceServiceLabel(m_danmakuController->sourceProvider(), m_danmakuController->sourceServerName());
return tr("Current: %1 | %2 | %3").arg(sourceTitle, countText, sourceService);
}
QString PlayerView::buildDanmakuTooltipText() const
{
if (!m_danmakuController)
{
return tr("Danmaku");
}
if (m_danmakuController->isLoading())
{
return tr("Danmaku (Loading...)");
}
if (!m_danmakuController->isDanmakuVisible())
{
return tr("Danmaku (Hidden)");
}
if (!m_danmakuController->hasPreparedDanmaku())
{
return tr("Danmaku");
}
const QString sourceTitle = m_danmakuController->sourceTitle().trimmed().isEmpty()
? tr("Unknown Danmaku")
: m_danmakuController->sourceTitle().trimmed();
const QString countText = m_danmakuController->commentCount() > 0
? tr("%1 comments").arg(m_danmakuController->commentCount())
: tr("Comment count unavailable");
const QString serverName = m_danmakuController->sourceServerName().trimmed();
if (!serverName.isEmpty())
{
return tr("Danmaku\nSource: %1\nServer: %2\nProvider: %3\nComments: %4")
.arg(sourceTitle, serverName, formatDanmakuProviderLabel(m_danmakuController->sourceProvider()), countText);
}
return tr("Danmaku\nSource: %1\nProvider: %2\nComments: %3")
.arg(sourceTitle, formatDanmakuProviderLabel(m_danmakuController->sourceProvider()), countText);
}
void PlayerView::closeActivePlayerDialog()
{
if (m_activePlayerDialog)
{
m_activePlayerDialog->close();
m_activePlayerDialog = nullptr;
}
}
void PlayerView::trackPlayerDialog(PlayerOverlayDialog *dialog)
{
if (!dialog)
{
return;
}
closeActivePlayerDialog();
m_activePlayerDialog = dialog;
connect(dialog, &PlayerOverlayDialog::finished, this,
[this, dialog](int)
{
if (m_activePlayerDialog == dialog)
{
m_activePlayerDialog = nullptr;
if (!m_isViewTearingDown)
{
showControls();
}
}
});
showControls();
dialog->open();
}
bool PlayerView::shouldShowDanmakuHudControls() const
{
if (m_danmakuController)
{
return m_danmakuController->isDanmakuEnabled();
}
return ConfigStore::instance()->get<bool>(ConfigKeys::PlayerDanmakuEnabled, false);
}
void PlayerView::updateDanmakuButtonState()
{
if (m_isViewTearingDown || !m_danmakuBtn)
{
return;
}
const bool showDanmakuControls = shouldShowDanmakuHudControls();
m_danmakuBtn->setVisible(showDanmakuControls);
if (!showDanmakuControls || !m_danmakuController)
{
return;
}
const bool visible = m_danmakuController->isDanmakuVisible();
const bool loading = m_danmakuController->isLoading();
const bool loaded = m_danmakuController->hasDanmakuTrack() || m_danmakuController->hasPreparedDanmaku();
m_danmakuBtn->setEnabled(true);
if (loading)
{
m_danmakuBtn->setToolTip(tr("Danmaku (Loading...)"));
}
else if (!visible)
{
m_danmakuBtn->setToolTip(tr("Danmaku (Hidden)"));
}
else if (loaded && !m_danmakuController->sourceTitle().isEmpty())
{
m_danmakuBtn->setToolTip(buildDanmakuTooltipText());
}
else if (loaded)
{
m_danmakuBtn->setToolTip(buildDanmakuTooltipText());
}
else
{
m_danmakuBtn->setToolTip(tr("Danmaku"));
}
}
void PlayerView::showCenteredPopup(QWidget *popup, QPushButton *btn)
{
if (m_isViewTearingDown)
{
if (popup)
{
popup->deleteLater();
}
return;
}
hideHudMediaSwitcher();
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
m_activePopup = popup;
connect(popup, &QObject::destroyed, this,
[this, popup]()
{
if (m_activePopup == popup)
{
m_activePopup = nullptr;
}
});
popup->adjustSize();
QPoint btnPos = btn->mapTo(this, QPoint(btn->width() / 2, 0));
int mx = btnPos.x() - popup->sizeHint().width() / 2;
int my = btnPos.y() - popup->sizeHint().height() - 10;
if (mx < 10)
{
mx = 10;
}
const int maxX = width() - popup->sizeHint().width() - 10;
if (mx > maxX)
{
mx = qMax(10, maxX);
}
if (my < 10)
{
my = 10;
}
popup->move(mx, my);
popup->show();
popup->raise();
showControls();
}
void PlayerView::populateRightSidebarFromCache()
{
if (!m_resumeList || !m_sidebarTitleLabel)
{
return;
}
m_resumeList->clear();
if (m_isSeriesMode && !m_seriesId.isEmpty())
{
m_sidebarTitleLabel->setText(m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName);
QListWidgetItem *scrollTarget = nullptr;
for (const MediaItem &season : m_switcherSeriesSeasons)
{
auto *seasonHeader = new QListWidgetItem(season.name);
seasonHeader->setFlags(Qt::NoItemFlags);
QFont headerFont = m_resumeList->font();
headerFont.setBold(true);
headerFont.setPixelSize(13);
seasonHeader->setFont(headerFont);
seasonHeader->setForeground(QColor(140, 140, 140));
m_resumeList->addItem(seasonHeader);
const auto episodes = m_switcherSeasonEpisodes.value(season.id);
for (const MediaItem &episode : episodes)
{
QString label = tr(" S%1E%2 %3")
.arg(episode.parentIndexNumber, 2, 10, QChar('0'))
.arg(episode.indexNumber, 2, 10, QChar('0'))
.arg(episode.name);
auto *epItem = new QListWidgetItem(label);
epItem->setData(Qt::UserRole, episode.id);
epItem->setData(Qt::UserRole + 1, episode.userData.playbackPositionTicks);
epItem->setData(Qt::UserRole + 2, formatMediaSwitcherPlaybackTitle(episode));
epItem->setToolTip(formatMediaSwitcherPlaybackTitle(episode));
if (episode.id == m_currentMediaId)
{
QFont boldFont = m_resumeList->font();
boldFont.setBold(true);
epItem->setFont(boldFont);
epItem->setForeground(QColor(100, 180, 255));
scrollTarget = epItem;
}
m_resumeList->addItem(epItem);
}
}
if (m_resumeList->count() == 0)
{
auto *empty = new QListWidgetItem(tr("No episodes found."));
empty->setFlags(Qt::NoItemFlags);
m_resumeList->addItem(empty);
return;
}
if (scrollTarget)
{
m_resumeList->setCurrentItem(scrollTarget);
m_resumeList->scrollToItem(scrollTarget, QAbstractItemView::PositionAtCenter);
}
return;
}
m_sidebarTitleLabel->setText(tr("Continue Watching"));
for (const MediaItem &item : m_switcherResumeItems)
{
const QString displayTitle = formatMediaSwitcherPlaybackTitle(item);
auto *listItem = new QListWidgetItem(displayTitle);
listItem->setData(Qt::UserRole, item.id);
listItem->setData(Qt::UserRole + 1, item.userData.playbackPositionTicks);
listItem->setData(Qt::UserRole + 2, displayTitle);
listItem->setToolTip(displayTitle);
m_resumeList->addItem(listItem);
}
if (m_switcherResumeItems.isEmpty())
{
auto *empty = new QListWidgetItem(tr("No active media found."));
empty->setFlags(Qt::NoItemFlags);
m_resumeList->addItem(empty);
}
}
QCoro::Task<void> PlayerView::ensureMediaSwitcherDataLoaded()
{
if (m_currentMediaId.isEmpty())
{
co_return;
}
if (m_switcherCacheReady && m_switcherCacheMediaId == m_currentMediaId)
{
qDebug().noquote() << "[PlayerView] Reuse cached media switcher data"
<< "| mediaId:" << m_currentMediaId << "| seriesMode:" << m_isSeriesMode;
if (useHudMediaSwitcher() && m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible())
{
syncHudMediaSwitcherContent();
}
if (!useHudMediaSwitcher() && m_isRightSidebarVisible)
{
populateRightSidebarFromCache();
}
co_return;
}
QPointer<PlayerView> guard(this);
const QString currentMediaId = m_currentMediaId;
const bool seriesMode = m_isSeriesMode && !m_seriesId.isEmpty();
const QString seriesId = m_seriesId;
clearMediaSwitcherCache();
qDebug().noquote() << "[PlayerView] Loading media switcher data"
<< "| mediaId:" << currentMediaId << "| seriesMode:" << seriesMode << "| seriesId:" << seriesId;
try
{
if (seriesMode)
{
const QList<MediaItem> seasons = co_await m_core->mediaService()->getSeasons(seriesId);
if (!guard || guard->m_currentMediaId != currentMediaId)
{
co_return;
}
guard->m_switcherSeriesSeasons = seasons;
for (const MediaItem &season : seasons)
{
const QList<MediaItem> episodes = co_await m_core->mediaService()->getEpisodes(seriesId, season.id);
if (!guard || guard->m_currentMediaId != currentMediaId)
{
co_return;
}
guard->m_switcherSeasonEpisodes.insert(season.id, episodes);
}
try
{
const QList<MediaItem> items = co_await m_core->mediaService()->getResumeItems(30);
if (!guard || guard->m_currentMediaId != currentMediaId)
{
co_return;
}
guard->m_switcherResumeItems = items;
}
catch (const std::exception &e)
{
qDebug() << "[PlayerView] Resume media fallback fetch failed:" << e.what();
}
}
else
{
const QList<MediaItem> items = co_await m_core->mediaService()->getResumeItems(30);
if (!guard || guard->m_currentMediaId != currentMediaId)
{
co_return;
}
guard->m_switcherResumeItems = items;
}
if (!guard || guard->m_currentMediaId != currentMediaId)
{
co_return;
}
guard->m_switcherCacheMediaId = currentMediaId;
guard->m_switcherCacheReady = true;
qDebug().noquote() << "[PlayerView] Media switcher data ready"
<< "| mediaId:" << currentMediaId << "| resumeItems:" << guard->m_switcherResumeItems.size()
<< "| seasons:" << guard->m_switcherSeriesSeasons.size();
if (guard->useHudMediaSwitcher() && guard->m_mediaSwitchDrawer && guard->m_mediaSwitchDrawer->isVisible())
{
guard->syncHudMediaSwitcherContent();
}
if (!guard->useHudMediaSwitcher() && guard->m_isRightSidebarVisible)
{
guard->populateRightSidebarFromCache();
}
}
catch (const std::exception &e)
{
if (!guard || guard->m_currentMediaId != currentMediaId)
{
co_return;
}
guard->clearMediaSwitcherCache();
qDebug() << "[PlayerView] Media switcher data fetch failed:" << e.what();
if (guard->useHudMediaSwitcher() && guard->m_mediaSwitchDrawer && guard->m_mediaSwitchDrawer->isVisible())
{
guard->syncHudMediaSwitcherContent();
}
if (!guard->useHudMediaSwitcher() && guard->m_isRightSidebarVisible)
{
guard->populateRightSidebarFromCache();
}
}
}
QCoro::Task<void> PlayerView::switchFromMediaSwitcher(QString mediaId, QString title, long long startPositionTicks)
{
QPointer<PlayerView> guard(this);
if (mediaId.isEmpty())
{
co_return;
}
if (mediaId == m_currentMediaId)
{
hideRightSidebar();
hideHudMediaSwitcher();
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
co_return;
}
m_switcherPendingItemId = mediaId;
m_switcherPendingTitle = title;
m_switcherPendingTicks = startPositionTicks;
try
{
MediaItem detail = co_await m_core->mediaService()->getItemDetail(mediaId);
if (!guard)
{
co_return;
}
if (m_switcherPendingItemId.isEmpty() || detail.id != m_switcherPendingItemId)
{
co_return;
}
if (detail.mediaSources.isEmpty())
{
PlaybackInfo playbackInfo = co_await m_core->mediaService()->getPlaybackInfo(detail.id);
if (!guard)
{
co_return;
}
if (m_switcherPendingItemId.isEmpty() || detail.id != m_switcherPendingItemId)
{
co_return;
}
detail.mediaSources = playbackInfo.mediaSources;
}
MediaSourceInfo selectedSource;
QString mediaSourceId = detail.id;
if (!detail.mediaSources.isEmpty())
{
int sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex(
detail.mediaSources,
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerPreferredVersion).trimmed());
if (sourceIdx < 0 || sourceIdx >= detail.mediaSources.size())
{
sourceIdx = 0;
}
selectedSource = detail.mediaSources.at(sourceIdx);
PlayerPreferenceUtils::applyPreferredStreamRules(
selectedSource, ConfigStore::instance()->get<QString>(ConfigKeys::PlayerAudioLang, "auto"),
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerSubLang, "auto"));
mediaSourceId = selectedSource.id;
}
QString streamUrl = selectedSource.id.isEmpty()
? m_core->mediaService()->getStreamUrl(detail.id, mediaSourceId)
: m_core->mediaService()->getStreamUrl(detail.id, selectedSource);
const QString titleFallback = detail.seriesId == m_seriesId ? m_seriesName : QString();
QString resolvedTitle = MediaItemUtils::playbackTitle(detail, titleFallback);
if (resolvedTitle.isEmpty())
{
resolvedTitle = m_switcherPendingTitle.trimmed();
}
qDebug().noquote() << "[PlayerView] Switching playback item"
<< "| mediaId:" << detail.id << "| sourceId:" << mediaSourceId << "| title:" << resolvedTitle
<< "| startTicks:" << m_switcherPendingTicks;
hideRightSidebar();
hideHudMediaSwitcher();
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
stopAndReport();
m_toastLabel->hide();
PlayerLaunchContext launchContext;
launchContext.mediaItem = detail;
launchContext.selectedSource = selectedSource;
const QVariant sourceInfoVar = QVariant::fromValue(launchContext);
QTimer::singleShot(250, this,
[this, id = detail.id, resolvedTitle, streamUrl, startTicks = m_switcherPendingTicks,
sourceInfoVar]() { playMedia(id, resolvedTitle, streamUrl, startTicks, sourceInfoVar); });
m_switcherPendingItemId.clear();
m_switcherPendingTitle.clear();
m_switcherPendingTicks = 0;
}
catch (const std::exception &e)
{
if (!guard)
{
co_return;
}
qDebug() << "[PlayerView] Media switch failed:" << e.what();
m_switcherPendingItemId.clear();
m_switcherPendingTitle.clear();
m_switcherPendingTicks = 0;
}
}
void PlayerView::setupRightSidebar()
{
m_rightSidebar = new QWidget(this);
m_rightSidebar->setObjectName("playerRightSidebar");
m_rightSidebar->setFixedWidth(220);
m_rightSidebar->setGeometry(20000, 0, 220, 100);
// 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);
layout->setSpacing(10);
m_sidebarTitleLabel = new QLabel(tr("Continue Watching"), m_rightSidebar);
m_sidebarTitleLabel->setObjectName("playerSidebarTitle");
layout->addWidget(m_sidebarTitleLabel);
m_resumeList = new QListWidget(m_rightSidebar);
m_resumeList->setObjectName("playerResumeList");
m_resumeList->setFocusPolicy(Qt::NoFocus);
m_resumeList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_resumeList->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_resumeList->setTextElideMode(Qt::ElideRight);
layout->addWidget(m_resumeList);
connect(m_resumeList, &QListWidget::itemDoubleClicked, this,
[this](QListWidgetItem *item) -> QCoro::Task<void>
{
if (!item || item->flags() == Qt::NoItemFlags)
{
co_return;
}
co_await switchFromMediaSwitcher(item->data(Qt::UserRole).toString(),
item->data(Qt::UserRole + 2).toString(),
item->data(Qt::UserRole + 1).toLongLong());
});
m_rightSidebarAnim = new QPropertyAnimation(m_rightSidebar, "pos", this);
m_rightSidebarAnim->setDuration(350);
m_rightSidebarAnim->setEasingCurve(QEasingCurve::OutCubic);
connect(m_rightSidebarAnim, &QPropertyAnimation::finished, this,
[this]()
{
if (!m_rightSidebar || m_isRightSidebarVisible)
{
return;
}
m_rightSidebar->hide();
});
m_rightSidebar->installEventFilter(this);
m_rightSidebar->hide();
}
QCoro::Task<void> PlayerView::showRightSidebar()
{
if (m_isViewTearingDown || useHudMediaSwitcher() || m_hasReportedStop)
{
co_return;
}
m_isRightSidebarVisible = true;
const int sidebarY = m_topHUD->height();
const QPoint hiddenPos(width() + 30, sidebarY);
if (!m_rightSidebar || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject())
{
if (m_rightSidebar)
{
m_rightSidebar->move(width() - m_rightSidebar->width(), sidebarY);
m_rightSidebar->show();
}
}
else
{
if (m_rightSidebarAnim->state() == QAbstractAnimation::Running)
{
stopPropertyAnimationSafely(m_rightSidebarAnim);
}
if (!m_rightSidebar->isVisible())
{
m_rightSidebar->move(hiddenPos);
}
m_rightSidebar->show();
m_rightSidebarAnim->setStartValue(m_rightSidebar->pos());
m_rightSidebarAnim->setEndValue(QPoint(width() - m_rightSidebar->width(), sidebarY));
m_rightSidebarAnim->start();
}
showControls();
if (m_switcherCacheReady && m_switcherCacheMediaId == m_currentMediaId)
{
populateRightSidebarFromCache();
co_return;
}
m_sidebarTitleLabel->setText(m_isSeriesMode ? (m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName)
: tr("Continue Watching"));
m_resumeList->clear();
auto *loadingItem = new QListWidgetItem(tr("Loading..."));
loadingItem->setFlags(Qt::NoItemFlags);
loadingItem->setForeground(QColor(140, 140, 140));
m_resumeList->addItem(loadingItem);
co_await ensureMediaSwitcherDataLoaded();
if (!m_switcherCacheReady || m_switcherCacheMediaId != m_currentMediaId)
{
co_return;
}
populateRightSidebarFromCache();
}
void PlayerView::hideRightSidebar(bool immediate)
{
immediate = immediate || m_isViewTearingDown;
m_isRightSidebarVisible = false;
if (!m_rightSidebar)
{
return;
}
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 (m_rightSidebarAnim)
{
stopPropertyAnimationSafely(m_rightSidebarAnim);
}
m_rightSidebar->move(hiddenPos);
m_rightSidebar->hide();
return;
}
if (!m_rightSidebar->isVisible())
{
m_rightSidebar->move(hiddenPos);
return;
}
if (m_rightSidebarAnim->state() == QAbstractAnimation::Running)
{
stopPropertyAnimationSafely(m_rightSidebarAnim);
}
m_rightSidebarAnim->setStartValue(m_rightSidebar->pos());
m_rightSidebarAnim->setEndValue(hiddenPos);
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()
{
if (m_hasReportedStop)
{
return;
}
m_hasReportedStop = true;
updatePowerInhibition();
m_longPressHandler->setTeardown(true);
stopTransientUiAnimations(m_isViewTearingDown);
if (m_reportTimer)
m_reportTimer->stop();
if (m_mousePollTimer)
m_mousePollTimer->stop();
m_longPressHandler->stopKeyLongPress(false);
m_longPressHandler->stopMouseEdgeLongPress();
if (m_bufferTimer)
m_bufferTimer->stop();
if (m_hideTimer)
m_hideTimer->stop();
if (m_osdLayer)
m_osdLayer->forceHide();
if (m_toastTimer)
m_toastTimer->stop();
if (m_singleClickTimer)
m_singleClickTimer->stop();
hideRightSidebar(true);
hideHudMediaSwitcher();
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
closeActivePlayerDialog();
if (!m_currentMediaId.isEmpty() && m_core && m_core->mediaService())
{
long long currentTicks = static_cast<long long>(m_currentPosition * 10000000.0);
auto *service = m_core->mediaService();
QString mediaId = m_currentMediaId;
QString sourceId = m_currentMediaSourceId;
QString sessionId = m_currentPlaySessionId;
m_currentPlaySessionId.clear();
auto taskRoutine = [](MediaService *s, QString mId, QString sId, long long ticks,
QString sessId) -> QCoro::Task<void>
{
try
{
co_await s->reportPlaybackProgress(mId, sId, ticks, true, sessId);
}
catch (const std::exception &e)
{
qDebug() << "Progress sync failed (ignored):" << e.what();
}
try
{
co_await s->reportPlaybackStopped(mId, sId, ticks, sessId);
}
catch (const std::exception &e)
{
qDebug() << "Stop sync failed:" << e.what();
}
};
auto *lingeringTask = new QCoro::Task<void>(taskRoutine(service, mediaId, sourceId, currentTicks, sessionId));
QTimer::singleShot(10000, m_core, [lingeringTask]() { delete lingeringTask; });
}
if (m_mpvWidget)
{
if (m_danmakuController)
{
m_danmakuController->clearPlaybackContext();
}
disconnect(m_mpvWidget, &MpvWidget::positionChanged, this, &PlayerView::onPositionChanged);
m_mpvWidget->controller()->command(QVariantList{"stop"});
}
}
void PlayerView::beginViewTeardown()
{
if (m_isViewTearingDown)
{
return;
}
m_isViewTearingDown = true;
updatePowerInhibition();
setPlayerChromeVisible(false);
qDebug() << "[PlayerView] Begin teardown: stop timers, disconnect late signals, detach animations";
if (m_reportTimer)
m_reportTimer->stop();
if (m_mousePollTimer)
m_mousePollTimer->stop();
if (m_bufferTimer)
m_bufferTimer->stop();
if (m_hideTimer)
m_hideTimer->stop();
if (m_toastTimer)
m_toastTimer->stop();
if (m_singleClickTimer)
m_singleClickTimer->stop();
if (m_longPressHandler)
{
m_longPressHandler->setTeardown(true);
m_longPressHandler->stopKeyLongPress(false);
m_longPressHandler->stopMouseEdgeLongPress();
}
disconnect(ConfigStore::instance(), nullptr, this, nullptr);
if (m_danmakuController)
{
disconnect(m_danmakuController, nullptr, this, nullptr);
}
if (m_mpvWidget)
{
disconnect(m_mpvWidget, nullptr, this, nullptr);
if (m_mpvWidget->controller())
{
disconnect(m_mpvWidget->controller(), nullptr, this, nullptr);
}
}
stopTransientUiAnimations(true);
detachFadeGroupTargets(m_fadeGroup);
detachPropertyAnimationTarget(m_rightSidebarAnim);
m_isRightSidebarVisible = false;
if (m_mediaSwitchDrawer)
{
m_mediaSwitchDrawer->hide();
}
if (m_topHUD)
{
m_topHUD->hide();
}
if (m_bottomHUD)
{
m_bottomHUD->hide();
}
if (m_rightTrigger)
{
m_rightTrigger->hide();
}
if (m_rightSidebar)
{
m_rightSidebar->hide();
}
if (m_logoLabel)
{
m_logoLabel->hide();
}
if (m_networkSpeedLabel)
{
m_networkSpeedLabel->hide();
}
if (m_statisticsOverlay)
{
m_statisticsOverlay->hide();
}
if (m_toastLabel)
{
m_toastLabel->hide();
}
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
closeActivePlayerDialog();
setCursorHidden(false);
}
void PlayerView::keyPressEvent(QKeyEvent *event)
{
if (m_isViewTearingDown)
{
event->ignore();
return;
}
bool isHudVisible = (m_topOpacity->opacity() > 0.0);
if (event->key() == Qt::Key_Escape)
{
if (window()->isFullScreen())
{
window()->showNormal();
}
showControls();
event->accept();
}
else if (event->key() == Qt::Key_Space)
{
if (!event->isAutoRepeat())
{
togglePlayPause();
}
showControls();
event->accept();
}
else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
{
if (!event->isAutoRepeat())
{
toggleFullscreenWindow();
}
showControls();
event->accept();
}
else if (event->key() == Qt::Key_Left)
{
if (!event->isAutoRepeat())
{
m_longPressHandler->startKeyLongPress(-1, !isHudVisible);
}
event->accept();
}
else if (event->key() == Qt::Key_Right)
{
if (!event->isAutoRepeat())
{
m_longPressHandler->startKeyLongPress(1, !isHudVisible);
}
event->accept();
}
else if (event->key() == Qt::Key_Up)
{
changeVolume(5, !isHudVisible);
event->accept();
}
else if (event->key() == Qt::Key_Down)
{
changeVolume(-5, !isHudVisible);
event->accept();
}
else if (event->key() == Qt::Key_PageUp)
{
if (!event->isAutoRepeat() && m_prevMediaBtn)
{
m_prevMediaBtn->click();
}
event->accept();
}
else if (event->key() == Qt::Key_PageDown)
{
if (!event->isAutoRepeat() && m_nextMediaBtn)
{
m_nextMediaBtn->click();
}
event->accept();
}
else
{
showControls();
BaseView::keyPressEvent(event);
}
}
void PlayerView::keyReleaseEvent(QKeyEvent *event)
{
if (m_isViewTearingDown)
{
event->ignore();
return;
}
if (event->key() == Qt::Key_Left || event->key() == Qt::Key_Right)
{
if (!event->isAutoRepeat())
{
m_longPressHandler->stopKeyLongPress();
}
event->accept();
}
else
{
BaseView::keyReleaseEvent(event);
}
}
void PlayerView::setEffectivePlaybackSpeed(double speed)
{
if (!m_mpvWidget || !m_mpvWidget->controller())
{
return;
}
m_currentSpeed = speed;
m_mpvWidget->controller()->setProperty("speed", speed);
if (m_speedBtn)
{
m_speedBtn->setText(PlayerLongPressHandler::formatSpeedText(speed));
}
}
void PlayerView::stopTransientUiAnimations(bool immediate)
{
if (m_longPressHandler)
{
m_longPressHandler->stopAllAnimations();
}
if (m_loadingOverlay)
{
if (immediate)
{
m_loadingOverlay->forceStop();
}
else
{
m_loadingOverlay->stop();
}
}
if (m_rightSidebarAnim)
{
stopPropertyAnimationSafely(m_rightSidebarAnim);
}
if (m_osdLayer)
{
m_osdLayer->stopAnimations();
}
if (m_fadeGroup)
{
stopFadeGroupSafely(m_fadeGroup);
}
}
void PlayerView::updateOverlayLayout()
{
if (m_isViewTearingDown)
{
return;
}
if (!m_mpvWidget || !m_topHUD || !m_bottomHUD)
{
return;
}
const int drawerHeight = (m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible() && useHudMediaSwitcher())
? m_mediaSwitchDrawer->preferredDrawerHeight()
: 0;
const int targetBottomHudHeight = m_bottomHudBaseHeight + (drawerHeight > 0 ? drawerHeight + 8 : 0);
m_mpvWidget->setGeometry(0, 0, width(), height());
if (m_nativeDanmakuOverlay)
{
m_nativeDanmakuOverlay->setGeometry(0, 0, width(), height());
}
m_loadingOverlay->setGeometry(0, 0, width(), height());
m_topHUD->setGeometry(0, 0, width(), m_topHUD->height());
m_bottomHUD->setFixedHeight(targetBottomHudHeight);
m_bottomHUD->setGeometry(0, height() - m_bottomHUD->height(), width(), m_bottomHUD->height());
if (m_mediaSwitchDrawer)
{
m_mediaSwitchDrawer->setVisible(drawerHeight > 0 && useHudMediaSwitcher());
}
if (m_logoLabel)
{
m_logoLabel->move(24, m_topHUD->height());
}
if (m_networkSpeedLabel)
{
m_networkSpeedLabel->setGeometry(width() - 210, m_topHUD->height(), 200, 30);
}
if (m_statisticsOverlay)
{
int statisticsY = m_topHUD->height() + 10;
if (m_logoLabel && m_logoLabel->isVisible())
{
statisticsY = qMax(statisticsY, m_logoLabel->y() + m_logoLabel->height() + 8);
}
const int statisticsWidth = qMax(280, qMin(width() - 24, 620));
m_statisticsOverlay->setFixedWidth(statisticsWidth);
m_statisticsOverlay->setFixedHeight(m_statisticsOverlay->sizeHint().height());
m_statisticsOverlay->move(12, statisticsY);
}
const int sidebarY = m_topHUD->height();
const int sidebarH = height() - m_topHUD->height() - m_bottomHUD->height();
m_rightTrigger->setGeometry(width() - 15, sidebarY, 15, sidebarH);
if (!m_isRightSidebarVisible)
{
m_rightSidebar->setGeometry(width() + 30, sidebarY, m_rightSidebar->width(), sidebarH);
}
else
{
m_rightSidebar->setGeometry(width() - m_rightSidebar->width(), sidebarY, m_rightSidebar->width(), sidebarH);
}
if (m_osdLayer)
{
m_osdLayer->updateGeometry(width(), height());
}
if (m_longPressHandler)
{
m_longPressHandler->updateGeometry(width(), height(), m_topHUD ? m_topHUD->height() : 0);
}
if (m_activePopup)
{
m_activePopup->raise();
}
updateTitleElision();
}
void PlayerView::resizeEvent(QResizeEvent *event)
{
BaseView::resizeEvent(event);
if (m_isViewTearingDown)
{
return;
}
QTimer::singleShot(
0, this,
[this]()
{
if (window() && m_maxBtn)
{
bool isMax = window()->isMaximized();
if (!m_maxBtn->property("isMax").isValid() || m_maxBtn->property("isMax").toBool() != isMax)
{
m_maxBtn->setIcon(QIcon(isMax ? ":/svg/player/restore.svg" : ":/svg/player/max.svg"));
m_maxBtn->setProperty("isMax", isMax);
}
}
});
updateOverlayLayout();
}
void PlayerView::updateTitleElision()
{
if (m_fullTitle.isEmpty() || !m_titleLabel)
{
return;
}
int safeWidth = m_titleLabel->contentsRect().width();
if (safeWidth <= 0)
{
safeWidth = this->width() - 320;
}
if (safeWidth < 50)
{
safeWidth = 50;
}
QFontMetrics fm(m_titleLabel->font());
QString elided = fm.elidedText(m_fullTitle, Qt::ElideRight, safeWidth);
m_titleLabel->setText(elided);
m_titleLabel->setToolTip(m_fullTitle);
}
bool PlayerView::eventFilter(QObject *watched, QEvent *event)
{
if (m_isViewTearingDown)
{
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)
{
if (m_activePlayerDialog && m_activePlayerDialog->isVisible())
{
const auto type = event->type();
if (type == QEvent::MouseButtonPress || type == QEvent::MouseButtonRelease ||
type == QEvent::MouseButtonDblClick || type == QEvent::MouseMove || type == QEvent::Wheel)
{
return true;
}
}
if (event->type() == QEvent::MouseButtonPress)
{
auto *me = static_cast<QMouseEvent *>(event);
const QPoint clickPos = this->mapFromGlobal(me->globalPosition().toPoint());
const bool isOnHud = m_topHUD->geometry().contains(clickPos) || m_bottomHUD->geometry().contains(clickPos);
if (me->button() == Qt::BackButton || me->button() == Qt::XButton1)
{
onBackClicked();
return true;
}
if (m_activePopup)
{
QPoint localPos = this->mapFromGlobal(me->globalPosition().toPoint());
if (!m_activePopup->geometry().contains(localPos))
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
return true;
}
}
showControls();
if (me->button() == Qt::LeftButton && watched != m_bottomHUD)
{
m_singleClickTimer->stop();
if (!isOnHud)
{
m_longPressHandler->startMouseEdgeLongPress(clickPos.x() < width() / 2 ? -1 : 1);
}
m_dragPos = me->globalPosition().toPoint();
m_didDrag = false;
}
}
else if (event->type() == QEvent::MouseMove)
{
auto *me = static_cast<QMouseEvent *>(event);
m_lastMousePos = me->globalPosition().toPoint();
handlePointerActivity(m_lastMousePos);
const QPoint localPos = this->mapFromGlobal(me->globalPosition().toPoint());
const bool isOnHud = m_topHUD->geometry().contains(localPos) || m_bottomHUD->geometry().contains(localPos);
const bool movedEnough =
(me->globalPosition().toPoint() - m_dragPos).manhattanLength() > QApplication::startDragDistance();
if (m_longPressHandler->mouseEdgeLongPressDirection() != 0 && (movedEnough || isOnHud))
{
m_longPressHandler->stopMouseEdgeLongPress();
}
if ((me->buttons() & Qt::LeftButton) && watched != m_bottomHUD)
{
if (!window()->isFullScreen())
{
if (movedEnough)
{
m_didDrag = true;
if (window() && window()->windowHandle())
{
window()->windowHandle()->startSystemMove();
}
}
}
return true;
}
}
else if (event->type() == QEvent::MouseButtonRelease)
{
auto *me = static_cast<QMouseEvent *>(event);
const bool consumedByMouseEdge =
me->button() == Qt::LeftButton && m_longPressHandler->stopMouseEdgeLongPress();
if (consumedByMouseEdge)
{
m_singleClickTimer->stop();
m_didDrag = true;
return true;
}
QPoint clickPos = this->mapFromGlobal(me->globalPosition().toPoint());
bool isOnHud = m_topHUD->geometry().contains(clickPos) || m_bottomHUD->geometry().contains(clickPos);
if (me->button() == Qt::LeftButton && !isOnHud && !m_didDrag &&
ConfigStore::instance()->get<bool>(ConfigKeys::PlayerClickToPause, true))
{
m_singleClickTimer->start();
}
}
else if (event->type() == QEvent::MouseButtonDblClick)
{
auto *me = static_cast<QMouseEvent *>(event);
if (me->button() == Qt::LeftButton)
{
m_longPressHandler->stopMouseEdgeLongPress();
}
QPoint clickPos = this->mapFromGlobal(me->globalPosition().toPoint());
bool isOnHud = m_topHUD->geometry().contains(clickPos) || m_bottomHUD->geometry().contains(clickPos);
if (me->button() == Qt::LeftButton && !isOnHud)
{
m_singleClickTimer->stop();
m_didDrag = true;
toggleFullscreenWindow();
return true;
}
}
}
if (watched == m_rightTrigger && event->type() == QEvent::Enter)
{
showRightSidebar();
return true;
}
if (watched == m_rightSidebar && event->type() == QEvent::Leave)
{
QPoint globalPos = QCursor::pos();
QPoint localPos = m_rightSidebar->mapFromGlobal(globalPos);
if (!m_rightSidebar->rect().contains(localPos))
{
hideRightSidebar();
}
return false;
}
if (event->type() == QEvent::Wheel)
{
if (m_activePlayerDialog && m_activePlayerDialog->isVisible())
{
return true;
}
if (watched == m_volumeBtn || watched == m_volumeSlider || watched == m_bottomHUD || watched == m_mpvWidget ||
watched == this)
{
showControls();
auto *we = static_cast<QWheelEvent *>(event);
changeVolume(we->angleDelta().y() > 0 ? 5 : -5);
return true;
}
}
return BaseView::eventFilter(watched, event);
}
void PlayerView::handlePointerActivity(const QPoint &globalPos)
{
if (m_isViewTearingDown || m_hasReportedStop)
{
return;
}
const QPoint localPos = mapFromGlobal(globalPos);
if (!rect().contains(localPos))
{
return;
}
setCursorHidden(false);
if (m_osdLayer && m_osdLayer->isVisible())
{
m_osdLayer->forceHide();
}
if (m_isPlaying)
{
m_hideTimer->start(kHudAutoHideDelayMs);
}
else
{
m_hideTimer->stop();
}
if (areControlsFullyVisible())
{
return;
}
showControls();
}
void PlayerView::setCursorHidden(bool hidden)
{
const auto targetShape = hidden ? Qt::BlankCursor : Qt::ArrowCursor;
if (cursor().shape() != targetShape)
{
setCursor(targetShape);
}
if (m_mpvWidget && m_mpvWidget->cursor().shape() != targetShape)
{
m_mpvWidget->setCursor(targetShape);
}
}
void PlayerView::setPlayerChromeVisible(bool visible)
{
if (m_playerChromeVisible == visible)
{
return;
}
m_playerChromeVisible = visible;
Q_EMIT playerChromeVisibilityChanged(visible);
}
bool PlayerView::areControlsFullyVisible() const
{
return m_topOpacity && m_topOpacity->opacity() >= 1.0 &&
(!m_fadeGroup || m_fadeGroup->state() != QAbstractAnimation::Running);
}
void PlayerView::showControls()
{
if (m_isViewTearingDown || m_hasReportedStop)
{
return;
}
setPlayerChromeVisible(true);
setCursorHidden(false);
if (m_osdLayer && m_osdLayer->isVisible())
{
m_osdLayer->forceHide();
}
if (m_isPlaying)
{
m_hideTimer->start(kHudAutoHideDelayMs);
}
else
{
m_hideTimer->stop();
}
if (m_topOpacity->opacity() >= 1.0 && m_fadeGroup->state() != QAbstractAnimation::Running)
{
return;
}
if (m_fadeGroup->state() == QAbstractAnimation::Running)
{
stopFadeGroupSafely(m_fadeGroup);
}
bool canAnimate = true;
for (int i = 0; i < m_fadeGroup->animationCount(); ++i)
{
auto *anim = qobject_cast<QPropertyAnimation *>(m_fadeGroup->animationAt(i));
if (!anim || !anim->targetObject())
{
canAnimate = false;
break;
}
anim->setStartValue(anim->targetObject()->property("opacity"));
anim->setEndValue(1.0);
}
if (!canAnimate)
{
if (m_topOpacity)
m_topOpacity->setOpacity(1.0);
if (m_bottomOpacity)
m_bottomOpacity->setOpacity(1.0);
if (m_logoOpacity)
m_logoOpacity->setOpacity(1.0);
if (m_speedOpacity)
m_speedOpacity->setOpacity(1.0);
return;
}
m_fadeGroup->setDirection(QAbstractAnimation::Forward);
m_fadeGroup->start();
}
void PlayerView::hideControls()
{
if (m_isViewTearingDown || m_hasReportedStop)
{
return;
}
if (!m_isPlaying)
{
return;
}
QPoint localPos = this->mapFromGlobal(QCursor::pos());
bool isAppActive = this->window()->isActiveWindow();
bool isMouseInside = this->rect().contains(localPos);
bool isMouseInsidePopup = m_activePopup && m_activePopup->geometry().contains(localPos);
const bool hasActivePlayerDialog = m_activePlayerDialog && m_activePlayerDialog->isVisible();
if (hasActivePlayerDialog)
{
m_hideTimer->start(1000);
return;
}
if (isAppActive && isMouseInside &&
(m_topHUD->geometry().contains(localPos) || m_bottomHUD->geometry().contains(localPos) || isMouseInsidePopup ||
(m_isRightSidebarVisible && m_rightSidebar->geometry().contains(localPos))))
{
m_hideTimer->start(1000);
return;
}
hideRightSidebar();
hideHudMediaSwitcher();
setPlayerChromeVisible(false);
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
if (m_topOpacity->opacity() <= 0.0)
{
return;
}
if (m_fadeGroup->state() == QAbstractAnimation::Running)
{
stopFadeGroupSafely(m_fadeGroup);
}
bool canAnimate = true;
for (int i = 0; i < m_fadeGroup->animationCount(); ++i)
{
auto *anim = qobject_cast<QPropertyAnimation *>(m_fadeGroup->animationAt(i));
if (!anim || !anim->targetObject())
{
canAnimate = false;
break;
}
anim->setStartValue(anim->targetObject()->property("opacity"));
anim->setEndValue(0.0);
}
if (!canAnimate)
{
if (m_topOpacity)
m_topOpacity->setOpacity(0.0);
if (m_bottomOpacity)
m_bottomOpacity->setOpacity(0.0);
if (m_logoOpacity)
m_logoOpacity->setOpacity(0.0);
if (m_speedOpacity)
m_speedOpacity->setOpacity(0.0);
return;
}
m_fadeGroup->setDirection(QAbstractAnimation::Forward);
m_fadeGroup->start();
setCursorHidden(true);
}
void PlayerView::onMpvPropertyChanged(const QString &property, const QVariant &value)
{
if (m_isViewTearingDown)
{
return;
}
if (property == "cache-speed")
{
qint64 speedBytes = value.toLongLong();
if (!m_useRelayNetworkSpeed)
{
m_effectiveNetworkSpeed = speedBytes;
m_networkSpeedLabel->setText(formatDataRateValue(speedBytes));
}
}
else if (property == "mute")
{
m_isMuted = value.toBool();
m_volumeBtn->setIcon(QIcon(m_isMuted ? ":/svg/player/volume-mute.svg" : ":/svg/player/volume.svg"));
m_volumeSlider->blockSignals(true);
if (m_isMuted)
{
m_volumeSlider->setValue(0);
}
else
{
m_volumeSlider->setValue(static_cast<int>(m_currentVolume));
}
m_volumeSlider->blockSignals(false);
}
else if (property == "volume")
{
m_currentVolume = value.toDouble();
if (!m_isMuted)
{
m_volumeSlider->blockSignals(true);
m_volumeSlider->setValue(static_cast<int>(m_currentVolume));
m_volumeSlider->blockSignals(false);
}
}
else if (property == "speed")
{
m_currentSpeed = value.toDouble();
if (m_speedBtn)
{
m_speedBtn->setText(PlayerLongPressHandler::formatSpeedText(m_currentSpeed));
}
}
else if (property == "paused-for-cache")
{
m_isBuffering = value.toBool();
updateLoadingState();
}
else if (property == "seeking")
{
m_isSeeking = value.toBool();
updateLoadingState();
}
else if (property == "eof-reached")
{
m_isPlaybackFinished = value.toBool();
if (m_isPlaybackFinished)
{
qDebug() << "[PlayerView] MPV reached EOF"
<< "| position=" << m_currentPosition
<< "| duration=" << m_totalDuration;
m_isPlaying = false;
m_isBuffering = false;
m_isSeeking = false;
updatePowerInhibition();
updateLoadingState();
if (m_playPauseBtn)
{
m_playPauseBtn->setIcon(QIcon(":/svg/player/play.svg"));
}
autoPlayNextMediaIfEnabled();
}
}
if (m_showStatisticsOverlay && (property == QLatin1String("cache-speed") ||
property == QLatin1String("track-list") || property == QLatin1String("pause")))
{
updateStatisticsDisplay();
}
}
void PlayerView::toggleMute()
{
m_isMuted = !m_isMuted;
m_mpvWidget->controller()->setProperty("mute", m_isMuted);
showToast(m_isMuted ? tr("Muted") : tr("Volume: %1%").arg(static_cast<int>(m_currentVolume)));
showControls();
ConfigStore::instance()->set(ConfigKeys::PlayerVolumeMuted, m_isMuted);
}
void PlayerView::changeVolume(int delta, bool silent)
{
if (m_isMuted)
{
return;
}
m_currentVolume += delta;
if (m_currentVolume < 0)
{
m_currentVolume = 0;
}
if (m_currentVolume > 100)
{
m_currentVolume = 100;
}
m_mpvWidget->controller()->setProperty("volume", m_currentVolume);
if (!silent)
{
showToast(tr("Volume: %1%").arg(static_cast<int>(m_currentVolume)));
showControls();
}
else
{
if (m_osdLayer && m_topOpacity->opacity() <= 0.0)
{
const int volumePercent = static_cast<int>(m_currentVolume);
m_osdLayer->showVolume(volumePercent, tr("%1%").arg(volumePercent), m_isMuted);
}
}
ConfigStore::instance()->set(ConfigKeys::PlayerVolumeLevel, m_currentVolume);
}
void PlayerView::onVolumeSliderMoved(int value)
{
m_currentVolume = value;
if (m_isMuted && m_currentVolume > 0)
{
m_isMuted = false;
m_mpvWidget->controller()->setProperty("mute", false);
}
m_mpvWidget->controller()->setProperty("volume", m_currentVolume);
showToast(tr("Volume: %1%").arg(static_cast<int>(m_currentVolume)));
showControls();
ConfigStore::instance()->set(ConfigKeys::PlayerVolumeLevel, m_currentVolume);
ConfigStore::instance()->set(ConfigKeys::PlayerVolumeMuted, m_isMuted);
}
void PlayerView::showSettingsMenu()
{
auto *panel = new ModernScrollPanel(this);
const bool showDanmakuControls = shouldShowDanmakuHudControls();
panel->addItem(tr("Network Speed"), "network_speed", m_showNetworkSpeed);
panel->addItem(tr("Statistics"), "statistics", m_showStatisticsOverlay);
panel->addItem(tr("Subtitle Settings"), "subtitle_settings", false);
if (showDanmakuControls)
{
panel->addItem(tr("Danmaku Settings"), "danmaku_settings", false);
}
ServerProfile profile = m_core->serverManager()->activeProfile();
bool defaultStrmDirect = (profile.type == ServerProfile::Jellyfin);
QSettings settings("qEmby", "Player");
bool strmDirect = settings.value("EnableStrmDirectPlay", defaultStrmDirect).toBool();
panel->addItem(tr("STRM Direct Play"), "strm_direct", strmDirect);
int maxHeight = this->height() - m_bottomHUD->height() - 40;
panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 300);
connect(panel, &ModernScrollPanel::itemTriggered, this,
[this](const QVariant &data, const QString &text)
{
Q_UNUSED(text);
QString action = data.toString();
auto dismissPopup = [this]()
{
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
};
if (action == "network_speed")
{
m_showNetworkSpeed = !m_showNetworkSpeed;
m_networkSpeedLabel->setVisible(m_showNetworkSpeed);
showToast(m_showNetworkSpeed ? tr("Network Speed Enabled") : tr("Network Speed Disabled"));
}
else if (action == "statistics")
{
m_showStatisticsOverlay = !m_showStatisticsOverlay;
if (m_showStatisticsOverlay)
{
updateStatisticsDisplay();
m_statisticsOverlay->show();
updateOverlayLayout();
}
else
{
m_statisticsOverlay->hide();
}
}
else if (action == "strm_direct")
{
ServerProfile profile = m_core->serverManager()->activeProfile();
bool defaultStrmDirect = (profile.type == ServerProfile::Jellyfin);
QSettings settings("qEmby", "Player");
bool currentStrm = settings.value("EnableStrmDirectPlay", defaultStrmDirect).toBool();
bool newStrm = !currentStrm;
settings.setValue("EnableStrmDirectPlay", newStrm);
showToast(newStrm ? tr("STRM Direct Play: ON (Reloading...)")
: tr("STRM Direct Play: OFF (Reloading...)"));
QString mediaId = m_currentMediaId;
QString title = m_fullTitle;
QString origUrl = m_originalStreamUrl;
QVariant sourceInfo = m_currentSourceInfoVar;
long long currentTicks = static_cast<long long>(m_currentPosition * 10000000.0);
stopAndReport();
QTimer::singleShot(150, this, [this, mediaId, title, origUrl, currentTicks, sourceInfo]()
{ playMedia(mediaId, title, origUrl, currentTicks, sourceInfo); });
}
else if (action == "subtitle_settings")
{
dismissPopup();
openSubtitleSettingsDialog();
return;
}
else if (action == "danmaku_settings")
{
dismissPopup();
openDanmakuSettingsDialog();
return;
}
dismissPopup();
});
showCenteredPopup(panel, m_settingsBtn);
}
void PlayerView::showSpeedMenu()
{
auto *panel = new ModernScrollPanel(this);
QList<double> speeds = {0.5, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
for (double s : speeds)
{
bool isSelected = qFuzzyCompare(m_currentSpeed, s);
panel->addItem(QString("%1X").arg(s), s, isSelected);
}
int maxHeight = this->height() - m_bottomHUD->height() - 40;
panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 130);
connect(panel, &ModernScrollPanel::itemTriggered, this,
[this](const QVariant &data, const QString &text)
{
Q_UNUSED(text);
double s = data.toDouble();
m_currentSpeed = s;
setEffectivePlaybackSpeed(s);
showToast(tr("Speed: %1X").arg(PlayerLongPressHandler::formatSpeedText(s).chopped(1)));
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
});
showCenteredPopup(panel, m_speedBtn);
}
void PlayerView::showAudioMenu()
{
auto *panel = new ModernScrollPanel(this);
auto tracks = m_mpvWidget->controller()->getProperty("track-list").toList();
bool anyAudioSelected = false;
for (const QVariant &v : tracks)
{
auto map = v.toMap();
if (map["type"].toString() == "audio")
{
int id = map["id"].toInt();
QString title = map["title"].toString();
QString lang = map["lang"].toString();
bool selected = map["selected"].toBool();
if (selected)
{
anyAudioSelected = true;
}
QString text = title.isEmpty() ? (lang.isEmpty() ? tr("Track %1").arg(id) : lang) : title;
panel->addItem(text, id, selected);
}
}
panel->addItem(tr("Disable Audio"), "no", !anyAudioSelected);
int maxHeight = this->height() - m_bottomHUD->height() - 40;
panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 200);
connect(panel, &ModernScrollPanel::itemTriggered, this,
[this](const QVariant &data, const QString &text)
{
QString valStr = data.toString();
if (valStr == "no")
{
m_mpvWidget->controller()->setProperty("aid", "no");
showToast(tr("Audio Disabled"));
}
else
{
int id = data.toInt();
m_mpvWidget->controller()->setProperty("aid", id);
showToast(tr("Audio: %1").arg(text));
}
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
});
showCenteredPopup(panel, m_audioBtn);
}
void PlayerView::showSubtitleMenu()
{
auto *panel = new ModernScrollPanel(this);
const QList<QVariantMap> tracks =
m_danmakuController ? m_danmakuController->contentSubtitleTracks() : QList<QVariantMap>{};
bool anySubSelected = false;
for (const QVariantMap &map : tracks)
{
int id = map["id"].toInt();
QString title = map["title"].toString();
QString lang = map["lang"].toString();
bool selected = map["selected"].toBool();
if (selected)
{
anySubSelected = true;
}
QString text = title.isEmpty() ? (lang.isEmpty() ? tr("Subtitle %1").arg(id) : lang) : title;
panel->addItem(text, id, selected);
}
panel->addItem(tr("Disable Subtitles"), "no", !anySubSelected);
panel->addItem(tr("Load Local Subtitle File"), QStringLiteral("load_local"), false);
panel->addItem(tr("Subtitle Settings"), QStringLiteral("settings"), false);
int maxHeight = this->height() - m_bottomHUD->height() - 40;
panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 240);
connect(panel, &ModernScrollPanel::itemTriggered, this,
[this](const QVariant &data, const QString &text)
{
auto dismissPopup = [this]()
{
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
};
const QString action = data.toString();
if (action == QLatin1String("settings"))
{
dismissPopup();
openSubtitleSettingsDialog();
return;
}
if (action == QLatin1String("load_local"))
{
dismissPopup();
loadExternalSubtitleFile();
return;
}
clearPersistedExternalSubtitle();
if (m_danmakuController)
{
m_danmakuController->selectSubtitleTrack(data);
}
showToast(action == "no" ? tr("Subtitles Disabled") : tr("Subtitle: %1").arg(text));
dismissPopup();
});
showCenteredPopup(panel, m_subtitleBtn);
}
void PlayerView::showDanmakuMenu()
{
if (!shouldShowDanmakuHudControls())
{
return;
}
auto *panel = new ModernScrollPanel(this);
const bool visible = m_danmakuController && m_danmakuController->isDanmakuVisible();
panel->addItem(buildDanmakuSummaryText(), QStringLiteral("info"), false);
panel->addItem(visible ? tr("Hide Danmaku") : tr("Show Danmaku"), QStringLiteral("toggle"), visible);
panel->addItem(tr("Reload Danmaku"), QStringLiteral("reload"), false);
panel->addItem(tr("Load Local Danmaku File"), QStringLiteral("load_local"), false);
panel->addItem(tr("Search Danmaku"), QStringLiteral("search"), false);
panel->addItem(tr("Danmaku Settings"), QStringLiteral("quick_settings"), false);
int maxHeight = this->height() - m_bottomHUD->height() - 40;
panel->finalizeLayout(maxHeight < 160 ? 160 : maxHeight, 360);
connect(panel, &ModernScrollPanel::itemTriggered, this,
[this](const QVariant &data, const QString &text)
{
Q_UNUSED(text);
if (!m_danmakuController)
{
return;
}
auto dismissPopup = [this]()
{
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
};
const QString action = data.toString();
if (action == QLatin1String("info"))
{
showToast(buildDanmakuSummaryText());
}
else if (action == QLatin1String("toggle"))
{
m_danmakuController->setDanmakuVisible(!m_danmakuController->isDanmakuVisible());
}
else if (action == QLatin1String("reload"))
{
m_danmakuController->reload();
}
else if (action == QLatin1String("load_local"))
{
dismissPopup();
loadLocalDanmakuFile();
return;
}
else if (action == QLatin1String("search"))
{
dismissPopup();
showDanmakuIdentifyDialog();
return;
}
else if (action == QLatin1String("quick_settings"))
{
dismissPopup();
openDanmakuSettingsDialog();
return;
}
dismissPopup();
});
showCenteredPopup(panel, m_danmakuBtn);
}
void PlayerView::showDanmakuIdentifyDialog()
{
if (!m_danmakuController || !m_danmakuController->hasPlaybackContext())
{
showToast(tr("Danmaku is unavailable for the current media"));
return;
}
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
const QString activeTargetId = m_danmakuController ? m_danmakuController->activeTargetId() : QString();
const QString activeEndpointId = m_danmakuController ? m_danmakuController->activeEndpointId() : QString();
auto *dialog = new PlayerDanmakuIdentifyDialog(m_core, m_danmakuController->mediaContext(), QString(),
activeTargetId, activeEndpointId, this);
connect(dialog, &PlayerDanmakuIdentifyDialog::finished, this,
[this, dialog](int result)
{
if (result != PlayerOverlayDialog::Accepted || !dialog->selectedCandidate().isValid() ||
!m_danmakuController)
{
return;
}
m_danmakuController->loadFromCandidate(dialog->selectedCandidate(), true);
});
trackPlayerDialog(dialog);
}
void PlayerView::loadLocalDanmakuFile()
{
if (!m_danmakuController || !m_danmakuController->hasPlaybackContext())
{
showToast(tr("Danmaku is unavailable for the current media"));
return;
}
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
closeActivePlayerDialog();
const QString serverId = m_danmakuController->mediaContext().serverId;
const QString startDir = DanmakuService::ensureLocalDanmakuDirectory(serverId)
? DanmakuService::localDanmakuDirectoryPath(serverId)
: QDir::homePath();
const QString filePath = openPlayerFileDialog(
tr("Load Local Danmaku File"), startDir,
tr("Danmaku Files (*.ass *.json *.xml);;ASS Files (*.ass);;JSON Files (*.json);;XML Files (*.xml)"));
if (filePath.trimmed().isEmpty())
{
return;
}
qDebug().noquote() << "[Danmaku][PlayerView] Load local danmaku file"
<< "| mediaId:" << m_danmakuController->mediaContext().mediaId << "| path:" << filePath;
m_danmakuController->loadLocalFile(filePath);
}
void PlayerView::loadExternalSubtitleFile()
{
if (!m_mpvWidget || !m_mpvWidget->controller())
{
showToast(tr("Player is not ready"));
return;
}
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
closeActivePlayerDialog();
const QString lastDir = ConfigStore::instance()->get<QString>(ConfigKeys::PlayerLastSubtitleDir, QString());
const QString startDir = (!lastDir.isEmpty() && QDir(lastDir).exists()) ? lastDir : QDir::homePath();
const QString filter = tr("Subtitle Files (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;"
"SubRip (*.srt);;"
"Advanced SubStation Alpha (*.ass *.ssa);;"
"WebVTT (*.vtt);;"
"MicroDVD / SubViewer (*.sub);;"
"VobSub (*.idx);;"
"SAMI (*.smi);;"
"PGS / HDMV (*.sup);;"
"All Files (*)");
const QString filePath = openPlayerFileDialog(tr("Load Local Subtitle File"), startDir, filter);
if (filePath.trimmed().isEmpty())
{
return;
}
const QFileInfo info(filePath);
ConfigStore::instance()->set(ConfigKeys::PlayerLastSubtitleDir, info.absolutePath());
qDebug().noquote() << "[Subtitle][PlayerView] Load external subtitle file"
<< "| path:" << filePath;
m_mpvWidget->controller()->command(QVariantList{QStringLiteral("sub-add"), QDir::toNativeSeparators(filePath),
QStringLiteral("auto"), info.fileName(), QString()});
const int newTrackId = findSubtitleTrackIdByPath(info.absoluteFilePath());
if (newTrackId > 0 && m_danmakuController)
{
m_danmakuController->selectSubtitleTrack(newTrackId);
}
else
{
qWarning().noquote() << "[Subtitle][PlayerView] Failed to locate newly added subtitle track"
<< "| path:" << filePath;
}
persistExternalSubtitle(info.absoluteFilePath());
showToast(tr("Subtitle Loaded: %1").arg(info.fileName()));
}
QString PlayerView::externalSubtitleConfigKey() const
{
if (!m_core)
{
return QString();
}
const QString serverId = m_core->serverManager()->activeProfile().id;
if (serverId.isEmpty() || m_currentMediaId.isEmpty())
{
return QString();
}
return ConfigKeys::forServerMedia(serverId, m_currentMediaId, ConfigKeys::PlayerExternalSubtitle);
}
QString PlayerView::readPersistedExternalSubtitle() const
{
const QString key = externalSubtitleConfigKey();
if (key.isEmpty())
{
return QString();
}
return ConfigStore::instance()->get<QString>(key, QString()).trimmed();
}
void PlayerView::persistExternalSubtitle(const QString &absPath)
{
const QString key = externalSubtitleConfigKey();
if (key.isEmpty())
{
return;
}
ConfigStore::instance()->set(key, absPath);
qDebug().noquote() << "[Subtitle][PlayerView] Persist external subtitle"
<< "| key:" << key << "| path:" << absPath;
}
void PlayerView::clearPersistedExternalSubtitle()
{
const QString key = externalSubtitleConfigKey();
if (key.isEmpty())
{
return;
}
const QString existing = ConfigStore::instance()->get<QString>(key, QString()).trimmed();
if (existing.isEmpty())
{
return;
}
ConfigStore::instance()->set(key, QString());
qDebug().noquote() << "[Subtitle][PlayerView] Clear external subtitle record"
<< "| key:" << key << "| previousPath:" << existing;
}
void PlayerView::applyPersistedExternalSubtitleIfAny()
{
if (!m_mpvWidget || !m_mpvWidget->controller())
{
return;
}
const QString persistedPath = readPersistedExternalSubtitle();
if (persistedPath.isEmpty())
{
return;
}
const QFileInfo info(persistedPath);
if (!info.exists() || !info.isFile() || !info.isReadable())
{
qDebug().noquote() << "[Subtitle][PlayerView] Persisted external subtitle missing, "
"fallback to default selection"
<< "| path:" << persistedPath;
clearPersistedExternalSubtitle();
return;
}
qDebug().noquote() << "[Subtitle][PlayerView] Restore persisted external subtitle"
<< "| path:" << persistedPath;
m_mpvWidget->controller()->command(QVariantList{QStringLiteral("sub-add"),
QDir::toNativeSeparators(info.absoluteFilePath()),
QStringLiteral("auto"), info.fileName(), QString()});
const int newTrackId = findSubtitleTrackIdByPath(info.absoluteFilePath());
if (newTrackId > 0 && m_danmakuController)
{
m_danmakuController->selectSubtitleTrack(newTrackId);
}
else
{
qWarning().noquote() << "[Subtitle][PlayerView] Failed to locate restored subtitle track"
<< "| path:" << persistedPath;
}
}
QString PlayerView::openPlayerFileDialog(const QString &title, const QString &startDir, const QString &filter)
{
QFileDialog dialog(this);
#ifdef Q_OS_LINUX
dialog.setOption(QFileDialog::DontUseNativeDialog, true);
dialog.setOption(QFileDialog::DontUseCustomDirectoryIcons, true);
#endif
dialog.setWindowTitle(title);
dialog.setDirectory(startDir);
dialog.setNameFilter(filter);
dialog.setFileMode(QFileDialog::ExistingFile);
dialog.setAcceptMode(QFileDialog::AcceptOpen);
QElapsedTimer timer;
timer.start();
qDebug().noquote() << "[PlayerView] Open local file dialog"
<< "| title:" << title << "| startDir:" << startDir;
if (dialog.exec() != QDialog::Accepted)
{
qDebug().noquote() << "[PlayerView] Local file dialog canceled"
<< "| title:" << title << "| elapsedMs:" << timer.elapsed();
return QString();
}
const QStringList selectedFiles = dialog.selectedFiles();
const QString filePath = selectedFiles.isEmpty() ? QString() : selectedFiles.first();
qDebug().noquote() << "[PlayerView] Local file dialog accepted"
<< "| title:" << title << "| elapsedMs:" << timer.elapsed() << "| path:" << filePath;
return filePath;
}
int PlayerView::findSubtitleTrackIdByPath(const QString &absPath) const
{
if (!m_mpvWidget || !m_mpvWidget->controller() || absPath.isEmpty())
{
return -1;
}
const QString normalizedTarget = QDir::fromNativeSeparators(absPath);
const QVariantList tracks = m_mpvWidget->controller()->getProperty(QStringLiteral("track-list")).toList();
for (const QVariant &v : tracks)
{
const QVariantMap m = v.toMap();
if (m.value(QStringLiteral("type")).toString() != QLatin1String("sub"))
{
continue;
}
const QString external = m.value(QStringLiteral("external-filename")).toString();
if (!external.isEmpty() && QDir::fromNativeSeparators(external) == normalizedTarget)
{
return m.value(QStringLiteral("id")).toInt();
}
}
return -1;
}
void PlayerView::openSubtitleSettingsDialog()
{
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
auto *dialog = new PlayerSubtitleSettingsDialog(this);
trackPlayerDialog(dialog);
}
void PlayerView::openDanmakuSettingsDialog()
{
if (!shouldShowDanmakuHudControls())
{
return;
}
if (m_activePopup)
{
m_activePopup->hide();
m_activePopup->close();
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
auto *dialog = new PlayerDanmakuSettingsDialog(this);
connect(dialog, &PlayerDanmakuSettingsDialog::liveReloadRequested, this,
[this]()
{
if (!m_danmakuController || !m_danmakuController->hasPlaybackContext() ||
!m_danmakuController->isDanmakuEnabled())
{
return;
}
m_danmakuController->reload();
});
connect(dialog, &PlayerDanmakuSettingsDialog::finished, this,
[this, dialog](int)
{
if (!m_danmakuController)
{
return;
}
if (dialog->requiresReload() && m_danmakuController->hasPlaybackContext() &&
m_danmakuController->isDanmakuEnabled())
{
m_danmakuController->reload();
}
});
trackPlayerDialog(dialog);
}
void PlayerView::applySubtitleStyleSettings()
{
if (!m_mpvWidget || !m_mpvWidget->controller())
{
return;
}
const bool protectDanmakuPrimary =
m_danmakuController && m_danmakuController->isDanmakuEnabled() && m_danmakuController->hasDanmakuTrack();
SubtitleStyleUtils::applyToController(m_mpvWidget->controller(), protectDanmakuPrimary);
}
void PlayerView::resumePlaybackAfterFinishedSeek()
{
if (!m_isPlaybackFinished || !m_mpvWidget)
{
return;
}
qDebug() << "[PlayerView] Resume playback after finished seek"
<< "| position=" << m_currentPosition
<< "| duration=" << m_totalDuration;
m_isPlaybackFinished = false;
m_isPlaying = true;
m_isBuffering = false;
m_isSeeking = false;
m_mpvWidget->play();
updatePowerInhibition();
updateLoadingState();
if (m_playPauseBtn)
{
m_playPauseBtn->setIcon(QIcon(":/svg/player/pause.svg"));
}
}
void PlayerView::seekRelative(double delta, bool silent)
{
const bool shouldResumeFinishedPlayback = m_isPlaybackFinished && delta < 0.0;
m_mpvWidget->controller()->command(QVariantList{"seek", delta, "relative"});
if (shouldResumeFinishedPlayback)
{
resumePlaybackAfterFinishedSeek();
}
if (!silent)
{
m_osdSeekPreviewPosition = -1.0;
showToast(delta > 0 ? tr("Forward %1s").arg(std::abs(delta)) : tr("Rewind %1s").arg(std::abs(delta)));
showControls();
}
else
{
if (m_osdLayer && m_topOpacity && m_topOpacity->opacity() <= 0.0)
{
const bool continuePreview = m_osdLayer->isSeekLineVisible() && m_osdSeekPreviewPosition >= 0.0;
const double basePosition = continuePreview ? m_osdSeekPreviewPosition : m_currentPosition;
double previewPosition = basePosition + delta;
if (m_totalDuration > 0.0)
{
previewPosition = qBound(0.0, previewPosition, m_totalDuration);
}
else
{
previewPosition = qMax(0.0, previewPosition);
}
m_osdSeekPreviewPosition = previewPosition;
m_osdLayer->showSeek(previewPosition, m_totalDuration, formatTime(previewPosition, m_totalDuration));
}
}
}
void PlayerView::cycleVideoScale()
{
m_videoScaleMode = (m_videoScaleMode + 1) % 4;
auto *ctrl = m_mpvWidget->controller();
QString modeStr;
switch (m_videoScaleMode)
{
case 0:
ctrl->setProperty("keepaspect", true);
ctrl->setProperty("panscan", 0.0);
ctrl->setProperty("video-unscaled", false);
modeStr = tr("Scale: Fit");
break;
case 1:
ctrl->setProperty("keepaspect", true);
ctrl->setProperty("panscan", 1.0);
ctrl->setProperty("video-unscaled", false);
modeStr = tr("Scale: Crop");
break;
case 2:
ctrl->setProperty("keepaspect", false);
ctrl->setProperty("panscan", 0.0);
ctrl->setProperty("video-unscaled", false);
modeStr = tr("Scale: Stretch");
break;
case 3:
ctrl->setProperty("keepaspect", true);
ctrl->setProperty("panscan", 0.0);
ctrl->setProperty("video-unscaled", true);
modeStr = tr("Scale: Original");
break;
}
setScaleIcon();
showToast(modeStr);
ConfigStore::instance()->set(ConfigKeys::PlayerDefaultScale, m_videoScaleMode);
}
void PlayerView::setScaleIcon()
{
switch (m_videoScaleMode)
{
case 0:
m_scaleBtn->setIcon(QIcon(":/svg/player/scale-fit.svg"));
break;
case 1:
m_scaleBtn->setIcon(QIcon(":/svg/player/scale-crop.svg"));
break;
case 2:
m_scaleBtn->setIcon(QIcon(":/svg/player/scale-stretch.svg"));
break;
case 3:
m_scaleBtn->setIcon(QIcon(":/svg/player/scale-original.svg"));
break;
}
}
void PlayerView::showToast(const QString &msg)
{
if (m_isViewTearingDown)
{
return;
}
m_toastLabel->setText(msg);
m_toastLabel->show();
m_toastTimer->start(2500);
}
void PlayerView::updateStatisticsDisplay()
{
if (!m_statisticsOverlay || !m_mpvWidget || !m_mpvWidget->controller())
{
return;
}
auto *ctrl = m_mpvWidget->controller();
const QVariantList tracks = ctrl->getProperty("track-list").toList();
const QVariantMap selectedVideoTrack = findSelectedTrackByType(tracks, QStringLiteral("video"));
const QVariantMap selectedAudioTrack = findSelectedTrackByType(tracks, QStringLiteral("audio"));
const QVariantMap selectedSubtitleTrack = findSelectedTrackByType(tracks, QStringLiteral("sub"));
const MediaSourceInfo &source = m_currentMediaSourceInfo;
const MediaStreamInfo *videoStream = findFirstStreamByType(source, QStringLiteral("Video"));
const MediaStreamInfo *audioStream = findFirstStreamByType(source, QStringLiteral("Audio"));
auto addLine = [](QStringList &lines, const QString &label, const QString &value)
{ lines << (label + QStringLiteral(": ") + trimOrDash(value)); };
QStringList lines;
QString sourcePath = firstNonEmpty({source.path, m_currentMediaItem.path});
sourcePath = QUrl::fromPercentEncoding(sourcePath.toUtf8()).trimmed();
QString fileDisplay = sourcePath;
if (!fileDisplay.isEmpty())
{
const QFileInfo fileInfo(fileDisplay);
if (!fileInfo.fileName().isEmpty())
{
fileDisplay = fileInfo.fileName();
}
}
if (fileDisplay.isEmpty())
{
fileDisplay = firstNonEmpty({source.name, m_currentMediaItem.name, m_fullTitle});
}
addLine(lines, tr("File"), fileDisplay);
QStringList sessionParts;
if (!m_currentMediaId.isEmpty())
{
sessionParts << tr("Item ID") + QStringLiteral("=") + m_currentMediaId;
}
if (!m_currentMediaSourceId.isEmpty())
{
sessionParts << tr("Media Source ID") + QStringLiteral("=") + m_currentMediaSourceId;
}
if (!m_currentPlaySessionId.isEmpty())
{
sessionParts << tr("Play Session ID") + QStringLiteral("=") + m_currentPlaySessionId;
}
if (!sessionParts.isEmpty())
{
addLine(lines, tr("Session"), sessionParts.join(QStringLiteral(" ")));
}
const QVariantList chapterList = ctrl->getProperty("chapter-list").toList();
const int chapterIndex = ctrl->getProperty("chapter").toInt();
if (!chapterList.isEmpty() && chapterIndex >= 0 && chapterIndex < chapterList.size())
{
const QVariantMap chapterInfo = chapterList.at(chapterIndex).toMap();
QString chapterTitle = chapterInfo.value(QStringLiteral("title")).toString().trimmed();
if (chapterTitle.isEmpty())
{
chapterTitle = tr("Chapter") + QStringLiteral(" ") + QString::number(chapterIndex + 1);
}
chapterTitle += QStringLiteral(" (%1 / %2)").arg(chapterIndex + 1).arg(chapterList.size());
addLine(lines, tr("Chapter"), chapterTitle);
}
const qint64 fileSize = source.size > 0 ? source.size : m_currentMediaItem.size;
const QString container = firstNonEmpty({source.container.toUpper(), m_currentMediaItem.container.toUpper()});
addLine(
lines, tr("Size"),
joinNonEmpty({fileSize > 0 ? FileUtils::formatSize(fileSize) : QString(), container}, QStringLiteral(" ")));
const QVariantMap cacheState = ctrl->getProperty("demuxer-cache-state").toMap();
const qint64 cacheBytes = qMax(qMax(variantToLongLong(cacheState.value(QStringLiteral("fw-bytes"))),
variantToLongLong(cacheState.value(QStringLiteral("file-cache-bytes")))),
variantToLongLong(cacheState.value(QStringLiteral("total-bytes"))));
double cacheDuration = ctrl->getProperty("demuxer-cache-duration").toDouble();
if (cacheDuration <= 0.0)
{
cacheDuration = variantToDouble(cacheState.value(QStringLiteral("cache-duration")));
}
const qint64 cacheSpeed =
m_useRelayNetworkSpeed ? m_effectiveNetworkSpeed : ctrl->getProperty("cache-speed").toLongLong();
addLine(lines, tr("Cache"),
joinNonEmpty({cacheBytes > 0 ? FileUtils::formatSize(cacheBytes) : QString(),
formatDurationValue(cacheDuration), formatDataRateValue(cacheSpeed)},
QStringLiteral(" ")));
const QString voName = ctrl->getProperty("current-vo").toString().trimmed();
QString decoderName = ctrl->getProperty("hwdec-current").toString().trimmed();
if (decoderName.isEmpty() || decoderName.compare(QStringLiteral("no"), Qt::CaseInsensitive) == 0)
{
decoderName = tr("Software (CPU)");
}
else
{
decoderName = decoderName.toUpper();
}
QStringList playbackParts;
if (!voName.isEmpty())
{
playbackParts << voName;
}
if (!decoderName.isEmpty())
{
playbackParts << decoderName;
}
playbackParts << tr("Speed") + QStringLiteral(" ") + stripTrailingZeros(QString::number(m_currentSpeed, 'f', 2)) +
QStringLiteral("X");
const double avsync = ctrl->getProperty("avsync").toDouble();
const QString avsyncText = formatAvSyncValue(avsync);
if (!avsyncText.isEmpty())
{
playbackParts << tr("A-V") + QStringLiteral(" ") + avsyncText;
}
addLine(lines, tr("Playback"), playbackParts.join(QStringLiteral(" ")));
double displayFps = ctrl->getProperty("display-fps").toDouble();
if (displayFps <= 0.0)
{
displayFps = ctrl->getProperty("estimated-display-fps").toDouble();
}
double videoFps = ctrl->getProperty("container-fps").toDouble();
if (videoFps <= 0.0)
{
videoFps = selectedVideoTrack.value(QStringLiteral("demux-fps")).toDouble();
}
if (videoFps <= 0.0 && videoStream)
{
videoFps = videoStream->realFrameRate;
}
QStringList refreshParts;
if (displayFps > 0.0)
{
refreshParts << QStringLiteral("%1 Hz").arg(stripTrailingZeros(QString::number(displayFps, 'f', 2)));
}
const QString frameRateText = formatFrameRateValue(videoFps);
if (!frameRateText.isEmpty())
{
refreshParts << frameRateText;
}
addLine(lines, tr("Refresh Rate"), refreshParts.join(QStringLiteral(" ")));
const QString outputDrops = ctrl->getProperty("frame-drop-count").toString().trimmed();
const QString decoderDrops = ctrl->getProperty("decoder-frame-drop-count").toString().trimmed();
QStringList dropParts;
if (!outputDrops.isEmpty())
{
dropParts << tr("Output") + QStringLiteral(" ") + outputDrops;
}
if (!decoderDrops.isEmpty())
{
dropParts << tr("Decoder") + QStringLiteral(" ") + decoderDrops;
}
addLine(lines, tr("Dropped Frames"), dropParts.join(QStringLiteral(" ")));
lines << QString();
const QString videoCodec = firstNonEmpty({ctrl->getProperty("video-codec").toString().toUpper(),
selectedVideoTrack.value(QStringLiteral("codec")).toString().toUpper(),
videoStream ? videoStream->codec.toUpper() : QString()});
const QString videoFormat = ctrl->getProperty("video-format").toString().toUpper().trimmed();
const QString videoProfile = videoStream ? videoStream->profile.trimmed() : QString();
const QString videoLevel = videoStream ? formatLevelValue(videoStream->level) : QString();
QStringList videoCodecParts;
if (!videoCodec.isEmpty())
{
videoCodecParts << videoCodec;
}
if (!videoFormat.isEmpty())
{
videoCodecParts << videoFormat;
}
if (!videoProfile.isEmpty())
{
videoCodecParts << videoProfile;
}
if (!videoLevel.isEmpty())
{
videoCodecParts << QStringLiteral("L%1").arg(videoLevel);
}
addLine(lines, tr("Video"), videoCodecParts.join(QStringLiteral(" / ")));
addLine(lines, tr("Frame Rate"), frameRateText);
int sourceWidth = ctrl->getProperty("width").toInt();
int sourceHeight = ctrl->getProperty("height").toInt();
if ((sourceWidth <= 0 || sourceHeight <= 0) && videoStream)
{
sourceWidth = videoStream->width;
sourceHeight = videoStream->height;
}
const double displayWidthRaw = ctrl->getProperty("video-params/dw").toDouble();
const double displayHeightRaw = ctrl->getProperty("video-params/dh").toDouble();
const int displayWidth = displayWidthRaw > 0.0 ? qRound(displayWidthRaw) : sourceWidth;
const int displayHeight = displayHeightRaw > 0.0 ? qRound(displayHeightRaw) : sourceHeight;
QString resolutionText = formatDimensionValue(sourceWidth, sourceHeight);
const QString displayDimensions = formatDimensionValue(displayWidth, displayHeight);
if (resolutionText.isEmpty())
{
resolutionText = displayDimensions;
}
else if (!displayDimensions.isEmpty() && displayDimensions != resolutionText)
{
resolutionText += QStringLiteral(" -> ") + displayDimensions;
}
const QString aspectText =
formatAspectValue(displayWidth, displayHeight, videoStream ? videoStream->aspectRatio : QString());
if (!aspectText.isEmpty())
{
resolutionText += QStringLiteral(" (%1)").arg(aspectText);
}
addLine(lines, tr("Resolution"), resolutionText);
const QString pixelFormat = firstNonEmpty(
{ctrl->getProperty("video-params/pixelformat").toString(), videoStream ? videoStream->pixelFormat : QString()});
const QString colorLevels = ctrl->getProperty("video-params/colorlevels").toString().trimmed();
const int bitDepth = videoStream ? videoStream->bitDepth : 0;
addLine(lines, tr("Format"),
joinNonEmpty({pixelFormat, colorLevels, bitDepth > 0 ? tr("%1-bit").arg(bitDepth) : QString()},
QStringLiteral(" ")));
const QString colorMatrix = ctrl->getProperty("video-params/colormatrix").toString().trimmed();
const QString colorPrimaries = ctrl->getProperty("video-params/primaries").toString().trimmed();
const QString colorTransfer = ctrl->getProperty("video-params/gamma").toString().trimmed();
addLine(lines, tr("Color"), joinNonEmpty({colorMatrix, colorPrimaries, colorTransfer}, QStringLiteral(" ")));
qint64 videoBitrate = ctrl->getProperty("video-bitrate").toLongLong();
if (videoBitrate <= 0 && videoStream)
{
videoBitrate = videoStream->bitRate;
}
if (videoBitrate <= 0)
{
videoBitrate = m_currentMediaItem.bitrate;
}
addLine(lines, tr("Bitrate"), formatBitrateValue(videoBitrate));
lines << QString();
const QString audioCodec = firstNonEmpty({ctrl->getProperty("audio-codec").toString().toUpper(),
selectedAudioTrack.value(QStringLiteral("codec")).toString().toUpper(),
audioStream ? audioStream->codec.toUpper() : QString()});
const QString currentAo = ctrl->getProperty("current-ao").toString().trimmed();
addLine(lines, tr("Audio"),
joinNonEmpty({audioCodec, !currentAo.isEmpty() ? tr("AO %1").arg(currentAo) : QString()},
QStringLiteral(" ")));
addLine(lines, tr("Device"), ctrl->getProperty("audio-device").toString());
const int channelCount =
qMax(ctrl->getProperty("audio-params/channel-count").toInt(), audioStream ? audioStream->channels : 0);
const QString channelLayout = firstNonEmpty({ctrl->getProperty("audio-params/channels").toString(),
selectedAudioTrack.value(QStringLiteral("demux-channels")).toString(),
audioStream ? audioStream->channelLayout : QString()});
addLine(lines, tr("Channels"),
joinNonEmpty({channelCount > 0 ? tr("%1 ch").arg(channelCount) : QString(), channelLayout},
QStringLiteral(" ")));
int sampleRate = ctrl->getProperty("audio-params/samplerate").toInt();
if (sampleRate <= 0)
{
sampleRate = selectedAudioTrack.value(QStringLiteral("demux-samplerate")).toInt();
}
if (sampleRate <= 0 && audioStream)
{
sampleRate = audioStream->sampleRate;
}
addLine(lines, tr("Sample Rate"), sampleRate > 0 ? QStringLiteral("%1 Hz").arg(sampleRate) : QString());
qint64 audioBitrate = ctrl->getProperty("audio-bitrate").toLongLong();
if (audioBitrate <= 0 && audioStream)
{
audioBitrate = audioStream->bitRate;
}
addLine(lines, tr("Bitrate"), formatBitrateValue(audioBitrate));
if (!selectedSubtitleTrack.isEmpty())
{
const QString subtitleLabel = firstNonEmpty({selectedSubtitleTrack.value(QStringLiteral("title")).toString(),
selectedSubtitleTrack.value(QStringLiteral("lang")).toString()});
if (!subtitleLabel.isEmpty())
{
addLine(lines, tr("Subtitle"), subtitleLabel);
}
}
m_statisticsOverlay->setLines(lines);
if (m_showStatisticsOverlay)
{
updateOverlayLayout();
}
}
void PlayerView::toggleFullscreenWindow()
{
if (window()->isFullScreen())
{
window()->showNormal();
}
else
{
window()->showFullScreen();
}
}
QCoro::Task<void> PlayerView::executeFetchLogo(QPointer<PlayerView> safeThis, QEmbyCore *core, QString mediaId)
{
try
{
MediaItem detail = co_await core->mediaService()->getItemDetail(mediaId);
if (!safeThis || safeThis->m_currentMediaId != mediaId)
{
co_return;
}
if (!detail.images.logoTag.isEmpty())
{
QPixmap pix = co_await core->mediaService()->fetchImage(mediaId, "Logo", detail.images.logoTag, 400);
if (safeThis && safeThis->m_currentMediaId == mediaId && !pix.isNull())
{
QPixmap scaledPix = pix.scaled(140, 55, Qt::KeepAspectRatio, Qt::SmoothTransformation);
safeThis->m_logoLabel->setPixmap(scaledPix);
safeThis->m_logoLabel->adjustSize();
safeThis->m_logoOpacity->setOpacity(safeThis->m_topOpacity->opacity());
safeThis->m_logoLabel->show();
safeThis->updateOverlayLayout();
}
}
}
catch (...)
{
}
}
void PlayerView::playMedia(const QString &mediaId, const QString &title, const QString &streamUrl,
long long startPositionTicks, const QVariant &sourceInfoVar)
{
PlayerLaunchContext launchContext;
MediaSourceInfo resolvedSourceInfo;
MediaItem resolvedItem;
if (sourceInfoVar.isValid() && sourceInfoVar.canConvert<PlayerLaunchContext>())
{
launchContext = sourceInfoVar.value<PlayerLaunchContext>();
resolvedItem = launchContext.mediaItem;
resolvedSourceInfo = launchContext.selectedSource;
}
else if (sourceInfoVar.isValid() && sourceInfoVar.canConvert<MediaSourceInfo>())
{
resolvedSourceInfo = sourceInfoVar.value<MediaSourceInfo>();
}
connect(m_mpvWidget, &MpvWidget::positionChanged, this, &PlayerView::onPositionChanged, Qt::UniqueConnection);
m_hasReportedStop = false;
m_isPlaybackFinished = false;
m_autoPlayAdvanceInProgress = false;
m_currentMediaId = mediaId;
m_currentMediaSourceId = mediaId;
m_currentPlaySessionId.clear();
m_currentMediaItem = resolvedItem;
m_currentMediaSourceInfo = resolvedSourceInfo;
hideRightSidebar(true);
hideHudMediaSwitcher();
if (m_activePopup)
{
m_activePopup->deleteLater();
m_activePopup = nullptr;
}
closeActivePlayerDialog();
m_logoLabel->clear();
m_logoLabel->hide();
m_originalStreamUrl = streamUrl;
m_currentSourceInfoVar = sourceInfoVar;
QString actualStreamUrl = streamUrl;
if (!resolvedSourceInfo.id.isEmpty())
{
QString directUrl = m_core->mediaService()->getStreamUrl(mediaId, resolvedSourceInfo);
if (!directUrl.isEmpty())
{
actualStreamUrl = directUrl;
}
}
QUrl streamQUrl(actualStreamUrl);
QUrl origQUrl(streamUrl);
QUrlQuery query(streamQUrl);
if (!query.hasQueryItem("mediaSourceId") && QUrlQuery(origQUrl).hasQueryItem("mediaSourceId"))
{
query = QUrlQuery(origQUrl);
}
if (query.hasQueryItem("mediaSourceId"))
{
m_currentMediaSourceId = query.queryItemValue("mediaSourceId");
}
else if (query.hasQueryItem("MediaSourceId"))
{
m_currentMediaSourceId = query.queryItemValue("MediaSourceId");
}
if (m_currentMediaSourceInfo.id.isEmpty() && !m_currentMediaItem.mediaSources.isEmpty())
{
for (const MediaSourceInfo &candidate : m_currentMediaItem.mediaSources)
{
if (!m_currentMediaSourceId.isEmpty() &&
candidate.id.compare(m_currentMediaSourceId, Qt::CaseInsensitive) == 0)
{
m_currentMediaSourceInfo = candidate;
break;
}
}
if (m_currentMediaSourceInfo.id.isEmpty())
{
m_currentMediaSourceInfo = m_currentMediaItem.mediaSources.first();
}
}
m_pendingSeekSeconds = startPositionTicks / 10000000.0;
QString displayTitle = title.trimmed();
if (!m_currentMediaItem.id.isEmpty())
{
const QString resolvedTitle = MediaItemUtils::playbackTitle(m_currentMediaItem, displayTitle).trimmed();
if (!resolvedTitle.isEmpty())
{
displayTitle = resolvedTitle;
}
}
if (displayTitle.isEmpty())
{
displayTitle = title;
}
m_fullTitle = displayTitle;
Q_EMIT playbackTitleChanged(m_fullTitle);
updateTitleElision();
m_currentPosition = m_pendingSeekSeconds;
m_totalDuration = 0.0;
if (m_statisticsOverlay)
{
if (m_showStatisticsOverlay)
{
updateStatisticsDisplay();
m_statisticsOverlay->show();
}
else
{
m_statisticsOverlay->hide();
}
}
QWidget *win = window();
if (win)
{
m_wasMaximized = win->isMaximized();
m_originalGeometry = win->geometry();
m_hasSetVideoSize = false;
if (m_wasMaximized)
{
win->showFullScreen();
}
}
m_isSeriesMode = false;
m_seriesId.clear();
m_seriesName.clear();
m_episodeSegments = {};
m_introSkipped = false;
m_outroSkipped = false;
m_segmentsRequested = false;
clearMediaSwitcherCache();
updateMediaSwitcherButton();
if (resolvedItem.type == "Episode" && !resolvedItem.seriesId.isEmpty())
{
m_isSeriesMode = true;
m_seriesId = resolvedItem.seriesId;
m_seriesName = resolvedItem.seriesName;
updateMediaSwitcherButton();
requestIntroDBSegments();
ensureMediaSwitcherDataLoaded();
}
else
{
auto detectSeriesMode = [](QPointer<PlayerView> safeThis, QEmbyCore *core, QString mId) -> QCoro::Task<void>
{
try
{
MediaItem detail = co_await core->mediaService()->getItemDetail(mId);
if (!safeThis || safeThis->m_currentMediaId != mId)
co_return;
if (detail.type == "Episode" && !detail.seriesId.isEmpty())
{
safeThis->m_isSeriesMode = true;
safeThis->m_seriesId = detail.seriesId;
safeThis->m_seriesName = detail.seriesName;
safeThis->m_currentMediaItem = detail;
safeThis->clearMediaSwitcherCache();
safeThis->updateMediaSwitcherButton();
safeThis->requestIntroDBSegments();
safeThis->ensureMediaSwitcherDataLoaded();
if (safeThis->useHudMediaSwitcher() && safeThis->m_mediaSwitchDrawer &&
safeThis->m_mediaSwitchDrawer->isVisible())
{
safeThis->showHudMediaSwitcher();
}
else if (safeThis->m_isRightSidebarVisible)
{
safeThis->showRightSidebar();
}
}
}
catch (...)
{
}
};
detectSeriesMode(QPointer<PlayerView>(this), m_core, mediaId);
ensureMediaSwitcherDataLoaded();
}
executeFetchLogo(QPointer<PlayerView>(this), m_core, mediaId);
const QString activeServerId = m_core->serverManager()->activeProfile().id;
m_mpvWidget->loadMedia(actualStreamUrl, activeServerId);
if (!resolvedItem.id.isEmpty() || !resolvedSourceInfo.id.isEmpty())
{
if (resolvedItem.id.isEmpty())
{
resolvedItem.id = mediaId;
resolvedItem.name = title;
}
PlayerLaunchContext danmakuContext;
danmakuContext.mediaItem = resolvedItem;
danmakuContext.selectedSource = resolvedSourceInfo;
qDebug().noquote() << "[Danmaku][PlayerView] Prepared playback context"
<< "| mediaId:" << danmakuContext.mediaItem.id
<< "| sourceId:" << danmakuContext.selectedSource.id
<< "| title:" << danmakuContext.mediaItem.name;
m_danmakuController->setPlaybackContext(danmakuContext);
}
else
{
qDebug().noquote() << "[Danmaku][PlayerView] No playback context available for danmaku"
<< "| mediaId:" << mediaId << "| sourceInfoValid:" << sourceInfoVar.isValid();
m_danmakuController->clearPlaybackContext();
}
m_isBuffering = true;
m_isSeeking = false;
updateLoadingState();
m_targetAudioStreamIndex = -2;
m_targetSubStreamIndex = -2;
QVariantList pendingSubtitles;
if (!resolvedSourceInfo.id.isEmpty())
{
MediaSourceInfo sourceInfo = resolvedSourceInfo;
m_targetAudioStreamIndex = -2;
m_targetSubStreamIndex = -2;
bool hasExternalDefault = false;
bool foundDefaultAudio = false;
for (const auto &stream : sourceInfo.mediaStreams)
{
if (stream.type == "Audio" && stream.isDefault)
{
m_targetAudioStreamIndex = stream.index;
foundDefaultAudio = true;
}
else if (stream.type == "Subtitle" && stream.isDefault)
{
if (stream.isExternal)
{
hasExternalDefault = true;
}
else
{
m_targetSubStreamIndex = stream.index;
}
}
}
if (hasExternalDefault)
{
m_targetSubStreamIndex = -2;
}
QString tokenQuery;
if (query.hasQueryItem("api_key"))
{
tokenQuery = "api_key=" + query.queryItemValue("api_key");
}
else if (query.hasQueryItem("X-Emby-Token"))
{
tokenQuery = "X-Emby-Token=" + query.queryItemValue("X-Emby-Token");
}
else if (query.hasQueryItem("api_token"))
{
tokenQuery = "api_token=" + query.queryItemValue("api_token");
}
QString baseUrl;
int videosIdx = streamUrl.indexOf("/videos/", 0, Qt::CaseInsensitive);
if (videosIdx != -1)
{
baseUrl = streamUrl.left(videosIdx);
}
else
{
baseUrl = origQUrl.scheme() + "://" + origQUrl.authority();
}
for (const auto &stream : sourceInfo.mediaStreams)
{
if (stream.type == "Subtitle" && stream.isExternal)
{
QString subUrl = stream.deliveryUrl;
if (subUrl.isEmpty())
{
QString codec = stream.codec.toLower();
if (codec.isEmpty() || codec == "subrip")
{
codec = "srt";
}
subUrl = QString("/Videos/%1/%2/Subtitles/%3/Stream.%4")
.arg(mediaId)
.arg(sourceInfo.id)
.arg(stream.index)
.arg(codec);
}
if (!subUrl.startsWith("http"))
{
if (!subUrl.startsWith("/"))
{
subUrl = "/" + subUrl;
}
subUrl = baseUrl + subUrl;
if (!tokenQuery.isEmpty())
{
subUrl += (subUrl.contains("?") ? "&" : "?") + tokenQuery;
}
}
QString subTitle = stream.displayTitle.isEmpty() ? stream.language : stream.displayTitle;
if (subTitle.isEmpty())
{
subTitle = tr("External Sub %1").arg(stream.index);
}
QString flag = stream.isDefault ? "select" : "auto";
QVariantMap subMap;
subMap["url"] = subUrl;
subMap["flag"] = flag;
subMap["title"] = subTitle;
subMap["lang"] = stream.language;
pendingSubtitles.append(subMap);
}
}
}
setProperty("pendingSubtitles", pendingSubtitles);
m_mpvWidget->play();
m_isPlaying = true;
updatePowerInhibition();
m_playPauseBtn->setIcon(QIcon(":/svg/player/pause.svg"));
m_currentVolume = ConfigStore::instance()->get<double>(ConfigKeys::PlayerVolumeLevel, 100.0);
m_isMuted = ConfigStore::instance()->get<bool>(ConfigKeys::PlayerVolumeMuted, false);
m_mpvWidget->controller()->setProperty("volume", m_currentVolume);
m_mpvWidget->controller()->setProperty("mute", m_isMuted);
m_volumeSlider->blockSignals(true);
m_volumeSlider->setValue(m_isMuted ? 0 : static_cast<int>(m_currentVolume));
m_volumeSlider->blockSignals(false);
m_volumeBtn->setIcon(QIcon(m_isMuted ? ":/svg/player/volume-mute.svg" : ":/svg/player/volume.svg"));
m_videoScaleMode = ConfigStore::instance()->get<int>(ConfigKeys::PlayerDefaultScale, 1);
setScaleIcon();
switch (m_videoScaleMode)
{
case 0:
m_mpvWidget->controller()->setProperty("keepaspect", true);
m_mpvWidget->controller()->setProperty("panscan", 0.0);
m_mpvWidget->controller()->setProperty("video-unscaled", false);
break;
case 1:
m_mpvWidget->controller()->setProperty("keepaspect", true);
m_mpvWidget->controller()->setProperty("panscan", 1.0);
m_mpvWidget->controller()->setProperty("video-unscaled", false);
break;
case 2:
m_mpvWidget->controller()->setProperty("keepaspect", false);
m_mpvWidget->controller()->setProperty("panscan", 0.0);
m_mpvWidget->controller()->setProperty("video-unscaled", false);
break;
case 3:
m_mpvWidget->controller()->setProperty("keepaspect", true);
m_mpvWidget->controller()->setProperty("panscan", 0.0);
m_mpvWidget->controller()->setProperty("video-unscaled", true);
break;
}
auto startSessionTask = [](QPointer<PlayerView> safeThis, MediaService *s, QString mId, QString sId,
long long ticks) -> QCoro::Task<void>
{
QString sessionId = co_await s->reportPlaybackStart(mId, sId, ticks);
if (safeThis && safeThis->m_currentMediaId == mId)
{
safeThis->m_currentPlaySessionId = sessionId;
}
};
startSessionTask(QPointer<PlayerView>(this), m_core->mediaService(), m_currentMediaId, m_currentMediaSourceId,
startPositionTicks);
m_reportTimer->start();
m_mousePollTimer->start();
m_bufferTimer->start();
showControls();
}
void PlayerView::reportProgressToServer()
{
if (m_isViewTearingDown || m_currentMediaId.isEmpty() || m_hasReportedStop || m_currentPlaySessionId.isEmpty())
{
return;
}
long long currentTicks = static_cast<long long>(m_currentPosition * 10000000.0);
m_core->mediaService()->reportPlaybackProgress(m_currentMediaId, m_currentMediaSourceId, currentTicks, !m_isPlaying,
m_currentPlaySessionId);
if (m_showStatisticsOverlay)
{
updateStatisticsDisplay();
}
}
void PlayerView::onBackClicked()
{
if (m_isViewTearingDown)
{
return;
}
prepareForStackLeave();
Q_EMIT navigateBack();
QWidget *win = window();
bool isEmbedded = qobject_cast<QMainWindow *>(win) != nullptr;
bool wasMaximized = m_wasMaximized;
QRect originalGeo = m_originalGeometry;
if (win)
{
QPointer<QWidget> safeWin(win);
QTimer::singleShot(0, this,
[safeWin, isEmbedded, wasMaximized, originalGeo]()
{
if (!safeWin)
return;
if (isEmbedded)
{
if (safeWin->isFullScreen())
{
safeWin->showMaximized();
}
}
else
{
if (safeWin->isFullScreen())
{
safeWin->showNormal();
}
if (wasMaximized)
{
safeWin->showMaximized();
}
else if (originalGeo.isValid())
{
safeWin->setGeometry(originalGeo);
}
}
});
}
}
void PlayerView::onPositionChanged(double position)
{
if (m_isViewTearingDown)
{
return;
}
if (std::isnan(position) || std::isinf(position) || position < 0)
{
position = 0.0;
}
m_currentPosition = position;
checkAndSkipSegment(position);
if (!m_progressSlider->isSliderDown())
{
m_progressSlider->blockSignals(true);
m_progressSlider->setValue(static_cast<int>(position));
m_progressSlider->blockSignals(false);
}
m_currentTimeLabel->setText(formatTime(position, m_totalDuration));
if (m_osdLayer && m_osdLayer->isSeekLineVisible())
{
if (m_osdSeekPreviewPosition < 0.0 || std::abs(position - m_osdSeekPreviewPosition) <= 1.0)
{
m_osdSeekPreviewPosition = position;
m_osdLayer->updateSeekPosition(static_cast<int>(position), formatTime(position, m_totalDuration));
}
}
}
void PlayerView::onDurationChanged(double duration)
{
if (m_isViewTearingDown)
{
return;
}
if (std::isnan(duration) || std::isinf(duration) || duration < 0)
{
duration = 0.0;
}
m_totalDuration = duration;
m_progressSlider->setMaximum(static_cast<int>(duration));
m_totalTimeLabel->setText(formatTime(duration, duration));
if (!m_hasSetVideoSize && duration > 0)
{
m_hasSetVideoSize = true;
double dw = m_mpvWidget->controller()->getProperty("video-params/dw").toDouble();
double dh = m_mpvWidget->controller()->getProperty("video-params/dh").toDouble();
if (dw <= 0 || dh <= 0)
{
dw = m_mpvWidget->controller()->getProperty("width").toDouble();
dh = m_mpvWidget->controller()->getProperty("height").toDouble();
}
QWidget *win = window();
if (win && !m_wasMaximized && !win->isFullScreen() && !qobject_cast<QMainWindow *>(win))
{
double aspect = (dw > 0 && dh > 0) ? (dw / dh) : (16.0 / 9.0);
int playerW = this->width();
if (playerW < 100)
{
playerW = win->width();
}
int targetPlayerH = qRound(playerW / aspect);
if (playerW % 2 != 0)
{
playerW++;
}
if (targetPlayerH % 2 != 0)
{
targetPlayerH++;
}
int deltaH = targetPlayerH - this->height();
int targetWinW = win->width() + (playerW - this->width());
int targetWinH = win->height() + deltaH;
QRect currentGeo = win->geometry();
win->setGeometry(currentGeo.center().x() - targetWinW / 2, currentGeo.center().y() - targetWinH / 2,
targetWinW, targetWinH);
}
if (m_targetAudioStreamIndex != -2 || m_targetSubStreamIndex != -2)
{
QVariantList tracks = m_mpvWidget->controller()->getProperty("track-list").toList();
if (m_targetAudioStreamIndex == -1)
{
m_mpvWidget->controller()->setProperty("aid", "no");
}
if (m_targetSubStreamIndex == -1)
{
m_mpvWidget->controller()->setProperty("sid", "no");
}
for (const QVariant &v : tracks)
{
QVariantMap map = v.toMap();
QString type = map["type"].toString();
int ffIndex = map["ff-index"].toInt();
int mpvId = map["id"].toInt();
if (type == "audio" && m_targetAudioStreamIndex >= 0 && ffIndex == m_targetAudioStreamIndex)
{
m_mpvWidget->controller()->setProperty("aid", mpvId);
}
if (type == "sub" && m_targetSubStreamIndex >= 0 && ffIndex == m_targetSubStreamIndex)
{
m_mpvWidget->controller()->setProperty("sid", mpvId);
}
}
}
QVariantList pendingSubs = property("pendingSubtitles").toList();
for (const QVariant &v : pendingSubs)
{
QVariantMap map = v.toMap();
m_mpvWidget->controller()->command(QVariantList{"sub-add", map["url"].toString(), map["flag"].toString(),
map["title"].toString(), map["lang"].toString()});
}
setProperty("pendingSubtitles", QVariantList());
applyPersistedExternalSubtitleIfAny();
}
if (m_pendingSeekSeconds > 0 && duration > 0)
{
m_mpvWidget->seek(m_pendingSeekSeconds);
m_pendingSeekSeconds = 0.0;
}
}
void PlayerView::onPlaybackStateChanged(bool isPaused)
{
if (m_isViewTearingDown)
{
return;
}
m_isPlaying = !isPaused;
updatePowerInhibition();
m_playPauseBtn->setIcon(QIcon(m_isPlaying ? ":/svg/player/pause.svg" : ":/svg/player/play.svg"));
showControls();
if (!m_hasReportedStop && !m_currentMediaId.isEmpty() && !m_currentPlaySessionId.isEmpty())
{
long long currentTicks = static_cast<long long>(m_currentPosition * 10000000.0);
m_core->mediaService()->reportPlaybackProgress(m_currentMediaId, m_currentMediaSourceId, currentTicks, isPaused,
m_currentPlaySessionId);
}
}
void PlayerView::togglePlayPause()
{
if (m_isPlaying)
{
m_mpvWidget->pause();
}
else if (m_isPlaybackFinished)
{
m_currentPosition = 0.0;
m_mpvWidget->seek(0.0);
if (m_progressSlider)
{
m_progressSlider->blockSignals(true);
m_progressSlider->setValue(0);
m_progressSlider->blockSignals(false);
}
m_currentTimeLabel->setText(formatTime(m_currentPosition, m_totalDuration));
resumePlaybackAfterFinishedSeek();
}
else
{
m_mpvWidget->play();
}
showControls();
}
void PlayerView::onSliderMoved(int value)
{
const bool shouldResumeFinishedPlayback =
m_isPlaybackFinished && (!m_progressSlider || value < m_progressSlider->maximum());
m_mpvWidget->seek(static_cast<double>(value));
m_currentPosition = static_cast<double>(value);
m_currentTimeLabel->setText(formatTime(m_currentPosition, m_totalDuration));
if (shouldResumeFinishedPlayback)
{
resumePlaybackAfterFinishedSeek();
}
if (!m_hasReportedStop && !m_currentMediaId.isEmpty() && !m_currentPlaySessionId.isEmpty())
{
long long currentTicks = static_cast<long long>(value * 10000000.0);
m_core->mediaService()->reportPlaybackProgress(m_currentMediaId, m_currentMediaSourceId, currentTicks,
!m_isPlaying, m_currentPlaySessionId);
}
showControls();
}
QString PlayerView::formatTime(double seconds, double totalSeconds) const
{
QTime t(0, 0, 0);
t = t.addSecs(static_cast<int>(seconds));
QTime totalT(0, 0, 0);
totalT = totalT.addSecs(static_cast<int>(totalSeconds));
if (totalT.hour() > 0)
{
return t.toString("hh:mm:ss");
}
else
{
return t.toString("mm:ss");
}
}