chore: baseline usable version
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 ®Key,
|
||||
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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user