feat: add playlist, recent files and MPRIS backends

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
triandClaude Opus 5.5 committed 2026-10-28 09:26:12 +07:00
1 parent 6dc9377de1
commit 54ad4eecf3
18 files changed
+2240 -4

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ include(FeatureSummary)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Widgets Network Concurrent)
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Widgets Network Concurrent DBus)
find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS Kirigami I18n CoreAddons Crash Config)
find_package(MpvQt 1.0 REQUIRED)
find_package(QCoro6 0.10 REQUIRED COMPONENTS Core Network)
+6
View File
@@ -7,3 +7,9 @@ ecm_add_test(mpvuserconfigtest.cpp
TEST_NAME mpvuserconfigtest
LINK_LIBRARIES qtpotato_core Qt6::Test
)
ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp
TEST_NAME playlistmodeltest
LINK_LIBRARIES Qt6::Qml Qt6::Test
)
target_include_directories(playlistmodeltest PRIVATE ../src/playlist)
+105
View File
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "playlistmodel.h"
#include <QFile>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
class PlaylistModelTest : public QObject
{
Q_OBJECT
private:
static void touch(const QTemporaryDir &dir, const QString &name)
{
QFile file(dir.filePath(name));
QVERIFY(file.open(QIODevice::WriteOnly));
}
static QStringList names(const PlaylistModel &model)
{
QStringList result;
for (int i = 0; i < model.rowCount(); ++i) {
result.append(model.data(model.index(i), PlaylistModel::NameRole).toString());
}
return result;
}
private Q_SLOTS:
void sortsEpisodesNaturallyAndSkipsOtherFiles()
{
QTemporaryDir dir;
for (const QString &name : {u"Show - 10.mkv"_s, u"show - 2.mkv"_s, u"Show - 1.mkv"_s, u"notes.txt"_s, u"Show - 1.srt"_s, u"theme.mp3"_s}) {
touch(dir, name);
}
const QList<QUrl> urls = PlaylistModel::mediaFilesIn(dir.path());
QStringList fileNames;
for (const QUrl &url : urls) {
fileNames.append(url.fileName());
}
QCOMPARE(fileNames, (QStringList{u"Show - 1.mkv"_s, u"show - 2.mkv"_s, u"Show - 10.mkv"_s, u"theme.mp3"_s}));
}
void openFileLoadsSiblingsAndPlaysTheFile()
{
QTemporaryDir dir;
for (const QString &name : {u"Ep 1.mkv"_s, u"Ep 2.mkv"_s, u"Ep 3.mkv"_s}) {
touch(dir, name);
}
PlaylistModel model;
QSignalSpy played(&model, &PlaylistModel::playRequested);
const QUrl second = QUrl::fromLocalFile(dir.filePath(u"Ep 2.mkv"_s));
model.openFile(second, true);
QCOMPARE(names(model), (QStringList{u"Ep 1.mkv"_s, u"Ep 2.mkv"_s, u"Ep 3.mkv"_s}));
QCOMPARE(model.playingIndex(), 1);
QCOMPARE(played.count(), 1);
QCOMPARE(played.first().first().toUrl(), second);
model.openFile(second, false);
QCOMPARE(names(model), (QStringList{u"Ep 2.mkv"_s}));
}
void nextAndPreviousStopAtTheEndsUnlessWrapping()
{
PlaylistModel model;
model.append({QUrl(u"https://example.com/a"_s), QUrl(u"https://example.com/b"_s)});
model.play(1);
QVERIFY(!model.playNext());
QCOMPARE(model.playingIndex(), 1);
QVERIFY(model.playNext(true));
QCOMPARE(model.playingIndex(), 0);
QVERIFY(!model.playPrevious());
QVERIFY(model.playPrevious(true));
QCOMPARE(model.playingIndex(), 1);
}
void playingIndexFollowsRemoveAndMove()
{
PlaylistModel model;
model.append({QUrl(u"https://example.com/a"_s), QUrl(u"https://example.com/b"_s), QUrl(u"https://example.com/c"_s)});
model.play(1);
model.move(1, 2);
QCOMPARE(model.playingIndex(), 2);
model.move(0, 2);
QCOMPARE(model.playingIndex(), 1);
model.remove(0);
QCOMPARE(model.playingIndex(), 0);
model.remove(0);
QCOMPARE(model.playingIndex(), -1);
QCOMPARE(model.count(), 1);
}
};
QTEST_GUILESS_MAIN(PlaylistModelTest)
#include "playlistmodeltest.moc"
+14 -1
View File
@@ -18,8 +18,20 @@ target_link_libraries(qtpotato_core PUBLIC
add_executable(qtpotato
main.cpp
models/recentfilesmodel.cpp
models/recentfilesmodel.h
mpris/mpriscontroller.cpp
mpris/mpriscontroller.h
mpris/mprisitem.cpp
mpris/mprisitem.h
mpris/playeradaptor.cpp
mpris/playeradaptor.h
mpris/rootadaptor.cpp
mpris/rootadaptor.h
mpv/mpvitem.cpp
mpv/mpvitem.h
playlist/playlistmodel.cpp
playlist/playlistmodel.h
translate/apikeystore.cpp
translate/apikeystore.h
translate/llmclient.cpp
@@ -51,7 +63,7 @@ ecm_target_qml_sources(qtpotato SOURCES
qml/SettingsPage.qml
)
target_include_directories(qtpotato PRIVATE mpv translate)
target_include_directories(qtpotato PRIVATE models mpris mpv playlist translate)
target_compile_definitions(qtpotato PRIVATE QTPOTATO_VERSION_STRING="${PROJECT_VERSION}")
target_link_libraries(qtpotato PRIVATE
@@ -63,6 +75,7 @@ target_link_libraries(qtpotato PRIVATE
Qt6::Widgets
Qt6::Network
Qt6::Concurrent
Qt6::DBus
KF6::I18n
KF6::I18nQml
KF6::CoreAddons
+122
View File
@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "recentfilesmodel.h"
#include <KConfigGroup>
#include <KSharedConfig>
#include <algorithm>
using namespace Qt::Literals::StringLiterals;
namespace
{
// Stored even when fewer are shown, so raising the limit brings old entries back.
constexpr int StoredFiles = 50;
KConfigGroup configGroup()
{
return KSharedConfig::openStateConfig()->group(u"RecentFiles"_s);
}
}
RecentFilesModel::RecentFilesModel(QObject *parent)
: QAbstractListModel(parent)
{
const QStringList urls = configGroup().readEntry(u"Urls"_s, QStringList());
for (const QString &url : urls) {
m_urls.append(QUrl(url));
}
}
int RecentFilesModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : count();
}
QVariant RecentFilesModel::data(const QModelIndex &index, int role) const
{
if (!checkIndex(index, CheckIndexOption::IndexIsValid)) {
return {};
}
const QUrl &url = m_urls.at(index.row());
switch (role) {
case UrlRole:
return url;
case Qt::DisplayRole:
case NameRole:
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
case PathRole:
return url.isLocalFile() ? url.toLocalFile() : url.toDisplayString();
}
return {};
}
QHash<int, QByteArray> RecentFilesModel::roleNames() const
{
return {
{UrlRole, "url"},
{NameRole, "name"},
{PathRole, "path"},
};
}
int RecentFilesModel::count() const
{
return int(std::min<qsizetype>(m_urls.size(), m_maxCount));
}
int RecentFilesModel::maxCount() const
{
return m_maxCount;
}
void RecentFilesModel::setMaxCount(int maxCount)
{
maxCount = std::clamp(maxCount, 0, StoredFiles);
if (maxCount == m_maxCount) {
return;
}
beginResetModel();
m_maxCount = maxCount;
endResetModel();
Q_EMIT maxCountChanged();
Q_EMIT countChanged();
}
void RecentFilesModel::add(const QUrl &url)
{
if (!url.isValid()) {
return;
}
beginResetModel();
m_urls.removeAll(url);
m_urls.prepend(url);
if (m_urls.size() > StoredFiles) {
m_urls.resize(StoredFiles);
}
endResetModel();
Q_EMIT countChanged();
save();
}
void RecentFilesModel::clear()
{
beginResetModel();
m_urls.clear();
endResetModel();
Q_EMIT countChanged();
save();
}
void RecentFilesModel::save() const
{
QStringList urls;
for (const QUrl &url : m_urls) {
urls.append(url.toString());
}
KConfigGroup group = configGroup();
group.writeEntry(u"Urls"_s, urls);
group.sync();
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QList>
#include <QUrl>
#include <QtQml/qqmlregistration.h>
// Recently opened files, newest first, kept in the application's state file.
class RecentFilesModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(int count READ count NOTIFY countChanged)
// Rows beyond this are hidden (but kept, in case it is raised again).
Q_PROPERTY(int maxCount READ maxCount WRITE setMaxCount NOTIFY maxCountChanged)
public:
enum Roles {
UrlRole = Qt::UserRole + 1,
NameRole,
PathRole,
};
Q_ENUM(Roles)
explicit RecentFilesModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
int count() const;
int maxCount() const;
void setMaxCount(int maxCount);
// Moves \a url to the top, adding it if needed.
Q_INVOKABLE void add(const QUrl &url);
Q_INVOKABLE void clear();
Q_SIGNALS:
void countChanged();
void maxCountChanged();
private:
void save() const;
QList<QUrl> m_urls;
int m_maxCount = 10;
};
+498
View File
@@ -0,0 +1,498 @@
/*
* SPDX-FileCopyrightText: 2026 George Florea Bănuș <georgefb899@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "mpriscontroller.h"
#include <QCoreApplication>
#include <QDBusConnection>
#include <QTimer>
#include "playeradaptor.h"
#include "rootadaptor.h"
using namespace Qt::StringLiterals;
class MprisControllerPrivate
{
Q_DISABLE_COPY(MprisControllerPrivate);
public:
MprisControllerPrivate(MprisController *q)
: q_ptr(q)
, rootAdaptor(*q)
, playerAdaptor(*q)
{
}
MprisController *const q_ptr;
QTimer notifyTimer;
// Interface MediaPlayer2
RootAdaptor rootAdaptor;
bool canQuit = false;
bool fullscreen = false;
bool canSetFullscreen = false;
bool canRaise = false;
bool hasTrackList = false;
QString identity;
QString desktopEntry;
QStringList supportedUriSchemes;
QStringList supportedMimeTypes;
// Interface MediaPlayer2.Player
PlayerAdaptor playerAdaptor;
QString playbackStatus;
QString loopStatus;
double rate = 1.0;
bool shuffle = false;
QVariantMap metadata;
double volume = 1.0;
qlonglong position = 0;
double minimumRate = 0.0;
double maximumRate = 1.0;
bool canGoNext = false;
bool canGoPrevious = false;
bool canPlay = false;
bool canPause = false;
bool canSeek = false;
bool canControl = false;
};
MprisController::MprisController(QObject *parent)
: QObject{parent}
, d_ptr{std::make_unique<MprisControllerPrivate>(this)}
{
d_ptr->notifyTimer.setSingleShot(true);
d_ptr->notifyTimer.setInterval(100);
d_ptr->notifyTimer.callOnTimeout([this]() {
d_ptr->playerAdaptor.notify(u"Metadata"_s, d_ptr->metadata);
});
// register mpris dbus service; the instance suffix lets several players coexist
QDBusConnection::sessionBus().registerService(u"org.mpris.MediaPlayer2.qtpotato.instance%1"_s.arg(QCoreApplication::applicationPid()));
QDBusConnection::sessionBus().registerObject(u"/org/mpris/MediaPlayer2"_s, this, QDBusConnection::ExportAdaptors);
}
MprisController::~MprisController() = default;
void MprisController::raise()
{
Q_EMIT raiseRequested();
}
void MprisController::quit()
{
Q_EMIT quitRequested();
}
void MprisController::next()
{
Q_EMIT nextRequested();
}
void MprisController::previous()
{
Q_EMIT previousRequested();
}
void MprisController::pause()
{
Q_EMIT pauseRequested();
}
void MprisController::playpause()
{
Q_EMIT playpauseRequested();
}
void MprisController::stop()
{
Q_EMIT stopRequested();
}
void MprisController::play()
{
Q_EMIT playRequested();
}
void MprisController::seek(qlonglong offset)
{
Q_EMIT seekRequested(offset);
}
void MprisController::seekTo(qlonglong position)
{
Q_EMIT seekToRequested(position);
}
void MprisController::openUri(QString uri)
{
Q_EMIT openUriRequested(uri);
}
bool MprisController::canQuit() const
{
return d_ptr->canQuit;
}
void MprisController::setCanQuit(bool newCanQuit)
{
if (d_ptr->canQuit == newCanQuit) {
return;
}
d_ptr->canQuit = newCanQuit;
d_ptr->rootAdaptor.notify(u"CanQuit"_s, d_ptr->canQuit);
}
bool MprisController::fullscreen() const
{
return d_ptr->fullscreen;
}
void MprisController::setFullscreen(bool newFullscreen)
{
if (d_ptr->fullscreen == newFullscreen) {
return;
}
d_ptr->fullscreen = newFullscreen;
d_ptr->rootAdaptor.notify(u"Fullscreen"_s, d_ptr->fullscreen);
}
void MprisController::requestFullscreenChange(bool newFullscreen)
{
Q_EMIT fullscreenChangeRequested(newFullscreen);
}
bool MprisController::canSetFullscreen() const
{
return d_ptr->canSetFullscreen;
}
void MprisController::setCanSetFullscreen(bool newCanSetFullscreen)
{
if (d_ptr->canSetFullscreen == newCanSetFullscreen) {
return;
}
d_ptr->canSetFullscreen = newCanSetFullscreen;
d_ptr->rootAdaptor.notify(u"CanSetFullscreen"_s, d_ptr->canSetFullscreen);
}
bool MprisController::canRaise() const
{
return d_ptr->canRaise;
}
void MprisController::setCanRaise(bool newCanRaise)
{
if (d_ptr->canRaise == newCanRaise) {
return;
}
d_ptr->canRaise = newCanRaise;
d_ptr->rootAdaptor.notify(u"CanRaise"_s, d_ptr->canRaise);
}
bool MprisController::hasTrackList() const
{
return d_ptr->hasTrackList;
}
void MprisController::setHasTrackList(bool newHasTrackList)
{
if (d_ptr->hasTrackList == newHasTrackList) {
return;
}
d_ptr->hasTrackList = newHasTrackList;
d_ptr->rootAdaptor.notify(u"HasTrackList"_s, d_ptr->hasTrackList);
}
QString MprisController::identity() const
{
return d_ptr->identity;
}
void MprisController::setIdentity(const QString &newIdentity)
{
if (d_ptr->identity == newIdentity) {
return;
}
d_ptr->identity = newIdentity;
d_ptr->rootAdaptor.notify(u"Identity"_s, d_ptr->identity);
}
QString MprisController::desktopEntry() const
{
return d_ptr->desktopEntry;
}
void MprisController::setDesktopEntry(const QString &newDesktopEntry)
{
if (d_ptr->desktopEntry == newDesktopEntry) {
return;
}
d_ptr->desktopEntry = newDesktopEntry;
d_ptr->rootAdaptor.notify(u"DesktopEntry"_s, d_ptr->desktopEntry);
}
QStringList MprisController::supportedUriSchemes() const
{
return d_ptr->supportedUriSchemes;
}
void MprisController::setSupportedUriSchemes(const QStringList &newSupportedUriSchemes)
{
if (d_ptr->supportedUriSchemes == newSupportedUriSchemes) {
return;
}
d_ptr->supportedUriSchemes = newSupportedUriSchemes;
d_ptr->rootAdaptor.notify(u"SupportedUriSchemes"_s, d_ptr->supportedUriSchemes);
}
QStringList MprisController::supportedMimeTypes() const
{
return d_ptr->supportedMimeTypes;
}
void MprisController::setSupportedMimeTypes(const QStringList &newSupportedMimeTypes)
{
if (d_ptr->supportedMimeTypes == newSupportedMimeTypes) {
return;
}
d_ptr->supportedMimeTypes = newSupportedMimeTypes;
d_ptr->rootAdaptor.notify(u"SupportedMimeTypes"_s, d_ptr->supportedMimeTypes);
}
QString MprisController::playbackStatus() const
{
return d_ptr->playbackStatus;
}
void MprisController::setPlaybackStatus(const QString &newStatus)
{
if (d_ptr->playbackStatus == newStatus) {
return;
}
d_ptr->playbackStatus = newStatus;
d_ptr->playerAdaptor.notify(u"PlaybackStatus"_s, d_ptr->playbackStatus);
}
QString MprisController::loopStatus() const
{
return d_ptr->loopStatus;
}
void MprisController::requestLoopStatusChange(const QString &newLoopStatus)
{
Q_EMIT loopStatusChangeRequested(newLoopStatus);
}
double MprisController::rate() const
{
return d_ptr->rate;
}
void MprisController::setRate(const double newRate)
{
if (d_ptr->rate == newRate) {
return;
}
d_ptr->rate = newRate;
d_ptr->playerAdaptor.notify(u"Rate"_s, d_ptr->rate);
}
void MprisController::requestRateChange(double newRate)
{
Q_EMIT rateChangeRequested(newRate);
}
void MprisController::setLoopStatus(const QString &newLoopStatus)
{
if (d_ptr->loopStatus == newLoopStatus) {
return;
}
d_ptr->loopStatus = newLoopStatus;
d_ptr->playerAdaptor.notify(u"LoopStatus"_s, d_ptr->loopStatus);
}
bool MprisController::shuffle() const
{
return d_ptr->shuffle;
}
void MprisController::setShuffle(const bool newShuffle)
{
if (d_ptr->shuffle == newShuffle) {
return;
}
d_ptr->shuffle = newShuffle;
d_ptr->playerAdaptor.notify(u"Shuffle"_s, d_ptr->shuffle);
}
void MprisController::requestShuffleChange(bool newShuffle)
{
Q_EMIT shuffleChangeRequested(newShuffle);
}
QVariantMap MprisController::metadata() const
{
return d_ptr->metadata;
}
void MprisController::setMetadata(const QString &field, const QVariant value)
{
d_ptr->metadata.insert(field, value);
d_ptr->notifyTimer.start();
}
double MprisController::volume() const
{
return d_ptr->volume;
}
void MprisController::setVolume(const double newVolume)
{
if (d_ptr->volume == newVolume) {
return;
}
d_ptr->volume = newVolume;
d_ptr->playerAdaptor.notify(u"Volume"_s, d_ptr->volume);
}
void MprisController::requestVolumeChange(const double &newVolume)
{
Q_EMIT volumeChangeRequested(newVolume);
}
qlonglong MprisController::position() const
{
return d_ptr->position;
}
void MprisController::setPosition(const qlonglong newPosition)
{
if (d_ptr->position == newPosition) {
return;
}
d_ptr->position = newPosition;
}
double MprisController::minimumRate() const
{
return d_ptr->minimumRate;
}
void MprisController::setMinimumRate(const double newMinRate)
{
if (d_ptr->minimumRate == newMinRate) {
return;
}
d_ptr->minimumRate = newMinRate;
d_ptr->playerAdaptor.notify(u"MinimumRate"_s, d_ptr->minimumRate);
}
double MprisController::maximumRate() const
{
return d_ptr->maximumRate;
}
void MprisController::setMaximumRate(const double newMaxRate)
{
if (d_ptr->maximumRate == newMaxRate) {
return;
}
d_ptr->maximumRate = newMaxRate;
d_ptr->playerAdaptor.notify(u"MaximumRate"_s, d_ptr->maximumRate);
}
bool MprisController::canGoNext() const
{
return d_ptr->canGoNext;
}
void MprisController::setCanGoNext(const bool newCanGoNext)
{
if (d_ptr->canGoNext == newCanGoNext) {
return;
}
d_ptr->canGoNext = newCanGoNext;
d_ptr->playerAdaptor.notify(u"CanGoNext"_s, d_ptr->canGoNext);
}
bool MprisController::canGoPrevious() const
{
return d_ptr->canGoPrevious;
}
void MprisController::setCanGoPrevious(const bool newCanGoPrevious)
{
if (d_ptr->canGoPrevious == newCanGoPrevious) {
return;
}
d_ptr->canGoPrevious = newCanGoPrevious;
d_ptr->playerAdaptor.notify(u"CanGoPrevious"_s, d_ptr->canGoPrevious);
}
bool MprisController::canPlay() const
{
return d_ptr->canPlay;
}
void MprisController::setCanPlay(const bool newCanPlay)
{
if (d_ptr->canPlay == newCanPlay) {
return;
}
d_ptr->canPlay = newCanPlay;
d_ptr->playerAdaptor.notify(u"CanPlay"_s, d_ptr->canPlay);
}
bool MprisController::canPause() const
{
return d_ptr->canPause;
}
void MprisController::setCanPause(const bool newCanPause)
{
if (d_ptr->canPause == newCanPause) {
return;
}
d_ptr->canPause = newCanPause;
d_ptr->playerAdaptor.notify(u"CanPause"_s, d_ptr->canPause);
}
bool MprisController::canSeek() const
{
return d_ptr->canSeek;
}
void MprisController::setCanSeek(const bool newCanSeek)
{
if (d_ptr->canSeek == newCanSeek) {
return;
}
d_ptr->canSeek = newCanSeek;
d_ptr->playerAdaptor.notify(u"CanSeek"_s, d_ptr->canSeek);
}
bool MprisController::canControl() const
{
return d_ptr->canControl;
}
void MprisController::setCanControl(const bool newCanControl)
{
if (d_ptr->canControl == newCanControl) {
return;
}
d_ptr->canControl = newCanControl;
d_ptr->playerAdaptor.notify(u"CanControl"_s, d_ptr->canControl);
}
void MprisController::seeked(qlonglong position)
{
d_ptr->playerAdaptor.seeked(position);
}
#include "moc_mpriscontroller.cpp"
+214
View File
@@ -0,0 +1,214 @@
/*
* SPDX-FileCopyrightText: 2026 George Florea Bănuș <georgefb899@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef MPRISCONTROLLER_H
#define MPRISCONTROLLER_H
#include <QObject>
class MprisControllerPrivate;
/*!
* \class MprisController
* \inmodule MPRIS2
* \brief Controls communication between application and MPRIS clients.
*
* MprisController receives requests from the MPRIS D-Bus adaptors and
* synchronizes state between the application and connected MPRIS clients.
*/
class MprisController : public QObject
{
Q_OBJECT
public:
explicit MprisController(QObject *parent = nullptr);
~MprisController();
bool canQuit() const;
void setCanQuit(bool newCanQuit);
bool fullscreen() const;
void setFullscreen(bool newFullscreen);
void requestFullscreenChange(bool newFullscreen);
bool canSetFullscreen() const;
void setCanSetFullscreen(bool newCanSetFullscreen);
bool canRaise() const;
void setCanRaise(bool newCanRaise);
bool hasTrackList() const;
void setHasTrackList(bool newHasTrackList);
QString identity() const;
void setIdentity(const QString &newIdentity);
QString desktopEntry() const;
void setDesktopEntry(const QString &newDesktopEntry);
QStringList supportedUriSchemes() const;
void setSupportedUriSchemes(const QStringList &newSupportedUriSchemes);
QStringList supportedMimeTypes() const;
void setSupportedMimeTypes(const QStringList &newSupportedMimeTypes);
void raise();
void quit();
void next();
void previous();
void pause();
void playpause();
void stop();
void play();
/*!
* \a offset in microseconds to seek by
*/
void seek(qlonglong offset);
/*!
* \a position to seek to in microseconds
*/
void seekTo(qlonglong position);
void openUri(QString uri);
QString playbackStatus() const;
void setPlaybackStatus(const QString &newStatus);
QString loopStatus() const;
/*!
* Set the internal loop status property to \c newLoopStatus and notify MPRIS clients.
*
* Should be called when loop status changes in the application.
*/
void setLoopStatus(const QString &newLoopStatus);
/*!
* Request the application to change the loop status property to \c newLoopStatus
*/
void requestLoopStatusChange(const QString &newLoopStatus);
double rate() const;
/*!
* Set the internal rate property to \c newRate and notify MPRIS clients.
*
* Should be called when rate changes in the application.
*/
void setRate(const double newRate);
/*!
* Request the application to change the rate property to \c newLoopStatus
*/
void requestRateChange(double newRate);
bool shuffle() const;
/*!
* Set the internal shuffle property to \c newShuffle and notify MPRIS clients.
*
* Should be called when shuffle changes in the application.
*/
void setShuffle(const bool newShuffle);
/*!
* Request the application to change the shuffle property to \c newShuffle
*/
void requestShuffleChange(bool newShuffle);
QVariantMap metadata() const;
void setMetadata(const QString &field, const QVariant value);
double volume() const;
/*!
* Set the internal volume property to \c newVolume and notify MPRIS clients.
*
* Should be called when volume changes in the application.
*/
void setVolume(const double newVolume);
/*!
* Request the application to change the volume property to \c newVolume
*/
void requestVolumeChange(const double &newVolume);
/*!
* Returns \c position in microseconds
*/
qlonglong position() const;
/*!
* Set position to /c newPosition in microseconds
*/
void setPosition(const qlonglong newPosition);
double minimumRate() const;
void setMinimumRate(const double newMinRate);
double maximumRate() const;
void setMaximumRate(const double newMaxRate);
bool canGoNext() const;
void setCanGoNext(const bool newCanGoNext);
bool canGoPrevious() const;
void setCanGoPrevious(const bool newCanGoPrevious);
bool canPlay() const;
void setCanPlay(const bool newCanPlay);
bool canPause() const;
void setCanPause(const bool newCanPause);
bool canSeek() const;
void setCanSeek(const bool newCanSeek);
bool canControl() const;
void setCanControl(const bool newCanControl);
/*!
* Indicates that position has changed on the application side.
* Causes the Seeked signal of the M ediaPlayer2.Player interface to be emitted
*/
void seeked(qlonglong position);
Q_SIGNALS:
void raiseRequested();
void quitRequested();
void nextRequested();
void previousRequested();
void pauseRequested();
void playpauseRequested();
void stopRequested();
void playRequested();
/*!
* \a offset in microseconds to seek by
*/
void seekRequested(qlonglong offset);
/*!
* \a position to seek to in microseconds
*/
void seekToRequested(qlonglong position);
void openUriRequested(QString uri);
/*!
* Emitted when fullscreen is changed by MPRIS client
*/
void fullscreenChangeRequested(bool fullscreen);
/*!
* Emitted when volume is changed by MPRIS client
*/
void volumeChangeRequested(double volume);
/*!
* Emitted when loop status is changed by MPRIS client
*/
void loopStatusChangeRequested(const QString &loopStatus);
/*!
* Emitted when shuffle is changed by MPRIS client
*/
void shuffleChangeRequested(bool shuffle);
/*!
* Emitted when rate is changed by MPRIS client
*/
void rateChangeRequested(double rate);
private:
std::unique_ptr<MprisControllerPrivate> d_ptr;
};
#endif // MPRISCONTROLLER_H
+171
View File
@@ -0,0 +1,171 @@
// SPDX-FileCopyrightText: 2026 George Florea Bănuș <georgefb899@gmail.com>
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "mprisitem.h"
#include "mpriscontroller.h"
#include "mpvitem.h"
#include "playlistmodel.h"
#include <QGuiApplication>
#include <QWindow>
#include <cmath>
using namespace Qt::Literals::StringLiterals;
MprisItem::MprisItem(QObject *parent)
: QObject(parent)
, m_controller(std::make_unique<MprisController>())
{
m_controller->setCanQuit(true);
m_controller->setCanRaise(true);
m_controller->setHasTrackList(false);
m_controller->setIdentity(QGuiApplication::applicationDisplayName());
m_controller->setDesktopEntry(QGuiApplication::desktopFileName());
m_controller->setSupportedUriSchemes({u"file"_s, u"http"_s, u"https"_s});
m_controller->setSupportedMimeTypes({u"video/*"_s, u"audio/*"_s});
m_controller->setMinimumRate(0.1);
m_controller->setMaximumRate(10.0);
m_controller->setCanControl(true);
m_controller->setPlaybackStatus(u"Stopped"_s);
connect(m_controller.get(), &MprisController::raiseRequested, this, &MprisItem::raiseRequested);
connect(m_controller.get(), &MprisController::openUriRequested, this, &MprisItem::openUriRequested);
connect(m_controller.get(), &MprisController::quitRequested, QGuiApplication::instance(), &QGuiApplication::quit);
connect(m_controller.get(), &MprisController::nextRequested, this, &MprisItem::nextRequested);
connect(m_controller.get(), &MprisController::previousRequested, this, &MprisItem::previousRequested);
}
MprisItem::~MprisItem() = default;
MpvItem *MprisItem::mpvItem() const
{
return m_mpv;
}
void MprisItem::setMpvItem(MpvItem *mpv)
{
if (m_mpv || !mpv) {
return;
}
m_mpv = mpv;
Q_EMIT mpvItemChanged();
m_controller->setVolume(mpv->volume() / 100.0);
connect(m_controller.get(), &MprisController::volumeChangeRequested, mpv, [mpv](double volume) {
mpv->setVolume(int(std::round(volume * 100.0)));
});
connect(m_controller.get(), &MprisController::seekToRequested, mpv, [mpv](qlonglong position) {
mpv->seek(position / 1'000'000.0);
});
connect(m_controller.get(), &MprisController::seekRequested, mpv, [mpv](qlonglong offset) {
mpv->seekBy(offset / 1'000'000.0);
});
connect(m_controller.get(), &MprisController::stopRequested, mpv, &MpvItem::stop);
connect(m_controller.get(), &MprisController::playpauseRequested, mpv, &MpvItem::togglePause);
connect(m_controller.get(), &MprisController::playRequested, mpv, [mpv] {
mpv->setPaused(false);
});
connect(m_controller.get(), &MprisController::pauseRequested, mpv, [mpv] {
mpv->setPaused(true);
});
connect(m_controller.get(), &MprisController::rateChangeRequested, mpv, &MpvItem::setSpeed);
connect(mpv, &MpvItem::volumeChanged, this, [this] {
m_controller->setVolume(m_mpv->volume() / 100.0);
});
connect(mpv, &MpvItem::positionChanged, this, [this] {
m_controller->setPosition(qlonglong(m_mpv->position() * 1'000'000.0));
});
connect(mpv, &MpvItem::pausedChanged, this, &MprisItem::updatePlaybackStatus);
connect(mpv, &MpvItem::fileLoadedChanged, this, &MprisItem::updatePlaybackStatus);
connect(mpv, &MpvItem::speedChanged, this, [this] {
m_controller->setRate(m_mpv->speed());
});
connect(mpv, &MpvItem::mediaTitleChanged, this, [this] {
if (!m_mpv->mediaTitle().isEmpty()) {
m_controller->setMetadata(u"xesam:title"_s, m_mpv->mediaTitle());
}
});
connect(mpv, &MpvItem::durationChanged, this, [this] {
if (m_mpv->duration() > 0) {
m_controller->setMetadata(u"mpris:length"_s, qlonglong(m_mpv->duration() * 1'000'000.0));
}
});
connect(mpv, &MpvItem::currentUrlChanged, this, [this] {
const bool hasFile = !m_mpv->currentUrl().isEmpty();
m_controller->setCanPlay(hasFile);
m_controller->setCanPause(hasFile);
m_controller->setCanSeek(hasFile);
const int index = m_playlist ? m_playlist->playingIndex() : 0;
m_controller->setMetadata(u"mpris:trackid"_s, u"/dev/trivk/qtpotato/track/%1"_s.arg(std::max(0, index)));
m_controller->setMetadata(u"xesam:url"_s, m_mpv->currentUrl().toString());
// No artwork for videos, so the task manager shows the live window preview.
m_controller->setMetadata(u"mpris:artUrl"_s, QString());
});
}
PlaylistModel *MprisItem::playlist() const
{
return m_playlist;
}
void MprisItem::setPlaylist(PlaylistModel *playlist)
{
if (m_playlist || !playlist) {
return;
}
m_playlist = playlist;
Q_EMIT playlistChanged();
connect(playlist, &PlaylistModel::countChanged, this, &MprisItem::updateNavigation);
connect(playlist, &PlaylistModel::playingIndexChanged, this, &MprisItem::updateNavigation);
updateNavigation();
}
QWindow *MprisItem::window() const
{
return m_window;
}
void MprisItem::setWindow(QWindow *window)
{
if (m_window || !window) {
return;
}
m_window = window;
Q_EMIT windowChanged();
auto updateFullscreen = [this] {
const bool fullscreen = m_window->windowStates().testFlag(Qt::WindowFullScreen);
m_controller->setFullscreen(fullscreen);
m_controller->setCanSetFullscreen(true);
};
updateFullscreen();
connect(window, &QWindow::windowStateChanged, this, updateFullscreen);
connect(m_controller.get(), &MprisController::fullscreenChangeRequested, this, [this](bool fullscreen) {
if (fullscreen) {
m_window->showFullScreen();
} else {
m_window->showNormal();
}
});
}
void MprisItem::updatePlaybackStatus()
{
QString status = u"Stopped"_s;
if (m_mpv->isFileLoaded()) {
status = m_mpv->paused() ? u"Paused"_s : u"Playing"_s;
}
m_controller->setPlaybackStatus(status);
}
void MprisItem::updateNavigation()
{
const int count = m_playlist->count();
m_controller->setCanGoNext(count > 1);
m_controller->setCanGoPrevious(count > 1);
}
+59
View File
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2026 George Florea Bănuș <georgefb899@gmail.com>
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "mpvitem.h"
#include "playlistmodel.h"
#include <QObject>
#include <QPointer>
#include <QWindow>
#include <QtQml/qqmlregistration.h>
#include <memory>
class MprisController;
// Publishes the player on D-Bus (MPRIS), so Plasma's media controls, the lock screen
// and media keys can drive it. Adapted from Haruna's MprisItem.
class MprisItem : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(MpvItem *mpvItem READ mpvItem WRITE setMpvItem NOTIFY mpvItemChanged REQUIRED)
Q_PROPERTY(PlaylistModel *playlist READ playlist WRITE setPlaylist NOTIFY playlistChanged REQUIRED)
Q_PROPERTY(QWindow *window READ window WRITE setWindow NOTIFY windowChanged REQUIRED)
public:
explicit MprisItem(QObject *parent = nullptr);
~MprisItem() override;
MpvItem *mpvItem() const;
void setMpvItem(MpvItem *mpv);
PlaylistModel *playlist() const;
void setPlaylist(PlaylistModel *playlist);
QWindow *window() const;
void setWindow(QWindow *window);
Q_SIGNALS:
void mpvItemChanged();
void playlistChanged();
void windowChanged();
void raiseRequested();
void openUriRequested(const QString &uri);
// Play the next/previous entry, the way the playlist actions would.
void nextRequested();
void previousRequested();
private:
void updatePlaybackStatus();
void updateNavigation();
std::unique_ptr<MprisController> m_controller;
QPointer<MpvItem> m_mpv;
QPointer<PlaylistModel> m_playlist;
QPointer<QWindow> m_window;
};
+182
View File
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: 2021 George Florea Bănuș <georgefb899@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "playeradaptor.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusObjectPath>
#include "mpriscontroller.h"
using namespace Qt::StringLiterals;
PlayerAdaptor::PlayerAdaptor(MprisController &parent)
: QDBusAbstractAdaptor(&parent)
, m_mprisController(parent)
{
}
void PlayerAdaptor::notify(const QString &property, const QVariant &value)
{
const auto path = u"/org/mpris/MediaPlayer2"_s;
const auto propertiesInterface = u"org.freedesktop.DBus.Properties"_s;
const auto playerInterface = u"org.mpris.MediaPlayer2.Player"_s;
const auto properties = QVariantMap{{property, value}};
QDBusMessage msg = QDBusMessage::createSignal(path, propertiesInterface, u"PropertiesChanged"_s);
msg << playerInterface << properties << QStringList{};
QDBusConnection::sessionBus().send(msg);
}
void PlayerAdaptor::seeked(qlonglong position)
{
Q_EMIT Seeked(position);
}
QString PlayerAdaptor::playbackStatus()
{
return m_mprisController.playbackStatus();
}
QString PlayerAdaptor::loopStatus()
{
return m_mprisController.loopStatus();
}
void PlayerAdaptor::setLoopStatus(const QString &newLoopStatus)
{
m_mprisController.requestLoopStatusChange(newLoopStatus);
}
double PlayerAdaptor::rate() const
{
return m_mprisController.rate();
}
void PlayerAdaptor::setRate(double newRate)
{
m_mprisController.requestRateChange(newRate);
}
bool PlayerAdaptor::shuffle()
{
return m_mprisController.shuffle();
}
void PlayerAdaptor::setShuffle(bool newShuffle)
{
m_mprisController.requestShuffleChange(newShuffle);
}
QVariantMap PlayerAdaptor::metadata()
{
return m_mprisController.metadata();
}
double PlayerAdaptor::volume()
{
return m_mprisController.volume();
}
void PlayerAdaptor::setVolume(double newVolume)
{
m_mprisController.requestVolumeChange(newVolume);
}
qlonglong PlayerAdaptor::position()
{
return m_mprisController.position();
}
double PlayerAdaptor::minimumRate() const
{
return m_mprisController.minimumRate();
}
double PlayerAdaptor::maximumRate() const
{
return m_mprisController.maximumRate();
}
bool PlayerAdaptor::canGoNext()
{
return m_mprisController.canGoNext();
}
bool PlayerAdaptor::canGoPrevious()
{
return m_mprisController.canGoPrevious();
}
bool PlayerAdaptor::canPlay()
{
return m_mprisController.canPlay();
}
bool PlayerAdaptor::canPause()
{
return m_mprisController.canPause();
}
bool PlayerAdaptor::canSeek()
{
return m_mprisController.canSeek();
}
bool PlayerAdaptor::canControl()
{
return m_mprisController.canControl();
}
void PlayerAdaptor::Next()
{
m_mprisController.next();
}
void PlayerAdaptor::Previous()
{
m_mprisController.previous();
}
void PlayerAdaptor::Pause()
{
m_mprisController.pause();
}
void PlayerAdaptor::PlayPause()
{
m_mprisController.playpause();
}
void PlayerAdaptor::Stop()
{
m_mprisController.stop();
}
void PlayerAdaptor::Play()
{
m_mprisController.play();
}
void PlayerAdaptor::Seek(qlonglong offset)
{
m_mprisController.seek(offset);
}
void PlayerAdaptor::SetPosition(const QDBusObjectPath &trackId, qlonglong position)
{
Q_UNUSED(trackId)
m_mprisController.seekTo(position);
}
void PlayerAdaptor::OpenUri(const QString &uri)
{
m_mprisController.openUri(uri);
}
#include "moc_playeradaptor.cpp"
+97
View File
@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2021 George Florea Bănuș <georgefb899@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef MEDIAPLAYER2PLAYER_H
#define MEDIAPLAYER2PLAYER_H
#include <QDBusAbstractAdaptor>
class MprisController;
class MpvItem;
class QDBusObjectPath;
class QImage;
// https://specifications.freedesktop.org/mpris/latest/Player_Interface.html
class PlayerAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player")
public:
explicit PlayerAdaptor(MprisController &parent);
~PlayerAdaptor() = default;
Q_PROPERTY(QString PlaybackStatus READ playbackStatus CONSTANT)
QString playbackStatus();
Q_PROPERTY(QString LoopStatus READ loopStatus WRITE setLoopStatus)
QString loopStatus();
void setLoopStatus(const QString &newLoopStatus);
Q_PROPERTY(double Rate READ rate WRITE setRate)
double rate() const;
void setRate(double newRate);
Q_PROPERTY(bool Shuffle READ shuffle WRITE setShuffle)
bool shuffle();
void setShuffle(bool newShuffle);
Q_PROPERTY(QVariantMap Metadata READ metadata CONSTANT)
QVariantMap metadata();
Q_PROPERTY(double Volume READ volume WRITE setVolume)
double volume();
void setVolume(double newVolume);
Q_PROPERTY(qlonglong Position READ position CONSTANT)
qlonglong position();
Q_PROPERTY(double MinimumRate READ minimumRate CONSTANT)
double minimumRate() const;
Q_PROPERTY(double MaximumRate READ maximumRate CONSTANT)
double maximumRate() const;
Q_PROPERTY(bool CanGoNext READ canGoNext CONSTANT)
bool canGoNext();
Q_PROPERTY(bool CanGoPrevious READ canGoPrevious CONSTANT)
bool canGoPrevious();
Q_PROPERTY(bool CanPlay READ canPlay CONSTANT)
bool canPlay();
Q_PROPERTY(bool CanPause READ canPause CONSTANT)
bool canPause();
Q_PROPERTY(bool CanSeek READ canSeek CONSTANT)
bool canSeek();
Q_PROPERTY(bool CanControl READ canControl CONSTANT)
bool canControl();
void notify(const QString &property, const QVariant &value);
void seeked(qlonglong position);
public Q_SLOTS:
void Next();
void Previous();
void Pause();
void PlayPause();
void Stop();
void Play();
void Seek(qlonglong offset);
void SetPosition(const QDBusObjectPath &trackId, qlonglong position);
void OpenUri(const QString &uri);
Q_SIGNALS:
void Seeked(qlonglong position);
private:
MprisController &m_mprisController;
};
#endif // MEDIAPLAYER2PLAYER_H
+95
View File
@@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2021 George Florea Bănuș <georgefb899@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "rootadaptor.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include "mpriscontroller.h"
using namespace Qt::StringLiterals;
RootAdaptor::RootAdaptor(MprisController &parent)
: QDBusAbstractAdaptor(&parent)
, m_mprisController(parent)
{
}
void RootAdaptor::notify(const QString &property, const QVariant &value)
{
const auto path = u"/org/mpris/MediaPlayer2"_s;
const auto propertiesInterface = u"org.freedesktop.DBus.Properties"_s;
const auto rootInterface = u"org.mpris.MediaPlayer2"_s;
const auto properties = QVariantMap{{property, value}};
QDBusMessage msg = QDBusMessage::createSignal(path, propertiesInterface, u"PropertiesChanged"_s);
msg << rootInterface << properties << QStringList{};
QDBusConnection::sessionBus().send(msg);
}
bool RootAdaptor::canQuit() const
{
return true;
}
bool RootAdaptor::fullscreen() const
{
return m_mprisController.fullscreen();
}
void RootAdaptor::setFullscreen(bool newFullscreen)
{
m_mprisController.requestFullscreenChange(newFullscreen);
}
bool RootAdaptor::canSetFullscreen() const
{
return m_mprisController.canSetFullscreen();
}
bool RootAdaptor::canRaise() const
{
return m_mprisController.canRaise();
}
bool RootAdaptor::hasTrackList() const
{
return m_mprisController.hasTrackList();
}
QString RootAdaptor::identity() const
{
return m_mprisController.identity();
}
QString RootAdaptor::desktopEntry() const
{
return m_mprisController.desktopEntry();
}
QStringList RootAdaptor::supportedUriSchemes() const
{
return m_mprisController.supportedUriSchemes();
}
QStringList RootAdaptor::supportedMimeTypes() const
{
return m_mprisController.supportedMimeTypes();
}
void RootAdaptor::Raise()
{
m_mprisController.raise();
}
void RootAdaptor::Quit()
{
m_mprisController.quit();
}
#include "moc_rootadaptor.cpp"
+63
View File
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2021 George Florea Bănuș <georgefb899@gmail.com>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef MEDIAPLAYER2_H
#define MEDIAPLAYER2_H
#include <QDBusAbstractAdaptor>
class MprisController;
class QDBusObjectPath;
// https://specifications.freedesktop.org/mpris/latest/Media_Player.html
class RootAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2")
public:
explicit RootAdaptor(MprisController &parent);
~RootAdaptor() = default;
Q_PROPERTY(bool CanQuit READ canQuit CONSTANT)
bool canQuit() const;
Q_PROPERTY(bool Fullscreen READ fullscreen WRITE setFullscreen)
bool fullscreen() const;
void setFullscreen(bool newFullscreen);
Q_PROPERTY(bool CanSetFullscreen READ canSetFullscreen CONSTANT)
bool canSetFullscreen() const;
Q_PROPERTY(bool CanRaise READ canRaise CONSTANT)
bool canRaise() const;
Q_PROPERTY(bool HasTrackList READ hasTrackList CONSTANT)
bool hasTrackList() const;
Q_PROPERTY(QString Identity READ identity CONSTANT)
QString identity() const;
Q_PROPERTY(QString DesktopEntry READ desktopEntry CONSTANT)
QString desktopEntry() const;
Q_PROPERTY(QStringList SupportedUriSchemes READ supportedUriSchemes CONSTANT)
QStringList supportedUriSchemes() const;
Q_PROPERTY(QStringList SupportedMimeTypes READ supportedMimeTypes CONSTANT)
QStringList supportedMimeTypes() const;
void notify(const QString &property, const QVariant &value);
public Q_SLOTS:
void Raise();
void Quit();
private:
MprisController &m_mprisController;
};
#endif // MEDIAPLAYER2_H
+162 -2
View File
@@ -9,6 +9,7 @@
#include <QSaveFile>
#include <QStandardPaths>
#include <algorithm>
#include <cmath>
#include <utility>
@@ -41,6 +42,13 @@ MpvItem::MpvItem(QQuickItem *parent)
observeProperty(QStringLiteral("pause"), MPV_FORMAT_FLAG);
observeProperty(QStringLiteral("track-list"), MPV_FORMAT_NODE);
observeProperty(QStringLiteral("sub-delay"), MPV_FORMAT_DOUBLE);
observeProperty(QStringLiteral("volume"), MPV_FORMAT_INT64);
observeProperty(QStringLiteral("volume-max"), MPV_FORMAT_INT64);
observeProperty(QStringLiteral("mute"), MPV_FORMAT_FLAG);
observeProperty(QStringLiteral("speed"), MPV_FORMAT_DOUBLE);
observeProperty(QStringLiteral("chapter-list"), MPV_FORMAT_NODE);
observeProperty(QStringLiteral("chapter"), MPV_FORMAT_INT64);
observeProperty(QStringLiteral("eof-reached"), MPV_FORMAT_FLAG);
// MpvController lives on a worker thread; queue its signals back to the GUI thread.
connect(mpvController(), &MpvController::propertyChanged, this, &MpvItem::onPropertyChanged, Qt::QueuedConnection);
@@ -132,6 +140,13 @@ void MpvItem::loadFile(const QUrl &url)
setSubtitleDelay(0.0);
}
void MpvItem::stop()
{
commandAsync(QStringList{QStringLiteral("stop")});
m_currentUrl.clear();
Q_EMIT currentUrlChanged();
}
void MpvItem::togglePause()
{
setPaused(!m_paused);
@@ -142,6 +157,21 @@ void MpvItem::seek(double seconds)
commandAsync(QStringList{QStringLiteral("seek"), QString::number(seconds), QStringLiteral("absolute")});
}
void MpvItem::seekBy(double seconds)
{
commandAsync(QStringList{QStringLiteral("seek"), QString::number(seconds), QStringLiteral("relative")});
}
void MpvItem::frameStep(bool forward)
{
commandAsync(QStringList{forward ? QStringLiteral("frame-step") : QStringLiteral("frame-back-step")});
}
void MpvItem::screenshot()
{
commandAsync(QStringList{QStringLiteral("screenshot")});
}
QVariantList MpvItem::subtitleTracks() const
{
return m_subtitleTracks;
@@ -186,6 +216,85 @@ void MpvItem::setSubtitleDelay(double seconds)
setPropertyAsync(QStringLiteral("secondary-sub-delay"), rounded);
}
QVariantList MpvItem::audioTracks() const
{
return m_audioTracks;
}
int MpvItem::audioId() const
{
return m_audioId;
}
void MpvItem::setAudioId(int id)
{
setPropertyAsync(QStringLiteral("aid"), trackIdValue(id));
}
int MpvItem::volume() const
{
return m_volume;
}
void MpvItem::setVolume(int volume)
{
volume = std::clamp(volume, 0, m_volumeMax);
// Like sub-delay: update right away so repeated steps build on each other. Always
// sent, since mpv may not have reported a volume from mpv.conf yet.
if (volume != m_volume) {
m_volume = volume;
Q_EMIT volumeChanged();
}
setPropertyAsync(QStringLiteral("volume"), volume);
}
int MpvItem::volumeMax() const
{
return m_volumeMax;
}
bool MpvItem::mute() const
{
return m_mute;
}
void MpvItem::setMute(bool mute)
{
setPropertyAsync(QStringLiteral("mute"), mute);
}
double MpvItem::speed() const
{
return m_speed;
}
void MpvItem::setSpeed(double speed)
{
const double rounded = std::round(std::clamp(speed, 0.1, 10.0) * 100.0) / 100.0;
if (rounded != m_speed) {
m_speed = rounded;
Q_EMIT speedChanged();
}
setPropertyAsync(QStringLiteral("speed"), rounded);
}
QVariantList MpvItem::chapters() const
{
return m_chapters;
}
int MpvItem::chapter() const
{
return m_chapter;
}
void MpvItem::setChapter(int chapter)
{
if (chapter >= 0 && chapter < m_chapters.size()) {
setPropertyAsync(QStringLiteral("chapter"), chapter);
}
}
void MpvItem::addSubtitleFile(const QUrl &url)
{
if (!url.isValid()) {
@@ -301,22 +410,69 @@ void MpvItem::onPropertyChanged(const QString &property, const QVariant &value)
} else if (property == u"sub-delay") {
m_subtitleDelay = value.toDouble();
Q_EMIT subtitleDelayChanged();
} else if (property == u"volume") {
m_volume = value.toInt();
Q_EMIT volumeChanged();
} else if (property == u"volume-max") {
m_volumeMax = value.toInt();
Q_EMIT volumeMaxChanged();
} else if (property == u"mute") {
m_mute = value.toBool();
Q_EMIT muteChanged();
} else if (property == u"speed") {
m_speed = value.toDouble();
Q_EMIT speedChanged();
} else if (property == u"chapter-list") {
QVariantList chapters;
for (const QVariant &entry : value.toList()) {
const QVariantMap chapter = entry.toMap();
chapters.append(QVariantMap{
{QStringLiteral("title"), chapter.value(QStringLiteral("title")).toString()},
{QStringLiteral("time"), chapter.value(QStringLiteral("time")).toDouble()},
});
}
m_chapters = chapters;
Q_EMIT chaptersChanged();
} else if (property == u"chapter") {
// Unavailable (no chapters) arrives as an invalid value.
m_chapter = value.isValid() ? value.toInt() : -1;
Q_EMIT chapterChanged();
} else if (property == u"eof-reached") {
if (value.toBool()) {
Q_EMIT endOfFile();
}
}
}
void MpvItem::updateTracks(const QVariantList &trackList)
{
QVariantList subtitles;
QVariantList audio;
int primary = 0;
int secondary = 0;
int audioId = 0;
for (const QVariant &entry : trackList) {
const QVariantMap track = entry.toMap();
if (track.value(QStringLiteral("type")).toString() != u"sub") {
const QString type = track.value(QStringLiteral("type")).toString();
const int id = track.value(QStringLiteral("id")).toInt();
if (type == u"audio") {
if (track.value(QStringLiteral("selected")).toBool()) {
audioId = id;
}
audio.append(QVariantMap{
{QStringLiteral("id"), id},
{QStringLiteral("title"), track.value(QStringLiteral("title")).toString()},
{QStringLiteral("lang"), track.value(QStringLiteral("lang")).toString()},
{QStringLiteral("codec"), track.value(QStringLiteral("codec")).toString()},
});
continue;
}
if (type != u"sub") {
continue;
}
const int id = track.value(QStringLiteral("id")).toInt();
if (track.value(QStringLiteral("selected")).toBool()) {
// main-selection is 0 for the primary subtitle and 1 for the secondary one.
if (track.value(QStringLiteral("main-selection")).toInt() == 0) {
@@ -346,6 +502,10 @@ void MpvItem::updateTracks(const QVariantList &trackList)
m_subtitleId = primary;
m_secondarySubtitleId = secondary;
Q_EMIT subtitleTracksChanged();
m_audioTracks = audio;
m_audioId = audioId;
Q_EMIT audioTracksChanged();
}
void MpvItem::setFileLoaded(bool loaded)
+50
View File
@@ -29,6 +29,19 @@ class MpvItem : public MpvAbstractItem
// In seconds; applied to both subtitles so a translation stays in sync with its source.
Q_PROPERTY(double subtitleDelay READ subtitleDelay WRITE setSubtitleDelay NOTIFY subtitleDelayChanged)
// Audio tracks of the current file, each a map with id, title, lang and codec.
Q_PROPERTY(QVariantList audioTracks READ audioTracks NOTIFY audioTracksChanged)
Q_PROPERTY(int audioId READ audioId WRITE setAudioId NOTIFY audioTracksChanged)
// 0 to volumeMax (mpv's "volume-max", 130 unless mpv.conf changes it).
Q_PROPERTY(int volume READ volume WRITE setVolume NOTIFY volumeChanged)
Q_PROPERTY(int volumeMax READ volumeMax NOTIFY volumeMaxChanged)
Q_PROPERTY(bool mute READ mute WRITE setMute NOTIFY muteChanged)
Q_PROPERTY(double speed READ speed WRITE setSpeed NOTIFY speedChanged)
// Chapters of the current file, each a map with title and time (in seconds).
Q_PROPERTY(QVariantList chapters READ chapters NOTIFY chaptersChanged)
// Index into chapters, -1 before the first chapter or without chapters.
Q_PROPERTY(int chapter READ chapter WRITE setChapter NOTIFY chapterChanged)
// Load the user's mpv.conf when mpv starts. Must be set before the item is shown.
Q_PROPERTY(bool loadUserConfig READ loadUserConfig WRITE setLoadUserConfig NOTIFY loadUserConfigChanged)
Q_PROPERTY(QString userConfigDir READ userConfigDir CONSTANT)
@@ -55,6 +68,19 @@ public:
void setSecondarySubtitleId(int id);
double subtitleDelay() const;
void setSubtitleDelay(double seconds);
QVariantList audioTracks() const;
int audioId() const;
void setAudioId(int id);
int volume() const;
void setVolume(int volume);
int volumeMax() const;
bool mute() const;
void setMute(bool mute);
double speed() const;
void setSpeed(double speed);
QVariantList chapters() const;
int chapter() const;
void setChapter(int chapter);
bool loadUserConfig() const;
void setLoadUserConfig(bool load);
QString userConfigDir() const;
@@ -62,8 +88,15 @@ public:
QStringList skippedUserOptions() const;
Q_INVOKABLE void loadFile(const QUrl &url);
// Unloads the current file.
Q_INVOKABLE void stop();
Q_INVOKABLE void togglePause();
Q_INVOKABLE void seek(double seconds);
// Seeks \a seconds forward, or backward when negative.
Q_INVOKABLE void seekBy(double seconds);
Q_INVOKABLE void frameStep(bool forward);
// Saves a screenshot the way mpv does (screenshot-directory, screenshot-format).
Q_INVOKABLE void screenshot();
// Adds an external subtitle file and makes it the primary subtitle.
Q_INVOKABLE void addSubtitleFile(const QUrl &url);
// Makes \a url the only subtitle shown, adding it first (named \a title) unless it is
@@ -81,6 +114,15 @@ Q_SIGNALS:
void fileLoadedChanged();
void subtitleTracksChanged();
void subtitleDelayChanged();
void audioTracksChanged();
void volumeChanged();
void volumeMaxChanged();
void muteChanged();
void speedChanged();
void chaptersChanged();
void chapterChanged();
// Playback reached the end of the file. The last frame stays on screen.
void endOfFile();
void loadUserConfigChanged();
// mpv.conf was queued for loading; options set after this take precedence over it.
void userConfigApplied();
@@ -104,6 +146,14 @@ private:
int m_subtitleId = 0;
int m_secondarySubtitleId = 0;
double m_subtitleDelay = 0.0;
QVariantList m_audioTracks;
int m_audioId = 0;
int m_volume = 100;
int m_volumeMax = 130;
bool m_mute = false;
double m_speed = 1.0;
QVariantList m_chapters;
int m_chapter = -1;
// File added by showTranslatedSubtitleFile() that should be shown once mpv lists it.
QString m_pendingTranslatedPath;
bool m_loadUserConfig = false;
+276
View File
@@ -0,0 +1,276 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#include "playlistmodel.h"
#include <QCollator>
#include <QDir>
#include <QFileInfo>
#include <QMimeDatabase>
#include <algorithm>
using namespace Qt::Literals::StringLiterals;
PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int PlaylistModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : int(m_urls.size());
}
QVariant PlaylistModel::data(const QModelIndex &index, int role) const
{
if (!checkIndex(index, CheckIndexOption::IndexIsValid)) {
return {};
}
const QUrl &url = m_urls.at(index.row());
switch (role) {
case UrlRole:
return url;
case Qt::DisplayRole:
case NameRole:
return url.isLocalFile() ? url.fileName() : url.toDisplayString();
case IsPlayingRole:
return index.row() == m_playingIndex;
}
return {};
}
QHash<int, QByteArray> PlaylistModel::roleNames() const
{
return {
{UrlRole, "url"},
{NameRole, "name"},
{IsPlayingRole, "isPlaying"},
};
}
int PlaylistModel::count() const
{
return int(m_urls.size());
}
int PlaylistModel::playingIndex() const
{
return m_playingIndex;
}
bool PlaylistModel::isMediaFile(const QString &fileName)
{
static const QMimeDatabase mimeDatabase;
const QString mimeType = mimeDatabase.mimeTypeForFile(fileName, QMimeDatabase::MatchExtension).name();
return mimeType.startsWith(u"video/") || mimeType.startsWith(u"audio/");
}
QList<QUrl> PlaylistModel::mediaFilesIn(const QString &folder)
{
QStringList names;
const QStringList entries = QDir(folder).entryList(QDir::Files | QDir::Readable);
for (const QString &name : entries) {
if (isMediaFile(name)) {
names.append(name);
}
}
QCollator collator;
collator.setNumericMode(true);
collator.setCaseSensitivity(Qt::CaseInsensitive);
std::sort(names.begin(), names.end(), collator);
QList<QUrl> urls;
const QDir dir(folder);
for (const QString &name : std::as_const(names)) {
urls.append(QUrl::fromLocalFile(dir.absoluteFilePath(name)));
}
return urls;
}
QList<QUrl> PlaylistModel::expand(const QList<QUrl> &urls) const
{
QList<QUrl> result;
for (const QUrl &url : urls) {
if (url.isLocalFile() && QFileInfo(url.toLocalFile()).isDir()) {
result.append(mediaFilesIn(url.toLocalFile()));
} else {
result.append(url);
}
}
return result;
}
void PlaylistModel::openFile(const QUrl &url, bool loadSiblings)
{
if (!url.isValid()) {
return;
}
QList<QUrl> urls{url};
if (loadSiblings && url.isLocalFile()) {
const QFileInfo file(url.toLocalFile());
if (file.isDir()) {
urls = mediaFilesIn(file.absoluteFilePath());
} else if (isMediaFile(file.fileName())) {
urls = mediaFilesIn(file.absolutePath());
// Files whose type is not recognized (or that vanished) still play.
if (!urls.contains(url)) {
urls = {url};
}
}
}
beginResetModel();
m_urls = urls;
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
play(std::max<int>(0, int(m_urls.indexOf(url))));
}
void PlaylistModel::append(const QList<QUrl> &urls)
{
const QList<QUrl> added = expand(urls);
if (added.isEmpty()) {
return;
}
beginInsertRows({}, int(m_urls.size()), int(m_urls.size() + added.size() - 1));
m_urls.append(added);
endInsertRows();
Q_EMIT countChanged();
}
void PlaylistModel::replace(const QList<QUrl> &urls)
{
const QList<QUrl> files = expand(urls);
if (files.isEmpty()) {
return;
}
beginResetModel();
m_urls = files;
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
play(0);
}
void PlaylistModel::play(int index)
{
if (index < 0 || index >= m_urls.size()) {
return;
}
setPlayingIndex(index);
Q_EMIT playRequested(m_urls.at(index));
}
bool PlaylistModel::playNext(bool wrap)
{
if (m_urls.isEmpty()) {
return false;
}
int next = m_playingIndex + 1;
if (next >= m_urls.size()) {
if (!wrap) {
return false;
}
next = 0;
}
play(next);
return true;
}
bool PlaylistModel::playPrevious(bool wrap)
{
if (m_urls.isEmpty()) {
return false;
}
int previous = m_playingIndex - 1;
if (previous < 0) {
if (!wrap) {
return false;
}
previous = int(m_urls.size()) - 1;
}
play(previous);
return true;
}
void PlaylistModel::remove(int index)
{
if (index < 0 || index >= m_urls.size()) {
return;
}
beginRemoveRows({}, index, index);
m_urls.removeAt(index);
endRemoveRows();
Q_EMIT countChanged();
// The playing file keeps playing; it just has no entry any more.
if (index == m_playingIndex) {
m_playingIndex = -1;
Q_EMIT playingIndexChanged();
} else if (index < m_playingIndex) {
--m_playingIndex;
Q_EMIT playingIndexChanged();
}
}
void PlaylistModel::move(int from, int to)
{
if (from == to || from < 0 || to < 0 || from >= m_urls.size() || to >= m_urls.size()) {
return;
}
// beginMoveRows wants the row the item lands before, counted before the move.
if (!beginMoveRows({}, from, from, {}, to > from ? to + 1 : to)) {
return;
}
m_urls.move(from, to);
endMoveRows();
if (m_playingIndex == from) {
m_playingIndex = to;
} else if (from < m_playingIndex && to >= m_playingIndex) {
--m_playingIndex;
} else if (from > m_playingIndex && to <= m_playingIndex) {
++m_playingIndex;
} else {
return;
}
Q_EMIT playingIndexChanged();
}
void PlaylistModel::clear()
{
if (m_urls.isEmpty()) {
return;
}
beginResetModel();
m_urls.clear();
m_playingIndex = -1;
endResetModel();
Q_EMIT countChanged();
Q_EMIT playingIndexChanged();
}
QUrl PlaylistModel::urlAt(int index) const
{
return m_urls.value(index);
}
void PlaylistModel::setPlayingIndex(int index)
{
if (index == m_playingIndex) {
return;
}
const int previous = m_playingIndex;
m_playingIndex = index;
if (previous >= 0 && previous < m_urls.size()) {
Q_EMIT dataChanged(this->index(previous), this->index(previous), {IsPlayingRole});
}
if (index >= 0) {
Q_EMIT dataChanged(this->index(index), this->index(index), {IsPlayingRole});
}
Q_EMIT playingIndexChanged();
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QList>
#include <QUrl>
#include <QtQml/qqmlregistration.h>
// The files queued for playback. The model only tracks which entry plays; it asks for
// a file to be played through playRequested(), and the player loads it.
class PlaylistModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int count READ count NOTIFY countChanged)
// -1 when nothing from the list is playing.
Q_PROPERTY(int playingIndex READ playingIndex NOTIFY playingIndexChanged)
public:
enum Roles {
UrlRole = Qt::UserRole + 1,
NameRole,
IsPlayingRole,
};
Q_ENUM(Roles)
explicit PlaylistModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
int count() const;
int playingIndex() const;
// Replaces the list with \a url, plus the other media files in its folder when
// \a loadSiblings is set, and plays \a url.
Q_INVOKABLE void openFile(const QUrl &url, bool loadSiblings);
// Adds files, and the media files inside folders, to the end of the list.
Q_INVOKABLE void append(const QList<QUrl> &urls);
// Replaces the list with \a urls (folders expanded) and plays the first one.
Q_INVOKABLE void replace(const QList<QUrl> &urls);
Q_INVOKABLE void play(int index);
// Plays the entry after (or before) the playing one. With \a wrap the end of the
// list continues at the other end. Returns false when there is nothing to play.
Q_INVOKABLE bool playNext(bool wrap = false);
Q_INVOKABLE bool playPrevious(bool wrap = false);
Q_INVOKABLE void remove(int index);
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear();
Q_INVOKABLE QUrl urlAt(int index) const;
// Media files directly inside \a folder, sorted the way a file manager does
// ("Episode 2" before "Episode 10").
static QList<QUrl> mediaFilesIn(const QString &folder);
static bool isMediaFile(const QString &fileName);
Q_SIGNALS:
void countChanged();
void playingIndexChanged();
void playRequested(const QUrl &url);
private:
QList<QUrl> expand(const QList<QUrl> &urls) const;
void setPlayingIndex(int index);
QList<QUrl> m_urls;
int m_playingIndex = -1;
};