#include "playbackmanager.h" #include #include #include #include #include #include #include #include "../components/playerwindow.h" #include "../components/moderntoast.h" #include "../utils/logredactionutils.h" #include "../utils/playerpreferenceutils.h" #include #include #include #include #include #include #include static constexpr long long TICKS_PER_SECOND = 10000000LL; static constexpr int PROGRESS_REPORT_INTERVAL_MS = 10000; PlaybackManager* PlaybackManager::instance() { static PlaybackManager s_instance; return &s_instance; } PlaybackManager::PlaybackManager(QObject *parent) : QObject(parent) { } void PlaybackManager::init(QEmbyCore* core) { m_core = core; } void PlaybackManager::startPlayback(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData) { if (!m_core) return; if (m_independentWindow) { m_independentWindow->close(); } qDebug() << "[PlaybackManager] ===== startPlayback ====="; qDebug() << "[PlaybackManager] mediaId:" << mediaId; qDebug() << "[PlaybackManager] title:" << title; qDebug() << "[PlaybackManager] streamUrl:" << LogRedactionUtils::url(streamUrl); qDebug() << "[PlaybackManager] startPositionTicks:" << startPositionTicks << "(" << ticksToTimeString(startPositionTicks) << ")"; bool extEnabled = ConfigStore::instance()->get(ConfigKeys::ExtPlayerEnable, false); bool quickPlayExt = ConfigStore::instance()->get(ConfigKeys::ExtPlayerQuickPlay, false); if (extEnabled && quickPlayExt) { QString playerPath = ConfigStore::instance()->get(ConfigKeys::ExtPlayerPath); if (!playerPath.isEmpty() && QFile::exists(playerPath)) { qDebug() << "[PlaybackManager] → Mode: External player (quick play)"; launchExternalPlayer(mediaId, title, streamUrl, startPositionTicks, extraData); return; } qDebug() << "[PlaybackManager] External player path invalid, falling back"; ModernToast::showMessage(tr("External player not found, falling back to internal player"), 2000); } bool independent = ConfigStore::instance()->get(ConfigKeys::PlayerIndependentWindow, false); if (independent) { qDebug() << "[PlaybackManager] → Mode: Independent window"; launchIndependentWindow(mediaId, title, streamUrl, startPositionTicks, extraData); return; } qDebug() << "[PlaybackManager] → Mode: Embedded player"; Q_EMIT requestEmbeddedPlay(mediaId, title, streamUrl, startPositionTicks, extraData); } void PlaybackManager::startInternalPlayback(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData) { if (!m_core) return; if (m_independentWindow) { m_independentWindow->close(); } qDebug() << "[PlaybackManager] ===== startInternalPlayback ====="; qDebug() << "[PlaybackManager] mediaId:" << mediaId << "title:" << title; qDebug() << "[PlaybackManager] streamUrl:" << LogRedactionUtils::url(streamUrl); bool independent = ConfigStore::instance()->get(ConfigKeys::PlayerIndependentWindow, false); if (independent) { qDebug() << "[PlaybackManager] → Mode: Independent window (internal)"; launchIndependentWindow(mediaId, title, streamUrl, startPositionTicks, extraData); return; } qDebug() << "[PlaybackManager] → Mode: Embedded player (internal)"; Q_EMIT requestEmbeddedPlay(mediaId, title, streamUrl, startPositionTicks, extraData); } void PlaybackManager::stopExternalPlayer() { if (m_extProcess && m_extProcess->state() != QProcess::NotRunning) { m_extProcess->kill(); m_extProcess->waitForFinished(3000); } cleanupExternalPlayerState(); } void PlaybackManager::cleanupExternalPlayerState() { if (m_progressTimer) { m_progressTimer->stop(); m_progressTimer->deleteLater(); m_progressTimer = nullptr; } if (m_ipc) { m_ipc->stop(); m_ipc->deleteLater(); m_ipc = nullptr; } m_currentMediaId.clear(); m_currentMediaSourceId.clear(); m_currentPlaySessionId.clear(); } PlayerType PlaybackManager::identifyPlayerType(const QString& playerPath) { QString baseName = QFileInfo(playerPath).baseName().toLower(); if (baseName == "vlc") return PlayerType::VLC; if (baseName == "mpv") return PlayerType::MPV; if (baseName == "iina") return PlayerType::IINA; if (baseName.contains("mpc-qt")) return PlayerType::MPC_Qt; return PlayerType::Unknown; } QString PlaybackManager::ticksToTimeString(long long ticks) { long long totalSeconds = ticks / TICKS_PER_SECOND; int h = static_cast(totalSeconds / 3600); int m = static_cast((totalSeconds % 3600) / 60); int s = static_cast(totalSeconds % 60); return QString("%1:%2:%3") .arg(h, 2, 10, QChar('0')) .arg(m, 2, 10, QChar('0')) .arg(s, 2, 10, QChar('0')); } double PlaybackManager::ticksToSeconds(long long ticks) { return static_cast(ticks) / TICKS_PER_SECOND; } int PlaybackManager::computeRelativeStreamIndex(const MediaSourceInfo& source, const QString& streamType, int globalIndex) { int count = 0; for (const auto& stream : source.mediaStreams) { if (stream.type == streamType) { if (stream.index == globalIndex) return count; count++; } } return -1; } QString PlaybackManager::generateIpcEndpoint(PlayerType type) { qint64 pid = QCoreApplication::applicationPid(); switch (type) { case PlayerType::MPV: case PlayerType::IINA: return QString("/tmp/qemby-mpv-%1.sock").arg(pid); case PlayerType::VLC: { int port = 42900 + static_cast(pid % 1000); return QString::number(port); } default: return {}; } } QStringList PlaybackManager::buildPlayerArgs(PlayerType type, long long startPositionTicks, int audioStreamIndex, int subtitleStreamIndex, bool subtitleDisabled, const MediaSourceInfo* sourceInfo) { QStringList args; if (startPositionTicks <= 0 && audioStreamIndex < 0 && subtitleStreamIndex < 0) return args; QString timeStr = ticksToTimeString(startPositionTicks); double seconds = ticksToSeconds(startPositionTicks); int relAudio = -1; int relSub = -1; if (sourceInfo) { if (audioStreamIndex >= 0) relAudio = computeRelativeStreamIndex(*sourceInfo, "Audio", audioStreamIndex); if (subtitleStreamIndex >= 0) relSub = computeRelativeStreamIndex(*sourceInfo, "Subtitle", subtitleStreamIndex); } switch (type) { case PlayerType::MPV: if (startPositionTicks > 0) args << QString("--start=%1").arg(timeStr); if (relAudio >= 0) args << QString("--aid=%1").arg(relAudio + 1); if (relSub >= 0) args << QString("--sid=%1").arg(relSub + 1); else if (subtitleDisabled) args << "--sid=no"; break; case PlayerType::IINA: if (startPositionTicks > 0) args << QString("--mpv-start=%1").arg(timeStr); if (relAudio >= 0) args << QString("--mpv-aid=%1").arg(relAudio + 1); if (relSub >= 0) args << QString("--mpv-sid=%1").arg(relSub + 1); else if (subtitleDisabled) args << "--mpv-sid=no"; break; case PlayerType::VLC: if (startPositionTicks > 0) args << QString("--start-time=%1").arg(static_cast(seconds)); if (relAudio >= 0) args << QString("--audio-track=%1").arg(relAudio); if (relSub >= 0) args << QString("--sub-track=%1").arg(relSub); break; default: break; } return args; } void PlaybackManager::startExternalPlayback(const QString& playerPath, const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData) { if (!m_core) return; if (playerPath.isEmpty() || !QFile::exists(playerPath)) { ModernToast::showMessage(tr("External player not found"), 2000); return; } launchExternalPlayer(mediaId, title, streamUrl, startPositionTicks, extraData, playerPath); } void PlaybackManager::launchExternalPlayer(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData, const QString& playerPathOverride) { stopExternalPlayer(); QString playerPath = playerPathOverride.isEmpty() ? ConfigStore::instance()->get(ConfigKeys::ExtPlayerPath) : playerPathOverride; QString argsStr = ConfigStore::instance()->get(ConfigKeys::ExtPlayerArgs); PlayerType playerType = identifyPlayerType(playerPath); int selectedAudioIdx = -1; int selectedSubIdx = -1; bool subtitleDisabled = false; QString mediaSourceId = mediaId; MediaSourceInfo sourceInfo; bool hasSourceInfo = false; if (extraData.canConvert()) { sourceInfo = extraData.value(); hasSourceInfo = true; mediaSourceId = sourceInfo.id; bool hasSubtitleStreams = false; for (const auto& stream : sourceInfo.mediaStreams) { if (stream.type == "Audio" && stream.isDefault) { selectedAudioIdx = stream.index; } else if (stream.type == "Subtitle") { hasSubtitleStreams = true; if (stream.isDefault) { selectedSubIdx = stream.index; } } } } if (hasSourceInfo && selectedSubIdx < 0) { subtitleDisabled = PlayerPreferenceUtils::isSubtitleDisabled( ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, "auto")); } QStringList args; if (!argsStr.trimmed().isEmpty()) { args = QProcess::splitCommand(argsStr); } QStringList playerArgs = buildPlayerArgs( playerType, startPositionTicks, selectedAudioIdx, selectedSubIdx, subtitleDisabled, hasSourceInfo ? &sourceInfo : nullptr); args << playerArgs; QString ipcEndpoint = generateIpcEndpoint(playerType); switch (playerType) { case PlayerType::MPV: if (!ipcEndpoint.isEmpty()) args << QString("--input-ipc-server=%1").arg(ipcEndpoint); break; case PlayerType::IINA: if (!ipcEndpoint.isEmpty()) args << QString("--mpv-input-ipc-server=%1").arg(ipcEndpoint); break; default: break; } switch (playerType) { case PlayerType::MPV: args << "--autofit=1280x720"; break; case PlayerType::IINA: args << "--mpv-autofit=1280x720"; break; default: break; } if (!title.isEmpty()) { switch (playerType) { case PlayerType::MPV: args << QString("--force-media-title=%1").arg(title); break; case PlayerType::IINA: args << QString("--mpv-force-media-title=%1").arg(title); break; case PlayerType::VLC: args << QString("--meta-title=%1").arg(title); break; default: break; } } QString actualStreamUrl = streamUrl; qDebug() << "[PlaybackManager] Original streamUrl:" << LogRedactionUtils::url(streamUrl); if (ConfigStore::instance()->get(ConfigKeys::ExtPlayerDirectStream, false) && hasSourceInfo) { qDebug() << "[PlaybackManager] DirectStream enabled, sourceInfo.path:" << sourceInfo.path; qDebug() << "[PlaybackManager] sourceInfo.directStreamUrl:" << LogRedactionUtils::url(sourceInfo.directStreamUrl); if (!sourceInfo.path.isEmpty()) { actualStreamUrl = sourceInfo.path; qDebug() << "[PlaybackManager] Using sourceInfo.path as preferred direct source:" << actualStreamUrl; } else if (!sourceInfo.directStreamUrl.isEmpty()) { if (sourceInfo.directStreamUrl.startsWith("http://", Qt::CaseInsensitive) || sourceInfo.directStreamUrl.startsWith("https://", Qt::CaseInsensitive)) { actualStreamUrl = sourceInfo.directStreamUrl; qDebug() << "[PlaybackManager] Using directStreamUrl (absolute):" << LogRedactionUtils::url(actualStreamUrl); } else { auto profile = m_core->serverManager()->activeProfile(); if (profile.isValid()) { actualStreamUrl = profile.url + sourceInfo.directStreamUrl; qDebug() << "[PlaybackManager] Using directStreamUrl (relative, joined):" << LogRedactionUtils::url(actualStreamUrl); } } } else { auto profile = m_core->serverManager()->activeProfile(); if (profile.isValid()) { actualStreamUrl = QString("%1/Videos/%2/stream?static=true&mediaSourceId=%3&api_key=%4") .arg(profile.url, mediaId, sourceInfo.id, profile.accessToken); qDebug() << "[PlaybackManager] Using fallback static stream URL:" << LogRedactionUtils::url(actualStreamUrl); } } } QString replaceRules = ConfigStore::instance()->get(ConfigKeys::ExtPlayerUrlReplace); if (!replaceRules.isEmpty()) { qDebug() << "[PlaybackManager] URL replace rules found, processing..."; QString beforeReplace = actualStreamUrl; const QStringList lines = replaceRules.split('\n', Qt::SkipEmptyParts); for (const QString &line : lines) { int sepIdx = line.indexOf("=>"); if (sepIdx <= 0) continue; QString src = line.left(sepIdx).trimmed(); QString dst = line.mid(sepIdx + 2).trimmed(); if (!src.isEmpty() && actualStreamUrl.contains(src)) { qDebug() << "[PlaybackManager] URL replace rule matched:" << src << "=>" << dst; actualStreamUrl.replace(src, dst); qDebug() << "[PlaybackManager] After replace:" << LogRedactionUtils::url(actualStreamUrl); if (!actualStreamUrl.startsWith("http://", Qt::CaseInsensitive) && !actualStreamUrl.startsWith("https://", Qt::CaseInsensitive) && !actualStreamUrl.contains("://")) { actualStreamUrl = QUrl::fromPercentEncoding(actualStreamUrl.toUtf8()); qDebug() << "[PlaybackManager] After URL decode (local path):" << actualStreamUrl; } } } if (actualStreamUrl != beforeReplace) { qDebug() << "[PlaybackManager] URL changed by replace rules:" << LogRedactionUtils::url(beforeReplace) << "->" << LogRedactionUtils::url(actualStreamUrl); } } { bool isFinalLocalPath = !actualStreamUrl.startsWith("http://", Qt::CaseInsensitive) && !actualStreamUrl.startsWith("https://", Qt::CaseInsensitive) && !actualStreamUrl.contains("://"); if (isFinalLocalPath && !QFileInfo::exists(actualStreamUrl)) { qWarning() << "[PlaybackManager] Final local path does NOT exist:" << actualStreamUrl << "- falling back to original streamUrl"; actualStreamUrl = streamUrl; } else if (isFinalLocalPath) { qDebug() << "[PlaybackManager] Final local path verified exists:" << actualStreamUrl; } } bool isLocalPath = !actualStreamUrl.startsWith("http://", Qt::CaseInsensitive) && !actualStreamUrl.startsWith("https://", Qt::CaseInsensitive) && !actualStreamUrl.contains("://"); if (isLocalPath) { qDebug() << "[PlaybackManager] Detected local path, adapting format..."; switch (playerType) { case PlayerType::MPV: case PlayerType::IINA: case PlayerType::MPC_Qt: actualStreamUrl = QUrl::fromLocalFile(actualStreamUrl).toString(); qDebug() << "[PlaybackManager] Converted to file:// URI:" << actualStreamUrl; break; default: break; } } qDebug() << "[PlaybackManager] Final playback URL:" << LogRedactionUtils::url(actualStreamUrl); args << actualStreamUrl; m_currentMediaId = mediaId; m_currentMediaSourceId = mediaSourceId; { m_ipc = ExternalPlayerIPC::create(playerType, ipcEndpoint, this); m_ipc->start(startPositionTicks); } auto *process = new QProcess(this); process->setProgram(playerPath); process->setArguments(args); QCoro::connect(m_core->mediaService()->reportPlaybackStart(mediaId, mediaSourceId, startPositionTicks), this, [this](const QString &sessionId) { m_currentPlaySessionId = sessionId; }); m_progressTimer = new QTimer(this); connect(m_progressTimer, &QTimer::timeout, this, [this]() { if (!m_core || !m_ipc || m_currentMediaId.isEmpty() || m_currentPlaySessionId.isEmpty()) return; long long currentTicks = m_ipc->currentPositionTicks(); m_core->mediaService()->reportPlaybackProgress( m_currentMediaId, m_currentMediaSourceId, currentTicks, false, m_currentPlaySessionId); }); connect(process, QOverload::of(&QProcess::finished), this, [this, process](int exitCode, QProcess::ExitStatus status) { qDebug() << "[PlaybackManager] External player exited, code:" << exitCode << "status:" << (status == QProcess::NormalExit ? "NormalExit" : "CrashExit"); long long finalTicks = 0; if (m_ipc) { finalTicks = m_ipc->currentPositionTicks(); } qDebug() << "[PlaybackManager] Final position:" << finalTicks << "(" << ticksToTimeString(finalTicks) << ")"; if (!m_currentMediaId.isEmpty() && !m_currentPlaySessionId.isEmpty()) { qDebug() << "[PlaybackManager] Reporting playback stopped, sessionId:" << m_currentPlaySessionId; m_core->mediaService()->reportPlaybackStopped( m_currentMediaId, m_currentMediaSourceId, finalTicks, m_currentPlaySessionId); } cleanupExternalPlayerState(); if (m_extProcess == process) { m_extProcess = nullptr; } process->deleteLater(); Q_EMIT playbackFinished(); }); connect(process, &QProcess::errorOccurred, this, [this, process](QProcess::ProcessError error) { qDebug() << "[PlaybackManager] External player error:" << error; if (error == QProcess::FailedToStart) { ModernToast::showMessage(tr("Failed to launch external player"), 2000); } cleanupExternalPlayerState(); if (m_extProcess == process) { m_extProcess = nullptr; } process->deleteLater(); }); m_extProcess = process; process->start(); m_progressTimer->start(PROGRESS_REPORT_INTERVAL_MS); QString playerName = QFileInfo(playerPath).baseName(); ModernToast::showMessage(tr("Launching %1...").arg(playerName), 1500); qDebug() << "[PlaybackManager] Launching external player:" << playerPath << "Type:" << static_cast(playerType) << "Args:" << LogRedactionUtils::stringList(args) << "IPC:" << (m_ipc ? (m_ipc->isPrecise() ? "precise" : "estimation") : "none"); } void PlaybackManager::launchIndependentWindow(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData) { qDebug() << "[PlaybackManager] ===== launchIndependentWindow ====="; qDebug() << "[PlaybackManager] mediaId:" << mediaId << "title:" << title; qDebug() << "[PlaybackManager] streamUrl:" << LogRedactionUtils::url(streamUrl); qDebug() << "[PlaybackManager] startPositionTicks:" << startPositionTicks << "(" << ticksToTimeString(startPositionTicks) << ")"; if (m_independentWindow) { qDebug() << "[PlaybackManager] Reusing existing independent window"; m_independentWindow->playMedia(mediaId, title, streamUrl, startPositionTicks, extraData); m_independentWindow->raise(); m_independentWindow->activateWindow(); return; } qDebug() << "[PlaybackManager] Creating new independent window"; auto* playerWindow = new PlayerWindow(m_core); playerWindow->setAttribute(Qt::WA_DeleteOnClose, true); playerWindow->setMinimumSize(960, 540); playerWindow->resize(1280, 720); connect(playerWindow, &QObject::destroyed, this, [this]() { m_independentWindow = nullptr; qDebug() << "[PlaybackManager] Independent window destroyed"; Q_EMIT playbackFinished(); }); m_independentWindow = playerWindow; playerWindow->show(); playerWindow->playMedia(mediaId, title, streamUrl, startPositionTicks, extraData); }