chore: baseline usable version

This commit is contained in:
dela
2026-06-10 11:17:51 +08:00
commit a2f82a86b6
932 changed files with 172551 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
#ifndef DOWNLOADMANAGER_H
#define DOWNLOADMANAGER_H
#include <QHash>
#include <QList>
#include <QObject>
#include <QString>
#include <optional>
#include <qcorotask.h>
#include <models/media/mediaitem.h>
class QEmbyCore;
class DownloadManager : public QObject
{
Q_OBJECT
public:
struct DownloadRecord {
QString id;
QString itemId;
QString itemName;
QString mediaSourceId;
QString fileName;
QString filePath;
QString serverName;
QString serverUrl;
QString serverUserId;
QString statusText;
QString errorText;
qint64 bytesReceived = 0;
qint64 bytesTotal = -1;
qint64 createdAtMs = 0;
qint64 finishedAtMs = 0;
bool isActive = false;
bool isCompleted = false;
bool isPaused = false;
};
static DownloadManager* instance();
~DownloadManager() override;
void init(QEmbyCore* core);
bool hasDownloadPermission() const;
bool canDownload(const MediaItem& item) const;
QString downloadDirectory() const;
void setDownloadDirectory(QString directory);
QList<DownloadRecord> activeDownloads() const;
QList<DownloadRecord> activeDownloadsForCurrentServer() const;
QList<DownloadRecord> historyDownloads() const;
QList<DownloadRecord> historyDownloadsForCurrentServer() const;
std::optional<DownloadRecord> recordById(const QString& recordId) const;
void enqueueDownload(MediaItem item);
bool pauseDownload(const QString& recordId);
bool resumeDownload(const QString& recordId);
bool stopDownload(const QString& recordId);
bool cancelDownload(const QString& recordId, bool deleteFile = true,
bool keepHistory = false);
bool openDownloadedFile(const QString& recordId) const;
bool openContainingFolder(const QString& recordId) const;
bool removeHistoryRecord(const QString& recordId, bool deleteFile);
Q_SIGNALS:
void downloadsChanged();
void downloadDirectoryChanged(const QString& directory);
private:
explicit DownloadManager(QObject* parent = nullptr);
struct ActiveDownload;
void ensureHistoryLoaded() const;
void saveHistory() const;
void notifyDownloadsChanged();
void upsertHistoryRecord(DownloadRecord record);
void handleReplyReadyRead(const QString& downloadId);
void handleReplyProgress(const QString& downloadId, qint64 received,
qint64 total);
void handleReplyFinished(const QString& downloadId);
QCoro::Task<void> startDownload(
MediaItem item,
std::optional<DownloadRecord> resumeRecord = std::nullopt);
QEmbyCore* m_core = nullptr;
class QNetworkAccessManager* m_networkManager = nullptr;
QHash<QString, ActiveDownload*> m_activeDownloads;
mutable QList<DownloadRecord> m_historyDownloads;
mutable bool m_historyLoaded = false;
};
#endif
@@ -0,0 +1,171 @@
#include "externalplayerdetector.h"
#include <config/config_keys.h>
#include <config/configstore.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QStandardPaths>
#ifdef Q_OS_WIN
#include <QSettings>
#endif
QList<DetectedPlayer> ExternalPlayerDetector::detect() {
QList<DetectedPlayer> result;
auto tryDetect = [&](const QString &name, const QStringList &exeNames,
const QStringList &extraDirs = {}) {
for (const auto &exe : exeNames) {
QString found = QStandardPaths::findExecutable(exe, extraDirs);
if (!found.isEmpty()) {
result.append({name, QDir::toNativeSeparators(found)});
return;
}
}
};
#ifdef Q_OS_WIN
auto tryRegistry = [&](const QString &name, const QString &regKey,
const QString &valueName,
const QString &exeName = {}) {
QSettings reg(regKey, QSettings::NativeFormat);
QString val = reg.value(valueName).toString();
if (val.isEmpty())
return;
QString fullPath = val;
if (!exeName.isEmpty() && QFileInfo(val).isDir()) {
fullPath = val + "/" + exeName;
}
if (QFile::exists(fullPath)) {
result.append({name, QDir::toNativeSeparators(fullPath)});
}
};
tryRegistry(
"PotPlayer",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App "
"Paths\\PotPlayerMini64.exe",
".", "");
if (result.isEmpty() || result.last().name != "PotPlayer") {
tryRegistry("PotPlayer",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\DAUM\\PotPlayer64",
"ProgramFolder", "PotPlayerMini64.exe");
}
tryRegistry("VLC", "HKEY_LOCAL_MACHINE\\SOFTWARE\\VideoLAN\\VLC",
"InstallDir", "vlc.exe");
tryRegistry(
"MPC-HC",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App "
"Paths\\mpc-hc64.exe",
".", "");
if (result.isEmpty() || result.last().name != "MPC-HC") {
tryRegistry("MPC-HC",
"HKEY_LOCAL_"
"MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App "
"Paths\\mpc-hc.exe",
".", "");
}
tryRegistry(
"MPC-BE",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App "
"Paths\\mpc-be64.exe",
".", "");
if (result.isEmpty() || result.last().name != "MPC-BE") {
tryRegistry("MPC-BE",
"HKEY_LOCAL_"
"MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App "
"Paths\\mpc-be.exe",
".", "");
}
if (result.isEmpty() || result.last().name != "MPC-BE") {
QStringList beDirs;
for (const auto &drive : QDir::drives()) {
QString d = drive.absolutePath();
beDirs << d + "Program Files/MPC-BE x64" << d + "Program Files/MPC-BE"
<< d + "Program Files (x86)/MPC-BE";
}
tryDetect("MPC-BE", {"mpc-be64.exe", "mpc-be.exe"}, beDirs);
}
{
QString pathMpv = QStandardPaths::findExecutable("mpv.exe");
if (!pathMpv.isEmpty()) {
result.append({"MPV", QDir::toNativeSeparators(pathMpv)});
}
QSet<QString> foundPaths;
if (!pathMpv.isEmpty())
foundPaths.insert(QDir::toNativeSeparators(pathMpv));
for (const auto &drive : QDir::drives()) {
QString d = drive.absolutePath();
QStringList searchRoots = {d + "Program Files",
d + "Program Files (x86)"};
for (const auto &root : searchRoots) {
QDir rootDir(root);
if (!rootDir.exists())
continue;
QStringList mpvDirs = rootDir.entryList({"mpv*"}, QDir::Dirs);
for (const auto &sub : mpvDirs) {
QString exePath = root + "/" + sub + "/mpv.exe";
if (QFile::exists(exePath)) {
QString native = QDir::toNativeSeparators(exePath);
if (!foundPaths.contains(native)) {
foundPaths.insert(native);
QString label = QString("MPV (%1)").arg(sub);
result.append({label, native});
}
}
}
}
}
}
#elif defined(Q_OS_MAC)
tryDetect("MPV", {"mpv"});
tryDetect("VLC", {"vlc"}, {"/Applications/VLC.app/Contents/MacOS"});
tryDetect("IINA", {"iina"}, {"/Applications/IINA.app/Contents/MacOS"});
#else
tryDetect("MPV", {"mpv"});
tryDetect("VLC", {"vlc"});
tryDetect("MPC-Qt", {"mpc-qt"});
#endif
return result;
}
void ExternalPlayerDetector::saveToConfig(const QList<DetectedPlayer> &players) {
QJsonArray arr;
for (const auto &p : players) {
QJsonObject obj;
obj["name"] = p.name;
obj["path"] = p.path;
arr.append(obj);
}
ConfigStore::instance()->set(
ConfigKeys::ExtPlayerDetectedList,
QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact)));
}
QList<DetectedPlayer> ExternalPlayerDetector::loadFromConfig() {
QList<DetectedPlayer> result;
QString jsonStr = ConfigStore::instance()->get<QString>(ConfigKeys::ExtPlayerDetectedList);
QJsonArray arr = QJsonDocument::fromJson(jsonStr.toUtf8()).array();
for (const auto &val : arr) {
QJsonObject obj = val.toObject();
QString name = obj["name"].toString();
QString path = obj["path"].toString();
if (!name.isEmpty() && !path.isEmpty()) {
result.append({name, path});
}
}
return result;
}
@@ -0,0 +1,25 @@
#ifndef EXTERNALPLAYERDETECTOR_H
#define EXTERNALPLAYERDETECTOR_H
#include <QList>
#include <QString>
struct DetectedPlayer {
QString name;
QString path;
};
class ExternalPlayerDetector {
public:
static QList<DetectedPlayer> detect();
static void saveToConfig(const QList<DetectedPlayer> &players);
static QList<DetectedPlayer> loadFromConfig();
};
#endif
+454
View File
@@ -0,0 +1,454 @@
#include "externalplayeripc.h"
#include <QLocalSocket>
#include <QTcpSocket>
#include <QTimer>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDebug>
static constexpr long long TICKS_PER_SECOND = 10000000LL;
static constexpr int POLL_INTERVAL_MS = 5000;
static constexpr int CONNECT_RETRY_MS = 1000;
static constexpr int MAX_CONNECT_ATTEMPTS = 15;
ExternalPlayerIPC* ExternalPlayerIPC::create(PlayerType type, const QString& ipcEndpoint, QObject* parent)
{
switch (type) {
case PlayerType::MPV:
case PlayerType::IINA:
return new MpvIPC(ipcEndpoint, parent);
case PlayerType::VLC:
#ifdef Q_OS_WIN
case PlayerType::MPC_HC:
return new MpcSlaveIPC(parent);
#endif
case PlayerType::MPC_BE:
default:
return new EstimationIPC(parent);
}
}
MpvIPC::MpvIPC(const QString& pipePath, QObject* parent)
: ExternalPlayerIPC(parent)
, m_pipePath(pipePath)
{
}
MpvIPC::~MpvIPC()
{
stop();
}
void MpvIPC::start(long long startPositionTicks)
{
m_lastPositionTicks = startPositionTicks;
m_connectAttempts = 0;
m_connected = false;
m_socket = new QLocalSocket(this);
connect(m_socket, &QLocalSocket::readyRead, this, &MpvIPC::onReadyRead);
connect(m_socket, &QLocalSocket::connected, this, [this]() {
m_connected = true;
m_connectAttempts = 0;
qDebug() << "[MpvIPC] Connected to pipe:" << m_pipePath;
if (!m_pollTimer) {
m_pollTimer = new QTimer(this);
connect(m_pollTimer, &QTimer::timeout, this, &MpvIPC::sendQuery);
}
m_pollTimer->start(POLL_INTERVAL_MS);
sendQuery();
});
connect(m_socket, &QLocalSocket::disconnected, this, [this]() {
m_connected = false;
if (m_pollTimer) m_pollTimer->stop();
qDebug() << "[MpvIPC] Disconnected from pipe";
});
m_connectTimer = new QTimer(this);
m_connectTimer->setSingleShot(true);
connect(m_connectTimer, &QTimer::timeout, this, &MpvIPC::tryConnect);
m_connectTimer->start(CONNECT_RETRY_MS);
}
void MpvIPC::tryConnect()
{
if (m_connected || m_connectAttempts >= MAX_CONNECT_ATTEMPTS) {
if (!m_connected) {
qDebug() << "[MpvIPC] Max connect attempts reached, giving up";
}
return;
}
m_connectAttempts++;
qDebug() << "[MpvIPC] Attempting to connect (" << m_connectAttempts << "/" << MAX_CONNECT_ATTEMPTS << ")";
m_socket->connectToServer(m_pipePath);
if (!m_socket->waitForConnected(500)) {
if (m_connectTimer)
m_connectTimer->start(CONNECT_RETRY_MS);
}
}
void MpvIPC::stop()
{
if (m_pollTimer) {
m_pollTimer->stop();
m_pollTimer->deleteLater();
m_pollTimer = nullptr;
}
if (m_connectTimer) {
m_connectTimer->stop();
m_connectTimer->deleteLater();
m_connectTimer = nullptr;
}
if (m_socket) {
if (m_socket->state() == QLocalSocket::ConnectedState) {
m_socket->disconnectFromServer();
}
m_socket->deleteLater();
m_socket = nullptr;
}
m_connected = false;
}
long long MpvIPC::currentPositionTicks() const
{
return m_lastPositionTicks;
}
void MpvIPC::sendQuery()
{
if (!m_socket || !m_connected) return;
QJsonObject cmd;
cmd["command"] = QJsonArray{"get_property", "time-pos"};
QByteArray data = QJsonDocument(cmd).toJson(QJsonDocument::Compact) + "\n";
m_socket->write(data);
m_socket->flush();
}
void MpvIPC::onReadyRead()
{
while (m_socket && m_socket->canReadLine()) {
QByteArray line = m_socket->readLine().trimmed();
if (line.isEmpty()) continue;
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(line, &err);
if (err.error != QJsonParseError::NoError) continue;
QJsonObject obj = doc.object();
if (obj.contains("data") && obj["error"].toString() == "success") {
double seconds = obj["data"].toDouble(-1.0);
if (seconds >= 0.0) {
m_lastPositionTicks = static_cast<long long>(seconds * TICKS_PER_SECOND);
Q_EMIT positionUpdated(m_lastPositionTicks);
}
}
}
}
VlcRcIPC::VlcRcIPC(int port, QObject* parent)
: ExternalPlayerIPC(parent)
, m_port(port)
{
}
VlcRcIPC::~VlcRcIPC()
{
stop();
}
void VlcRcIPC::start(long long startPositionTicks)
{
m_lastPositionTicks = startPositionTicks;
m_connectAttempts = 0;
m_connected = false;
m_socket = new QTcpSocket(this);
connect(m_socket, &QTcpSocket::connected, this, &VlcRcIPC::onConnected);
connect(m_socket, &QTcpSocket::readyRead, this, &VlcRcIPC::onReadyRead);
connect(m_socket, &QTcpSocket::disconnected, this, [this]() {
m_connected = false;
if (m_pollTimer) m_pollTimer->stop();
qDebug() << "[VlcRcIPC] Disconnected from RC interface";
});
m_connectTimer = new QTimer(this);
m_connectTimer->setSingleShot(true);
connect(m_connectTimer, &QTimer::timeout, this, &VlcRcIPC::tryConnect);
m_connectTimer->start(2000);
}
void VlcRcIPC::tryConnect()
{
if (m_connected || m_connectAttempts >= MAX_CONNECT_ATTEMPTS) {
if (!m_connected)
qDebug() << "[VlcRcIPC] Max connect attempts reached";
return;
}
m_connectAttempts++;
qDebug() << "[VlcRcIPC] Attempting to connect to port" << m_port
<< "(" << m_connectAttempts << "/" << MAX_CONNECT_ATTEMPTS << ")";
m_socket->connectToHost("127.0.0.1", m_port);
if (!m_socket->waitForConnected(1000)) {
if (m_connectTimer)
m_connectTimer->start(CONNECT_RETRY_MS);
}
}
void VlcRcIPC::onConnected()
{
m_connected = true;
m_connectAttempts = 0;
qDebug() << "[VlcRcIPC] Connected to VLC RC on port" << m_port;
if (!m_pollTimer) {
m_pollTimer = new QTimer(this);
connect(m_pollTimer, &QTimer::timeout, this, &VlcRcIPC::sendQuery);
}
m_pollTimer->start(POLL_INTERVAL_MS);
QTimer::singleShot(500, this, &VlcRcIPC::sendQuery);
}
void VlcRcIPC::stop()
{
if (m_pollTimer) {
m_pollTimer->stop();
m_pollTimer->deleteLater();
m_pollTimer = nullptr;
}
if (m_connectTimer) {
m_connectTimer->stop();
m_connectTimer->deleteLater();
m_connectTimer = nullptr;
}
if (m_socket) {
if (m_socket->state() == QTcpSocket::ConnectedState) {
m_socket->write("quit\n");
m_socket->flush();
m_socket->disconnectFromHost();
}
m_socket->deleteLater();
m_socket = nullptr;
}
m_connected = false;
}
long long VlcRcIPC::currentPositionTicks() const
{
return m_lastPositionTicks;
}
void VlcRcIPC::sendQuery()
{
if (!m_socket || !m_connected) return;
m_socket->write("get_time\n");
m_socket->flush();
}
void VlcRcIPC::onReadyRead()
{
while (m_socket && m_socket->canReadLine()) {
QByteArray line = m_socket->readLine().trimmed();
if (line.isEmpty()) continue;
bool ok;
int seconds = line.toInt(&ok);
if (ok && seconds >= 0) {
m_lastPositionTicks = static_cast<long long>(seconds) * TICKS_PER_SECOND;
Q_EMIT positionUpdated(m_lastPositionTicks);
}
}
}
#ifdef Q_OS_WIN
#include <Windows.h>
namespace MpcCmd {
constexpr DWORD CMD_CURRENTPOSITION = 0x50000007;
constexpr DWORD CMD_NOWPLAYING = 0x50000003;
constexpr DWORD CMD_PLAYMODE = 0x50000004;
constexpr DWORD CMD_NOTIFYSEEK = 0x50000008;
constexpr DWORD CMD_NOTIFYENDSTREAM = 0x50000009;
constexpr DWORD CMD_GETCURRENTPOSITION = 0xA0003004;
}
MpcSlaveWindow::MpcSlaveWindow(QWidget* parent)
: QWidget(parent)
{
setFixedSize(1, 1);
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint);
setAttribute(Qt::WA_ShowWithoutActivating);
winId();
}
bool MpcSlaveWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr* result)
{
if (eventType == "windows_generic_MSG") {
MSG* msg = static_cast<MSG*>(message);
if (msg->message == WM_COPYDATA) {
COPYDATASTRUCT* cds = reinterpret_cast<COPYDATASTRUCT*>(msg->lParam);
if (!cds) return false;
QString data = QString::fromWCharArray(
reinterpret_cast<const wchar_t*>(cds->lpData),
cds->cbData / sizeof(wchar_t));
if (data.endsWith(QChar('\0')))
data.chop(1);
switch (cds->dwData) {
case MpcCmd::CMD_CURRENTPOSITION:
case MpcCmd::CMD_NOTIFYSEEK: {
bool ok;
double seconds = data.toDouble(&ok);
if (!ok) {
QStringList parts = data.split(':');
if (parts.size() == 3) {
seconds = parts[0].toDouble() * 3600 + parts[1].toDouble() * 60 + parts[2].toDouble();
ok = true;
}
}
if (ok && seconds >= 0.0) {
long long posMs = static_cast<long long>(seconds * 1000);
Q_EMIT positionReceived(posMs);
}
if (result) *result = 1;
return true;
}
case MpcCmd::CMD_NOTIFYENDSTREAM:
Q_EMIT playbackEnded();
if (result) *result = 1;
return true;
default:
break;
}
}
}
return QWidget::nativeEvent(eventType, message, result);
}
MpcSlaveIPC::MpcSlaveIPC(QObject* parent)
: ExternalPlayerIPC(parent)
{
}
MpcSlaveIPC::~MpcSlaveIPC()
{
stop();
}
void MpcSlaveIPC::start(long long startPositionTicks)
{
m_lastPositionTicks = startPositionTicks;
m_window = new MpcSlaveWindow();
connect(m_window, &MpcSlaveWindow::positionReceived, this, [this](long long posMs) {
m_lastPositionTicks = posMs * 10000LL;
Q_EMIT positionUpdated(m_lastPositionTicks);
});
}
void MpcSlaveIPC::stop()
{
if (m_window) {
m_window->deleteLater();
m_window = nullptr;
}
}
long long MpcSlaveIPC::currentPositionTicks() const
{
return m_lastPositionTicks;
}
QString MpcSlaveIPC::hwndString() const
{
if (m_window)
return QString::number(static_cast<quintptr>(m_window->winId()));
return {};
}
#endif
EstimationIPC::EstimationIPC(QObject* parent)
: ExternalPlayerIPC(parent)
{
}
EstimationIPC::~EstimationIPC()
{
stop();
}
void EstimationIPC::start(long long startPositionTicks)
{
m_startTicks = startPositionTicks;
m_elapsed.start();
m_running = true;
}
void EstimationIPC::stop()
{
m_running = false;
}
long long EstimationIPC::currentPositionTicks() const
{
if (!m_running) return m_startTicks;
long long elapsedMs = m_elapsed.elapsed();
return m_startTicks + elapsedMs * 10000LL;
}
+185
View File
@@ -0,0 +1,185 @@
#ifndef EXTERNALPLAYERIPC_H
#define EXTERNALPLAYERIPC_H
#include <QObject>
#include <QElapsedTimer>
class QLocalSocket;
class QTcpSocket;
class QTimer;
enum class PlayerType {
MPV,
IINA,
VLC,
PotPlayer,
MPC_HC,
MPC_BE,
MPC_Qt,
Unknown
};
class ExternalPlayerIPC : public QObject
{
Q_OBJECT
public:
explicit ExternalPlayerIPC(QObject* parent = nullptr) : QObject(parent) {}
virtual ~ExternalPlayerIPC() = default;
virtual void start(long long startPositionTicks) = 0;
virtual void stop() = 0;
virtual long long currentPositionTicks() const = 0;
virtual bool isPrecise() const = 0;
static ExternalPlayerIPC* create(PlayerType type, const QString& ipcEndpoint, QObject* parent = nullptr);
signals:
void positionUpdated(long long ticks);
};
class MpvIPC : public ExternalPlayerIPC
{
Q_OBJECT
public:
explicit MpvIPC(const QString& pipePath, QObject* parent = nullptr);
~MpvIPC() override;
void start(long long startPositionTicks) override;
void stop() override;
long long currentPositionTicks() const override;
bool isPrecise() const override { return true; }
private slots:
void onReadyRead();
void tryConnect();
private:
QString m_pipePath;
QLocalSocket* m_socket = nullptr;
QTimer* m_pollTimer = nullptr;
QTimer* m_connectTimer = nullptr;
long long m_lastPositionTicks = 0;
int m_connectAttempts = 0;
bool m_connected = false;
void sendQuery();
};
class VlcRcIPC : public ExternalPlayerIPC
{
Q_OBJECT
public:
explicit VlcRcIPC(int port, QObject* parent = nullptr);
~VlcRcIPC() override;
void start(long long startPositionTicks) override;
void stop() override;
long long currentPositionTicks() const override;
bool isPrecise() const override { return true; }
private slots:
void onConnected();
void onReadyRead();
void tryConnect();
private:
int m_port;
QTcpSocket* m_socket = nullptr;
QTimer* m_pollTimer = nullptr;
QTimer* m_connectTimer = nullptr;
long long m_lastPositionTicks = 0;
int m_connectAttempts = 0;
bool m_connected = false;
void sendQuery();
};
#ifdef Q_OS_WIN
#include <QWidget>
class MpcSlaveWindow : public QWidget
{
Q_OBJECT
public:
explicit MpcSlaveWindow(QWidget* parent = nullptr);
signals:
void positionReceived(long long positionMs);
void playbackEnded();
protected:
bool nativeEvent(const QByteArray& eventType, void* message, qintptr* result) override;
};
class MpcSlaveIPC : public ExternalPlayerIPC
{
Q_OBJECT
public:
explicit MpcSlaveIPC(QObject* parent = nullptr);
~MpcSlaveIPC() override;
void start(long long startPositionTicks) override;
void stop() override;
long long currentPositionTicks() const override;
bool isPrecise() const override { return true; }
QString hwndString() const;
private:
MpcSlaveWindow* m_window = nullptr;
long long m_lastPositionTicks = 0;
};
#endif
class EstimationIPC : public ExternalPlayerIPC
{
Q_OBJECT
public:
explicit EstimationIPC(QObject* parent = nullptr);
~EstimationIPC() override;
void start(long long startPositionTicks) override;
void stop() override;
long long currentPositionTicks() const override;
bool isPrecise() const override { return false; }
private:
long long m_startTicks = 0;
QElapsedTimer m_elapsed;
bool m_running = false;
};
#endif
+119
View File
@@ -0,0 +1,119 @@
#include "languagemanager.h"
#include <QCoreApplication>
#include <QDebug>
#include <QFile>
#include <QLocale>
#include <config/config_keys.h>
#include <config/configstore.h>
LanguageManager* LanguageManager::instance() {
static LanguageManager s_instance;
return &s_instance;
}
LanguageManager::LanguageManager(QObject *parent)
: QObject(parent), m_currentLang("") {
}
LanguageManager::~LanguageManager() = default;
QString LanguageManager::currentLanguage() const {
return m_currentLang;
}
void LanguageManager::init() {
if (m_initialized) {
return;
}
m_initialized = true;
if (auto *app = QCoreApplication::instance()) {
connect(app, &QCoreApplication::aboutToQuit, this, &LanguageManager::removeCurrentTranslator, Qt::DirectConnection);
}
connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, [this](const QString& key, const QVariant& value) {
if (key == ConfigKeys::Language) {
applyLanguage(value.toString());
}
});
QString savedLang = ConfigStore::instance()->get<QString>(ConfigKeys::Language, "system");
qDebug() << "LanguageManager: saved language" << savedLang
<< "| config:" << ConfigStore::instance()->filePath();
applyLanguage(savedLang);
}
void LanguageManager::removeCurrentTranslator() {
if (!m_translatorInstalled) {
return;
}
auto *app = QCoreApplication::instance();
if (!app) {
m_translatorInstalled = false;
return;
}
app->removeTranslator(&m_translator);
m_translatorInstalled = false;
}
void LanguageManager::applyLanguage(const QString& langCode) {
if (m_currentLang == langCode && !m_translator.isEmpty()) {
return;
}
auto *app = QCoreApplication::instance();
if (!app) {
qWarning() << "LanguageManager: QCoreApplication is not available, skipping language change.";
return;
}
m_currentLang = langCode;
removeCurrentTranslator();
const auto loadTranslation = [this, app](const QString &baseName) {
const QString resourcePath = QStringLiteral(":/i18n/%1.qm").arg(baseName);
const bool resourceExists = QFile::exists(resourcePath);
if (resourceExists && m_translator.load(resourcePath)) {
app->installTranslator(&m_translator);
m_translatorInstalled = true;
qDebug() << "LanguageManager: Loaded translation"
<< resourcePath
<< "| language:" << m_translator.language()
<< "| filePath:" << m_translator.filePath();
return true;
}
qWarning() << "LanguageManager: Failed to load translation"
<< resourcePath << "| resourceExists:" << resourceExists;
return false;
};
if (langCode == "system" || langCode.isEmpty()) {
const QStringList uiLanguages = QLocale::system().uiLanguages();
bool loaded = false;
for (const QString &locale : uiLanguages) {
const QString baseName = "qEmby_" + QLocale(locale).name();
if (loadTranslation(baseName)) {
qDebug() << "LanguageManager: Loaded system translation" << baseName;
loaded = true;
break;
}
}
if (!loaded) {
qWarning() << "LanguageManager: Failed to load system translation, falling back to internal default.";
}
} else {
const QString baseName = "qEmby_" + langCode;
if (!loadTranslation(baseName)) {
qWarning() << "LanguageManager: Falling back to internal default for" << baseName;
}
}
Q_EMIT languageChanged(langCode);
}
+34
View File
@@ -0,0 +1,34 @@
#ifndef LANGUAGEMANAGER_H
#define LANGUAGEMANAGER_H
#include <QObject>
#include <QTranslator>
#include <QString>
class LanguageManager : public QObject {
Q_OBJECT
public:
static LanguageManager* instance();
void init();
QString currentLanguage() const;
signals:
void languageChanged(const QString& langCode);
private:
explicit LanguageManager(QObject *parent = nullptr);
~LanguageManager() override;
Q_DISABLE_COPY(LanguageManager)
void applyLanguage(const QString& langCode);
void removeCurrentTranslator();
QTranslator m_translator;
QString m_currentLang;
bool m_initialized = false;
bool m_translatorInstalled = false;
};
#endif
+171
View File
@@ -0,0 +1,171 @@
#include "logmanager.h"
#include "config/config_keys.h"
#include "config/configstore.h"
#include <QDir>
#include <QFileInfo>
#include <QStandardPaths>
#include <spdlog/spdlog.h>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/null_sink.h>
static const char *kLoggerName = "qemby";
static const int kMaxFileSize = 5 * 1024 * 1024;
static const int kMaxFiles = 3;
static void spdlogMessageHandler(QtMsgType type, const QMessageLogContext &ctx,
const QString &msg) {
auto logger = spdlog::get(kLoggerName);
if (!logger)
return;
std::string m = msg.toStdString();
std::string category = ctx.category ? ctx.category : "";
std::string formatted =
category.empty() ? m : fmt::format("[{}] {}", category, m);
switch (type) {
case QtDebugMsg:
logger->debug("{}", formatted);
break;
case QtInfoMsg:
logger->info("{}", formatted);
break;
case QtWarningMsg:
logger->warn("{}", formatted);
break;
case QtCriticalMsg:
logger->error("{}", formatted);
break;
case QtFatalMsg:
logger->critical("{}", formatted);
logger->flush();
break;
}
}
LogManager::LogManager(QObject *parent) : QObject(parent) {}
LogManager *LogManager::instance() {
static LogManager inst;
return &inst;
}
QString LogManager::logFilePath() const {
QString configDir =
QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
return configDir + "/qemby.log";
}
void LogManager::init() {
if (m_initialized)
return;
m_initialized = true;
bool enabled =
ConfigStore::instance()->get<bool>(ConfigKeys::LogEnable, false);
if (enabled) {
enable();
}
}
void LogManager::enable() {
if (m_enabled)
return;
setupSpdlog();
m_enabled = true;
ConfigStore::instance()->set(ConfigKeys::LogEnable, true);
}
void LogManager::disable() {
if (!m_enabled)
return;
teardownSpdlog();
m_enabled = false;
ConfigStore::instance()->set(ConfigKeys::LogEnable, false);
}
void LogManager::setupSpdlog() {
QString logPath = logFilePath();
QDir().mkpath(QFileInfo(logPath).absolutePath());
try {
auto fileSink =
std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
logPath.toStdString(), kMaxFileSize, kMaxFiles);
auto logger =
std::make_shared<spdlog::logger>(kLoggerName, fileSink);
logger->set_level(spdlog::level::debug);
logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] %v");
logger->flush_on(spdlog::level::warn);
spdlog::register_logger(logger);
qInstallMessageHandler(spdlogMessageHandler);
logger->info("=== qEmby Logging Started ===");
} catch (const spdlog::spdlog_ex &ex) {
Q_UNUSED(ex);
}
}
void LogManager::teardownSpdlog() {
auto logger = spdlog::get(kLoggerName);
if (logger) {
logger->info("=== qEmby Logging Stopped ===");
logger->flush();
}
qInstallMessageHandler(nullptr);
spdlog::drop(kLoggerName);
}
QString LogManager::logFileSize() const {
QFileInfo info(logFilePath());
if (!info.exists())
return QStringLiteral("0 B");
qint64 bytes = info.size();
if (bytes < 1024)
return QStringLiteral("%1 B").arg(bytes);
if (bytes < 1024 * 1024)
return QStringLiteral("%1 KB").arg(bytes / 1024.0, 0, 'f', 1);
return QStringLiteral("%1 MB").arg(bytes / (1024.0 * 1024.0), 0, 'f', 1);
}
void LogManager::clearLog() {
bool wasEnabled = m_enabled;
if (wasEnabled) {
teardownSpdlog();
}
QString logPath = logFilePath();
QFileInfo fi(logPath);
QString baseName = fi.completeBaseName();
QString suffix = fi.suffix();
QString dir = fi.absolutePath();
QFile::remove(logPath);
for (int i = 1; i <= kMaxFiles; ++i) {
QString rotated = dir + "/" + baseName + "." + QString::number(i) + "." + suffix;
QFile::remove(rotated);
}
if (wasEnabled) {
m_enabled = false;
enable();
}
}
+44
View File
@@ -0,0 +1,44 @@
#ifndef LOGMANAGER_H
#define LOGMANAGER_H
#include <QObject>
#include <QString>
class LogManager : public QObject {
Q_OBJECT
public:
static LogManager *instance();
void init();
void enable();
void disable();
bool isEnabled() const { return m_enabled; }
QString logFilePath() const;
QString logFileSize() const;
void clearLog();
private:
explicit LogManager(QObject *parent = nullptr);
void setupSpdlog();
void teardownSpdlog();
bool m_enabled = false;
bool m_initialized = false;
};
#endif
+713
View File
@@ -0,0 +1,713 @@
#include "playbackmanager.h"
#include <qembycore.h>
#include <services/media/mediaservice.h>
#include <services/manager/servermanager.h>
#include <models/profile/serverprofile.h>
#include <config/configstore.h>
#include <config/config_keys.h>
#include <models/media/playbackinfo.h>
#include "../components/playerwindow.h"
#include "../components/moderntoast.h"
#include "../utils/logredactionutils.h"
#include "../utils/playerpreferenceutils.h"
#include <QProcess>
#include <QFile>
#include <QFileInfo>
#include <QUrl>
#include <QCoreApplication>
#include <QTimer>
#include <QDebug>
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<bool>(ConfigKeys::ExtPlayerEnable, false);
bool quickPlayExt = ConfigStore::instance()->get<bool>(ConfigKeys::ExtPlayerQuickPlay, false);
if (extEnabled && quickPlayExt) {
QString playerPath = ConfigStore::instance()->get<QString>(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<bool>(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<bool>(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.contains("potplayer"))
return PlayerType::PotPlayer;
if (baseName == "vlc")
return PlayerType::VLC;
if (baseName.contains("mpc-hc") || baseName == "mpc-hc64")
return PlayerType::MPC_HC;
if (baseName.contains("mpc-be") || baseName == "mpc-be64")
return PlayerType::MPC_BE;
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<int>(totalSeconds / 3600);
int m = static_cast<int>((totalSeconds % 3600) / 60);
int s = static_cast<int>(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<double>(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:
#ifdef Q_OS_WIN
return QString("\\\\.\\pipe\\qemby-mpv-%1").arg(pid);
#else
return QString("/tmp/qemby-mpv-%1.sock").arg(pid);
#endif
case PlayerType::VLC: {
int port = 42900 + static_cast<int>(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::PotPlayer:
if (startPositionTicks > 0)
args << QString("/seek=%1").arg(timeStr);
break;
case PlayerType::VLC:
if (startPositionTicks > 0)
args << QString("--start-time=%1").arg(static_cast<int>(seconds));
if (relAudio >= 0)
args << QString("--audio-track=%1").arg(relAudio);
if (relSub >= 0)
args << QString("--sub-track=%1").arg(relSub);
break;
case PlayerType::MPC_HC:
if (startPositionTicks > 0)
args << "/startpos" << timeStr;
break;
case PlayerType::MPC_BE:
if (startPositionTicks > 0)
args << "/startpos" << timeStr;
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<QString>(ConfigKeys::ExtPlayerPath)
: playerPathOverride;
QString argsStr = ConfigStore::instance()->get<QString>(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<MediaSourceInfo>()) {
sourceInfo = extraData.value<MediaSourceInfo>();
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<QString>(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<bool>(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<QString>(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...";
#ifdef Q_OS_WIN
actualStreamUrl.replace('/', '\\');
#endif
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;
#ifdef Q_OS_WIN
if (playerType == PlayerType::MPC_HC) {
auto* mpcIpc = new MpcSlaveIPC(this);
mpcIpc->start(startPositionTicks);
QString hwnd = mpcIpc->hwndString();
if (!hwnd.isEmpty()) {
args.insert(args.size() - 1, "/slave");
args.insert(args.size() - 1, hwnd);
}
m_ipc = mpcIpc;
} else
#endif
{
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<int, QProcess::ExitStatus>::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<int>(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);
}
+111
View File
@@ -0,0 +1,111 @@
#ifndef PLAYBACKMANAGER_H
#define PLAYBACKMANAGER_H
#include <QObject>
#include <QPointer>
#include <QVariant>
#include <qcorotask.h>
#include "externalplayeripc.h"
class QEmbyCore;
class QProcess;
class PlayerWindow;
class QTimer;
struct MediaSourceInfo;
class PlaybackManager : public QObject
{
Q_OBJECT
public:
static PlaybackManager* instance();
void init(QEmbyCore* core);
void startPlayback(const QString& mediaId, const QString& title,
const QString& streamUrl, long long startPositionTicks,
const QVariant& extraData = QVariant());
void startExternalPlayback(const QString& playerPath,
const QString& mediaId, const QString& title,
const QString& streamUrl, long long startPositionTicks,
const QVariant& extraData = QVariant());
void startInternalPlayback(const QString& mediaId, const QString& title,
const QString& streamUrl, long long startPositionTicks,
const QVariant& extraData = QVariant());
void stopExternalPlayer();
signals:
void requestEmbeddedPlay(const QString& mediaId, const QString& title,
const QString& streamUrl, long long startPositionTicks,
const QVariant& extraData);
void playbackFinished();
private:
explicit PlaybackManager(QObject *parent = nullptr);
~PlaybackManager() = default;
void launchExternalPlayer(const QString& mediaId, const QString& title,
const QString& streamUrl, long long startPositionTicks,
const QVariant& extraData,
const QString& playerPathOverride = QString());
void launchIndependentWindow(const QString& mediaId, const QString& title,
const QString& streamUrl, long long startPositionTicks,
const QVariant& extraData);
static PlayerType identifyPlayerType(const QString& playerPath);
static QStringList buildPlayerArgs(PlayerType type,
long long startPositionTicks,
int audioStreamIndex,
int subtitleStreamIndex,
bool subtitleDisabled,
const MediaSourceInfo* sourceInfo);
static QString ticksToTimeString(long long ticks);
static double ticksToSeconds(long long ticks);
static int computeRelativeStreamIndex(const MediaSourceInfo& source,
const QString& streamType,
int globalIndex);
static QString generateIpcEndpoint(PlayerType type);
void cleanupExternalPlayerState();
QEmbyCore* m_core = nullptr;
QPointer<QProcess> m_extProcess;
QPointer<PlayerWindow> m_independentWindow;
ExternalPlayerIPC* m_ipc = nullptr;
QTimer* m_progressTimer = nullptr;
QString m_currentMediaId;
QString m_currentMediaSourceId;
QString m_currentPlaySessionId;
};
#endif
@@ -0,0 +1,469 @@
#include "searchhistorymanager.h"
#include "../../qEmbyCore/config/config_keys.h"
#include "../../qEmbyCore/config/configstore.h"
#include <QApplication>
#include <QDateTime>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <algorithm>
namespace
{
constexpr int kMaxStoredSearchEntries = 100;
bool isBetterRecentMatch(const SearchHistoryManager::SearchHistoryEntry &lhs,
const SearchHistoryManager::SearchHistoryEntry &rhs, const QString &normalizedPrefix)
{
const bool lhsStartsWith = lhs.normalizedTerm.startsWith(normalizedPrefix);
const bool rhsStartsWith = rhs.normalizedTerm.startsWith(normalizedPrefix);
if (lhsStartsWith != rhsStartsWith)
{
return lhsStartsWith;
}
if (lhs.lastSearchedAtMs != rhs.lastSearchedAtMs)
{
return lhs.lastSearchedAtMs > rhs.lastSearchedAtMs;
}
if (lhs.searchCount != rhs.searchCount)
{
return lhs.searchCount > rhs.searchCount;
}
return lhs.term.localeAwareCompare(rhs.term) < 0;
}
bool isBetterFrequentMatch(const SearchHistoryManager::SearchHistoryEntry &lhs,
const SearchHistoryManager::SearchHistoryEntry &rhs, const QString &normalizedPrefix)
{
const bool lhsStartsWith = lhs.normalizedTerm.startsWith(normalizedPrefix);
const bool rhsStartsWith = rhs.normalizedTerm.startsWith(normalizedPrefix);
if (lhsStartsWith != rhsStartsWith)
{
return lhsStartsWith;
}
if (lhs.searchCount != rhs.searchCount)
{
return lhs.searchCount > rhs.searchCount;
}
if (lhs.lastSearchedAtMs != rhs.lastSearchedAtMs)
{
return lhs.lastSearchedAtMs > rhs.lastSearchedAtMs;
}
return lhs.term.localeAwareCompare(rhs.term) < 0;
}
QStringList takeTerms(const QList<SearchHistoryManager::SearchHistoryEntry> &entries, int limit)
{
QStringList result;
result.reserve(qMin(limit, entries.size()));
QSet<QString> seenTerms;
for (const auto &entry : entries)
{
if (entry.term.isEmpty() || seenTerms.contains(entry.normalizedTerm))
{
continue;
}
result.append(entry.term);
seenTerms.insert(entry.normalizedTerm);
if (result.size() >= limit)
{
break;
}
}
return result;
}
}
SearchHistoryManager *SearchHistoryManager::instance()
{
static SearchHistoryManager *s_instance = new SearchHistoryManager(qApp);
return s_instance;
}
SearchHistoryManager::SearchHistoryManager(QObject *parent) : QObject(parent)
{
connect(ConfigStore::instance(), &ConfigStore::valueChanged, this,
[this](const QString &key, const QVariant &value)
{
if (key == ConfigKeys::SearchHistoryEnabled)
{
qDebug() << "[SearchHistoryManager] Search history feature toggled | enabled:" << value.toBool();
Q_EMIT enabledChanged(value.toBool());
Q_EMIT autocompleteEnabledChanged(isAutocompleteEnabled());
}
else if (key == ConfigKeys::SearchAutocompleteEnabled)
{
qDebug() << "[SearchHistoryManager] Search autocomplete toggled | requestedEnabled:"
<< value.toBool() << "| effectiveEnabled:" << isAutocompleteEnabled();
Q_EMIT autocompleteEnabledChanged(isAutocompleteEnabled());
}
});
}
bool SearchHistoryManager::isEnabled() const
{
return ConfigStore::instance()->get<bool>(ConfigKeys::SearchHistoryEnabled, false);
}
bool SearchHistoryManager::isAutocompleteEnabled() const
{
return isEnabled() && ConfigStore::instance()->get<bool>(
ConfigKeys::SearchAutocompleteEnabled, false);
}
void SearchHistoryManager::recordSearch(const QString &serverId, QString query)
{
if (!isEnabled())
{
return;
}
query = query.trimmed();
const QString normalizedQuery = normalizeQuery(query);
if (normalizedQuery.isEmpty())
{
return;
}
const QString bucket = effectiveServerBucket(serverId);
ensureLoaded(bucket);
auto &entries = m_cache[bucket];
auto it = std::find_if(entries.begin(), entries.end(), [&normalizedQuery](const SearchHistoryEntry &entry)
{ return entry.normalizedTerm == normalizedQuery; });
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
if (it != entries.end())
{
it->term = query;
it->searchCount += 1;
it->lastSearchedAtMs = nowMs;
}
else
{
SearchHistoryEntry entry;
entry.term = query;
entry.normalizedTerm = normalizedQuery;
entry.searchCount = 1;
entry.lastSearchedAtMs = nowMs;
entries.append(entry);
}
std::sort(entries.begin(), entries.end(),
[](const SearchHistoryEntry &lhs, const SearchHistoryEntry &rhs)
{
if (lhs.lastSearchedAtMs != rhs.lastSearchedAtMs)
{
return lhs.lastSearchedAtMs > rhs.lastSearchedAtMs;
}
if (lhs.searchCount != rhs.searchCount)
{
return lhs.searchCount > rhs.searchCount;
}
return lhs.term.localeAwareCompare(rhs.term) < 0;
});
if (entries.size() > kMaxStoredSearchEntries)
{
entries.erase(entries.begin() + kMaxStoredSearchEntries, entries.end());
}
saveEntries(bucket, entries);
qDebug() << "[SearchHistoryManager] Search recorded | bucket:" << bucket << "| totalEntries:" << entries.size();
Q_EMIT historyChanged(bucket);
}
void SearchHistoryManager::clearHistory(const QString &serverId)
{
const QString bucket = effectiveServerBucket(serverId);
m_loadedBuckets.insert(bucket);
m_cache.insert(bucket, {});
saveEntries(bucket, {});
qDebug() << "[SearchHistoryManager] History cleared | bucket:" << bucket;
Q_EMIT historyChanged(bucket);
}
void SearchHistoryManager::clearAllHistory()
{
const QStringList buckets = allHistoryBuckets();
if (buckets.isEmpty())
{
qDebug() << "[SearchHistoryManager] Clear all history requested | no history buckets found";
return;
}
for (const QString &bucket : buckets)
{
m_loadedBuckets.insert(bucket);
m_cache.insert(bucket, {});
saveEntries(bucket, {});
Q_EMIT historyChanged(bucket);
}
qDebug() << "[SearchHistoryManager] Cleared history for all buckets | count:" << buckets.size();
}
void SearchHistoryManager::removeHistoryTerm(const QString &serverId, QString term)
{
term = term.trimmed();
const QString normalizedTerm = normalizeQuery(term);
if (normalizedTerm.isEmpty())
{
return;
}
const QString bucket = effectiveServerBucket(serverId);
ensureLoaded(bucket);
auto &entries = m_cache[bucket];
const auto newEnd = std::remove_if(entries.begin(), entries.end(),
[&normalizedTerm](const SearchHistoryEntry &entry) {
return entry.normalizedTerm == normalizedTerm;
});
if (newEnd == entries.end())
{
return;
}
entries.erase(newEnd, entries.end());
saveEntries(bucket, entries);
qDebug() << "[SearchHistoryManager] History item removed | bucket:" << bucket
<< "| term:" << term << "| remaining:" << entries.size();
Q_EMIT historyChanged(bucket);
}
QStringList SearchHistoryManager::completionSuggestions(const QString &serverId, const QString &prefix, int limit) const
{
if (!isAutocompleteEnabled())
{
return {};
}
auto matches = filteredEntries(serverId, prefix);
const QString normalizedPrefix = normalizeQuery(prefix);
std::sort(matches.begin(), matches.end(),
[&normalizedPrefix](const SearchHistoryEntry &lhs, const SearchHistoryEntry &rhs)
{ return isBetterRecentMatch(lhs, rhs, normalizedPrefix); });
return takeTerms(matches, limit);
}
QList<SearchHistoryManager::SearchHistoryEntry>
SearchHistoryManager::historyEntries(const QString &serverId,
const QString &prefix) const
{
if (!isEnabled())
{
return {};
}
return filteredEntries(serverId, prefix);
}
QStringList SearchHistoryManager::recentSearches(const QString &serverId, int limit, const QString &prefix) const
{
if (!isEnabled())
{
return {};
}
auto matches = filteredEntries(serverId, prefix);
const QString normalizedPrefix = normalizeQuery(prefix);
std::sort(matches.begin(), matches.end(),
[&normalizedPrefix](const SearchHistoryEntry &lhs, const SearchHistoryEntry &rhs)
{ return isBetterRecentMatch(lhs, rhs, normalizedPrefix); });
return takeTerms(matches, limit);
}
QStringList SearchHistoryManager::frequentSearches(const QString &serverId, int limit, const QString &prefix,
const QStringList &excludeTerms) const
{
if (!isEnabled())
{
return {};
}
auto matches = filteredEntries(serverId, prefix);
const QString normalizedPrefix = normalizeQuery(prefix);
QSet<QString> excludedNormalizedTerms;
for (const QString &term : excludeTerms)
{
const QString normalizedTerm = normalizeQuery(term);
if (!normalizedTerm.isEmpty())
{
excludedNormalizedTerms.insert(normalizedTerm);
}
}
matches.erase(std::remove_if(matches.begin(), matches.end(),
[&excludedNormalizedTerms](const SearchHistoryEntry &entry)
{ return excludedNormalizedTerms.contains(entry.normalizedTerm); }),
matches.end());
std::sort(matches.begin(), matches.end(),
[&normalizedPrefix](const SearchHistoryEntry &lhs, const SearchHistoryEntry &rhs)
{ return isBetterFrequentMatch(lhs, rhs, normalizedPrefix); });
return takeTerms(matches, limit);
}
QString SearchHistoryManager::effectiveServerBucket(const QString &serverId) const
{
const QString trimmedId = serverId.trimmed();
return trimmedId.isEmpty() ? QStringLiteral("__global__") : trimmedId;
}
QString SearchHistoryManager::historyConfigKey(const QString &serverId) const
{
return ConfigKeys::forServer(effectiveServerBucket(serverId), ConfigKeys::SearchHistoryRecords);
}
QStringList SearchHistoryManager::allHistoryBuckets() const
{
QSet<QString> buckets = m_loadedBuckets;
for (auto it = m_cache.constBegin(); it != m_cache.constEnd(); ++it)
{
buckets.insert(it.key());
}
const QString prefix = QStringLiteral("server/");
const QString suffix =
QStringLiteral("/") + QString::fromLatin1(ConfigKeys::SearchHistoryRecords);
const QStringList allKeys = ConfigStore::instance()->allKeys();
for (const QString &key : allKeys)
{
if (!key.startsWith(prefix) || !key.endsWith(suffix))
{
continue;
}
const int bucketLength = key.size() - prefix.size() - suffix.size();
if (bucketLength <= 0)
{
continue;
}
buckets.insert(key.mid(prefix.size(), bucketLength));
}
QStringList result = buckets.values();
std::sort(result.begin(), result.end());
return result;
}
void SearchHistoryManager::ensureLoaded(const QString &serverId) const
{
const QString bucket = effectiveServerBucket(serverId);
if (m_loadedBuckets.contains(bucket))
{
return;
}
m_loadedBuckets.insert(bucket);
const QString jsonStr = ConfigStore::instance()->get<QString>(historyConfigKey(bucket));
if (jsonStr.trimmed().isEmpty())
{
m_cache.insert(bucket, {});
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(jsonStr.toUtf8());
if (!doc.isArray())
{
qDebug() << "[SearchHistoryManager] Invalid history cache payload | bucket:" << bucket;
m_cache.insert(bucket, {});
return;
}
QList<SearchHistoryEntry> entries;
const QJsonArray items = doc.array();
entries.reserve(items.size());
for (const auto &item : items)
{
if (!item.isObject())
{
continue;
}
const QJsonObject obj = item.toObject();
SearchHistoryEntry entry;
entry.term = obj.value(QStringLiteral("term")).toString().trimmed();
entry.normalizedTerm = normalizeQuery(entry.term);
entry.searchCount = qMax(1, obj.value(QStringLiteral("count")).toInt(1));
entry.lastSearchedAtMs = obj.value(QStringLiteral("last")).toVariant().toLongLong();
if (entry.normalizedTerm.isEmpty())
{
continue;
}
entries.append(entry);
}
std::sort(entries.begin(), entries.end(),
[](const SearchHistoryEntry &lhs, const SearchHistoryEntry &rhs)
{
if (lhs.lastSearchedAtMs != rhs.lastSearchedAtMs)
{
return lhs.lastSearchedAtMs > rhs.lastSearchedAtMs;
}
if (lhs.searchCount != rhs.searchCount)
{
return lhs.searchCount > rhs.searchCount;
}
return lhs.term.localeAwareCompare(rhs.term) < 0;
});
m_cache.insert(bucket, entries);
qDebug() << "[SearchHistoryManager] History cache loaded | bucket:" << bucket << "| entryCount:" << entries.size();
}
void SearchHistoryManager::saveEntries(const QString &serverId, const QList<SearchHistoryEntry> &entries)
{
QJsonArray array;
for (const auto &entry : entries)
{
QJsonObject obj;
obj[QStringLiteral("term")] = entry.term;
obj[QStringLiteral("count")] = entry.searchCount;
obj[QStringLiteral("last")] = QString::number(entry.lastSearchedAtMs);
array.append(obj);
}
ConfigStore::instance()->set(
historyConfigKey(serverId),
QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)));
}
QList<SearchHistoryManager::SearchHistoryEntry> SearchHistoryManager::filteredEntries(const QString &serverId,
const QString &prefix) const
{
const QString bucket = effectiveServerBucket(serverId);
ensureLoaded(bucket);
const QString normalizedPrefix = normalizeQuery(prefix);
const auto entries = m_cache.value(bucket);
if (normalizedPrefix.isEmpty())
{
return entries;
}
QList<SearchHistoryEntry> result;
result.reserve(entries.size());
for (const auto &entry : entries)
{
if (entry.normalizedTerm.contains(normalizedPrefix))
{
result.append(entry);
}
}
return result;
}
QString SearchHistoryManager::normalizeQuery(QString query)
{
query = query.simplified();
return query.toCaseFolded();
}
@@ -0,0 +1,64 @@
#ifndef SEARCHHISTORYMANAGER_H
#define SEARCHHISTORYMANAGER_H
#include <QObject>
#include <QHash>
#include <QList>
#include <QSet>
#include <QStringList>
class SearchHistoryManager : public QObject {
Q_OBJECT
public:
struct SearchHistoryEntry {
QString term;
QString normalizedTerm;
int searchCount = 0;
qint64 lastSearchedAtMs = 0;
};
static SearchHistoryManager *instance();
bool isEnabled() const;
bool isAutocompleteEnabled() const;
void recordSearch(const QString &serverId, QString query);
void clearHistory(const QString &serverId);
void clearAllHistory();
void removeHistoryTerm(const QString &serverId, QString term);
QStringList completionSuggestions(const QString &serverId,
const QString &prefix = QString(),
int limit = 8) const;
QList<SearchHistoryEntry> historyEntries(const QString &serverId,
const QString &prefix = QString()) const;
QStringList recentSearches(const QString &serverId, int limit = 6,
const QString &prefix = QString()) const;
QStringList frequentSearches(const QString &serverId, int limit = 6,
const QString &prefix = QString(),
const QStringList &excludeTerms = {}) const;
Q_SIGNALS:
void historyChanged(const QString &serverId);
void enabledChanged(bool enabled);
void autocompleteEnabledChanged(bool enabled);
private:
explicit SearchHistoryManager(QObject *parent = nullptr);
QString effectiveServerBucket(const QString &serverId) const;
QString historyConfigKey(const QString &serverId) const;
QStringList allHistoryBuckets() const;
void ensureLoaded(const QString &serverId) const;
void saveEntries(const QString &serverId,
const QList<SearchHistoryEntry> &entries);
QList<SearchHistoryEntry> filteredEntries(const QString &serverId,
const QString &prefix) const;
static QString normalizeQuery(QString query);
mutable QHash<QString, QList<SearchHistoryEntry>> m_cache;
mutable QSet<QString> m_loadedBuckets;
};
#endif
+291
View File
@@ -0,0 +1,291 @@
#include "thememanager.h"
#include <QApplication>
#include <QGuiApplication>
#include <QStyleHints>
#include <QFile>
#include <QHash>
#include <QMutex>
#include <QMutexLocker>
#include <QThread>
#include <QMetaObject>
#include <QDebug>
#include <QPalette>
#include <QPixmap>
#include <QPainter>
#include <QSettings>
#include <QProcess>
#include <QRegularExpression>
#include "config/configstore.h"
#include "config/config_keys.h"
namespace {
QString resolveThemeIconPath(QString svgPath)
{
if (svgPath.isEmpty()) {
return svgPath;
}
const bool isDarkMode = ThemeManager::instance()->isDarkMode();
QString candidatePath = svgPath;
if (isDarkMode && svgPath.contains(QStringLiteral("/light/"))) {
candidatePath.replace(QStringLiteral("/light/"), QStringLiteral("/dark/"));
} else if (!isDarkMode && svgPath.contains(QStringLiteral("/dark/"))) {
candidatePath.replace(QStringLiteral("/dark/"), QStringLiteral("/light/"));
}
return QFile::exists(candidatePath) ? candidatePath : svgPath;
}
QHash<QString, QPixmap> g_iconCache;
QMutex g_iconCacheMutex;
}
ThemeManager* ThemeManager::instance()
{
static ThemeManager s_instance;
return &s_instance;
}
ThemeManager::ThemeManager(QObject *parent)
: QObject(parent), m_currentTheme(Dark)
{
}
QIcon ThemeManager::getAdaptiveIcon(const QString& svgPath, QColor customColor)
{
const QString themedSvgPath = resolveThemeIconPath(svgPath);
QColor targetColor = customColor;
if (!targetColor.isValid()) {
targetColor = ThemeManager::instance()->isDarkMode() ? QColor("#CBD5E1") : QColor("#475569");
}
const QString cacheKey = themedSvgPath
+ QLatin1Char('|')
+ QString::number(targetColor.rgba(), 16);
{
QMutexLocker locker(&g_iconCacheMutex);
auto it = g_iconCache.constFind(cacheKey);
if (it != g_iconCache.constEnd()) {
return QIcon(*it);
}
}
const int renderSize = 128;
QPixmap pixmap(renderSize, renderSize);
pixmap.fill(Qt::transparent);
QIcon originalIcon(themedSvgPath);
QPixmap sourcePixmap = originalIcon.pixmap(renderSize, renderSize);
if (sourcePixmap.isNull()) {
return originalIcon;
}
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.drawPixmap(0, 0, sourcePixmap);
painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
painter.fillRect(pixmap.rect(), targetColor);
painter.end();
{
QMutexLocker locker(&g_iconCacheMutex);
g_iconCache.insert(cacheKey, pixmap);
}
return QIcon(pixmap);
}
void ThemeManager::setTheme(Theme theme)
{
m_currentTheme.store(theme, std::memory_order_relaxed);
{
QMutexLocker locker(&g_iconCacheMutex);
g_iconCache.clear();
}
if (QThread::currentThread() != qApp->thread()) {
QMetaObject::invokeMethod(this, [this, theme]() {
applyStyleSheet(theme);
Q_EMIT themeChanged(theme);
}, Qt::QueuedConnection);
} else {
applyStyleSheet(theme);
Q_EMIT themeChanged(theme);
}
}
void ThemeManager::applyThemeMode(const QString &mode)
{
Theme target;
if (mode == QLatin1String("light")) {
target = Light;
} else if (mode == QLatin1String("dark")) {
target = Dark;
} else {
target = detectSystemTheme();
}
setTheme(target);
}
ThemeManager::Theme ThemeManager::detectSystemTheme()
{
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
auto scheme = QGuiApplication::styleHints()->colorScheme();
return (scheme == Qt::ColorScheme::Light) ? Light : Dark;
#elif defined(Q_OS_WIN)
QSettings reg("HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
QSettings::NativeFormat);
QVariant val = reg.value("AppsUseLightTheme", 0);
return val.toInt() == 1 ? Light : Dark;
#elif defined(Q_OS_MACOS)
QProcess process;
process.start("defaults", {"read", "-g", "AppleInterfaceStyle"});
process.waitForFinished(500);
QString output = process.readAllStandardOutput().trimmed();
return (output.compare("Dark", Qt::CaseInsensitive) == 0) ? Dark : Light;
#elif defined(Q_OS_LINUX)
QProcess process;
process.start("gsettings", {"get", "org.gnome.desktop.interface", "color-scheme"});
process.waitForFinished(500);
QString output = process.readAllStandardOutput().trimmed();
if (output.contains("dark", Qt::CaseInsensitive)) {
return Dark;
}
QProcess gtkProcess;
gtkProcess.start("gsettings", {"get", "org.gnome.desktop.interface", "gtk-theme"});
gtkProcess.waitForFinished(500);
QString gtkTheme = gtkProcess.readAllStandardOutput().trimmed();
return gtkTheme.contains("dark", Qt::CaseInsensitive) ? Dark : Light;
#else
return Dark;
#endif
}
ThemeManager::Theme ThemeManager::currentTheme() const
{
return m_currentTheme.load(std::memory_order_relaxed);
}
bool ThemeManager::isDarkMode() const
{
return m_currentTheme.load(std::memory_order_relaxed) == Dark;
}
qreal ThemeManager::fontScaleFactor()
{
QString sizeKey = ConfigStore::instance()->get<QString>(ConfigKeys::FontSize, "medium");
if (sizeKey == QLatin1String("small")) return 0.85;
if (sizeKey == QLatin1String("large")) return 1.15;
return 1.0;
}
void ThemeManager::applyStyleSheet(Theme theme)
{
QString qssPath = (theme == Dark) ? ":/qss/dark-style.qss" : ":/qss/light-style.qss";
QFile file(qssPath);
if (file.open(QFile::ReadOnly | QFile::Text)) {
QString qss = QString::fromUtf8(file.readAll());
file.close();
QString sizeKey = ConfigStore::instance()->get<QString>(ConfigKeys::FontSize, "medium");
qss = scaleFontSizes(qss, sizeKey);
qApp->setStyleSheet(qss);
QPalette palette = qApp->palette();
if (theme == Dark) {
palette.setColor(QPalette::Highlight, QColor("#3B82F6"));
palette.setColor(QPalette::HighlightedText, QColor("#FFFFFF"));
} else {
palette.setColor(QPalette::Highlight, QColor("#93C5FD"));
palette.setColor(QPalette::HighlightedText, QColor("#0F172A"));
}
qApp->setPalette(palette);
} else {
qWarning() << "ThemeManager failed to load stylesheet:" << qssPath;
}
}
QString ThemeManager::scaleFontSizes(const QString &qss, const QString &sizeKey)
{
qreal factor = 1.0;
if (sizeKey == QLatin1String("small")) {
factor = 0.85;
} else if (sizeKey == QLatin1String("large")) {
factor = 1.15;
}
if (qFuzzyCompare(factor, 1.0)) {
return qss;
}
static QRegularExpression re(QStringLiteral("font-size:\\s*(\\d+)px"),
QRegularExpression::CaseInsensitiveOption);
QString result;
result.reserve(qss.size() + 64);
qsizetype lastEnd = 0;
auto it = re.globalMatch(qss);
while (it.hasNext()) {
auto match = it.next();
qsizetype startPos = match.capturedStart();
if (startPos > lastEnd) {
result.append(qss.mid(lastEnd, startPos - lastEnd));
}
int originalSize = match.captured(1).toInt();
int scaledSize = qMax(8, qRound(originalSize * factor));
result.append(QStringLiteral("font-size: %1px").arg(scaledSize));
lastEnd = match.capturedEnd();
}
result.append(qss.mid(lastEnd));
return result;
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef THEMEMANAGER_H
#define THEMEMANAGER_H
#include <QObject>
#include <QString>
#include <QIcon>
#include <QColor>
#include <atomic>
class ThemeManager : public QObject
{
Q_OBJECT
public:
enum Theme {
Light,
Dark
};
Q_ENUM(Theme)
static ThemeManager* instance();
static QIcon getAdaptiveIcon(const QString& svgPath, QColor customColor = QColor());
void setTheme(Theme theme);
void applyThemeMode(const QString &mode);
static Theme detectSystemTheme();
Theme currentTheme() const;
bool isDarkMode() const;
static qreal fontScaleFactor();
signals:
void themeChanged(Theme theme);
private:
explicit ThemeManager(QObject *parent = nullptr);
~ThemeManager() = default;
void applyStyleSheet(Theme theme);
static QString scaleFontSizes(const QString &qss, const QString &sizeKey);
std::atomic<Theme> m_currentTheme;
};
#endif
+88
View File
@@ -0,0 +1,88 @@
#include "traymanager.h"
#include <QApplication>
#include <QIcon>
#include "config/configstore.h"
#include "config/config_keys.h"
TrayManager::TrayManager(QObject *parent)
: QObject(parent),
m_trayIcon(new QSystemTrayIcon(this)),
m_trayMenu(new QMenu())
{
m_trayIcon->setIcon(QIcon(":/svg/qemby_logo.svg"));
m_trayIcon->setToolTip(qApp->applicationName());
setupMenu();
m_trayIcon->setContextMenu(m_trayMenu);
connect(m_trayIcon, &QSystemTrayIcon::activated, this, &TrayManager::onTrayActivated);
connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, &TrayManager::onConfigChanged);
connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, &TrayManager::onThemeChanged);
bool isTrayEnabled = ConfigStore::instance()->get<bool>(ConfigKeys::CloseToTray, false);
if (QSystemTrayIcon::isSystemTrayAvailable()) {
m_trayIcon->setVisible(isTrayEnabled);
QApplication::setQuitOnLastWindowClosed(!isTrayEnabled);
}
updateIcons();
}
TrayManager::~TrayManager() {
if (m_trayMenu) {
m_trayMenu->deleteLater();
}
}
void TrayManager::setupMenu() {
m_trayMenu->setObjectName("modern-tray-menu");
m_trayMenu->setWindowFlags(m_trayMenu->windowFlags() | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
m_trayMenu->setAttribute(Qt::WA_TranslucentBackground);
m_showAction = new QAction(tr("Show qEmby"), this);
connect(m_showAction, &QAction::triggered, this, &TrayManager::showMainRequested);
m_quitAction = new QAction(tr("Quit"), this);
connect(m_quitAction, &QAction::triggered, this, &TrayManager::quitRequested);
m_trayMenu->addAction(m_showAction);
m_trayMenu->addSeparator();
m_trayMenu->addAction(m_quitAction);
}
void TrayManager::updateIcons() {
m_showAction->setIcon(ThemeManager::getAdaptiveIcon(":/svg/dark/window-pop.svg"));
m_quitAction->setIcon(ThemeManager::getAdaptiveIcon(":/svg/dark/logout.svg"));
}
void TrayManager::onThemeChanged(ThemeManager::Theme theme) {
Q_UNUSED(theme);
updateIcons();
}
void TrayManager::onTrayActivated(QSystemTrayIcon::ActivationReason reason) {
if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick) {
emit showMainRequested();
}
}
void TrayManager::onConfigChanged(const QString &key, const QVariant &value) {
if (key == ConfigKeys::CloseToTray) {
bool enabled = value.toBool();
if (QSystemTrayIcon::isSystemTrayAvailable()) {
m_trayIcon->setVisible(enabled);
QApplication::setQuitOnLastWindowClosed(!enabled);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
#ifndef TRAYMANAGER_H
#define TRAYMANAGER_H
#include <QObject>
#include <QSystemTrayIcon>
#include <QMenu>
#include <QVariant>
#include <QAction>
#include "thememanager.h"
class TrayManager : public QObject {
Q_OBJECT
public:
explicit TrayManager(QObject *parent = nullptr);
~TrayManager() override;
signals:
void showMainRequested();
void quitRequested();
private slots:
void onTrayActivated(QSystemTrayIcon::ActivationReason reason);
void onConfigChanged(const QString &key, const QVariant &value);
void onThemeChanged(ThemeManager::Theme theme);
private:
void setupMenu();
void updateIcons();
QSystemTrayIcon *m_trayIcon;
QMenu *m_trayMenu;
QAction *m_showAction;
QAction *m_quitAction;
};
#endif