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.
5441 lines
166 KiB
C++
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");
|
|
}
|
|
}
|