From 54ad4eecf34a4cbd5aa5f7ce453e8a96ad389876 Mon Sep 17 00:00:00 2001 From: Tri Vo Khac Date: Wed, 28 Oct 2026 09:26:12 +0700 Subject: [PATCH] feat: add playlist, recent files and MPRIS backends Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 +- autotests/CMakeLists.txt | 6 + autotests/playlistmodeltest.cpp | 105 +++++++ src/CMakeLists.txt | 15 +- src/models/recentfilesmodel.cpp | 122 ++++++++ src/models/recentfilesmodel.h | 53 ++++ src/mpris/mpriscontroller.cpp | 498 ++++++++++++++++++++++++++++++++ src/mpris/mpriscontroller.h | 214 ++++++++++++++ src/mpris/mprisitem.cpp | 171 +++++++++++ src/mpris/mprisitem.h | 59 ++++ src/mpris/playeradaptor.cpp | 182 ++++++++++++ src/mpris/playeradaptor.h | 97 +++++++ src/mpris/rootadaptor.cpp | 95 ++++++ src/mpris/rootadaptor.h | 63 ++++ src/mpv/mpvitem.cpp | 164 ++++++++++- src/mpv/mpvitem.h | 50 ++++ src/playlist/playlistmodel.cpp | 276 ++++++++++++++++++ src/playlist/playlistmodel.h | 72 +++++ 18 files changed, 2240 insertions(+), 4 deletions(-) create mode 100644 autotests/playlistmodeltest.cpp create mode 100644 src/models/recentfilesmodel.cpp create mode 100644 src/models/recentfilesmodel.h create mode 100644 src/mpris/mpriscontroller.cpp create mode 100644 src/mpris/mpriscontroller.h create mode 100644 src/mpris/mprisitem.cpp create mode 100644 src/mpris/mprisitem.h create mode 100644 src/mpris/playeradaptor.cpp create mode 100644 src/mpris/playeradaptor.h create mode 100644 src/mpris/rootadaptor.cpp create mode 100644 src/mpris/rootadaptor.h create mode 100644 src/playlist/playlistmodel.cpp create mode 100644 src/playlist/playlistmodel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e31941c..97d2e19 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 8dee0f9..4de64da 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -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) diff --git a/autotests/playlistmodeltest.cpp b/autotests/playlistmodeltest.cpp new file mode 100644 index 0000000..bd6172b --- /dev/null +++ b/autotests/playlistmodeltest.cpp @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "playlistmodel.h" + +#include +#include +#include +#include + +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 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" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 175a09b..6c82149 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/models/recentfilesmodel.cpp b/src/models/recentfilesmodel.cpp new file mode 100644 index 0000000..25c0df8 --- /dev/null +++ b/src/models/recentfilesmodel.cpp @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "recentfilesmodel.h" + +#include +#include + +#include + +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 RecentFilesModel::roleNames() const +{ + return { + {UrlRole, "url"}, + {NameRole, "name"}, + {PathRole, "path"}, + }; +} + +int RecentFilesModel::count() const +{ + return int(std::min(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(); +} diff --git a/src/models/recentfilesmodel.h b/src/models/recentfilesmodel.h new file mode 100644 index 0000000..1e6ac1a --- /dev/null +++ b/src/models/recentfilesmodel.h @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +// 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 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 m_urls; + int m_maxCount = 10; +}; diff --git a/src/mpris/mpriscontroller.cpp b/src/mpris/mpriscontroller.cpp new file mode 100644 index 0000000..041fcf0 --- /dev/null +++ b/src/mpris/mpriscontroller.cpp @@ -0,0 +1,498 @@ +/* + * SPDX-FileCopyrightText: 2026 George Florea Bănuș + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "mpriscontroller.h" + +#include +#include +#include + +#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(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" \ No newline at end of file diff --git a/src/mpris/mpriscontroller.h b/src/mpris/mpriscontroller.h new file mode 100644 index 0000000..eabd552 --- /dev/null +++ b/src/mpris/mpriscontroller.h @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: 2026 George Florea Bănuș + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef MPRISCONTROLLER_H +#define MPRISCONTROLLER_H + +#include + +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 d_ptr; +}; + +#endif // MPRISCONTROLLER_H diff --git a/src/mpris/mprisitem.cpp b/src/mpris/mprisitem.cpp new file mode 100644 index 0000000..c4eedcb --- /dev/null +++ b/src/mpris/mprisitem.cpp @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 George Florea Bănuș +// 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 +#include + +#include + +using namespace Qt::Literals::StringLiterals; + +MprisItem::MprisItem(QObject *parent) + : QObject(parent) + , m_controller(std::make_unique()) +{ + 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); +} diff --git a/src/mpris/mprisitem.h b/src/mpris/mprisitem.h new file mode 100644 index 0000000..3ae834e --- /dev/null +++ b/src/mpris/mprisitem.h @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 George Florea Bănuș +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "mpvitem.h" +#include "playlistmodel.h" + +#include +#include +#include +#include + +#include + +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 m_controller; + QPointer m_mpv; + QPointer m_playlist; + QPointer m_window; +}; diff --git a/src/mpris/playeradaptor.cpp b/src/mpris/playeradaptor.cpp new file mode 100644 index 0000000..ffba53e --- /dev/null +++ b/src/mpris/playeradaptor.cpp @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2021 George Florea Bănuș + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "playeradaptor.h" + +#include +#include +#include + +#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" diff --git a/src/mpris/playeradaptor.h b/src/mpris/playeradaptor.h new file mode 100644 index 0000000..ca35e0f --- /dev/null +++ b/src/mpris/playeradaptor.h @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2021 George Florea Bănuș + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef MEDIAPLAYER2PLAYER_H +#define MEDIAPLAYER2PLAYER_H + +#include + +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 diff --git a/src/mpris/rootadaptor.cpp b/src/mpris/rootadaptor.cpp new file mode 100644 index 0000000..e7da28e --- /dev/null +++ b/src/mpris/rootadaptor.cpp @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2021 George Florea Bănuș + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "rootadaptor.h" + +#include +#include + +#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" diff --git a/src/mpris/rootadaptor.h b/src/mpris/rootadaptor.h new file mode 100644 index 0000000..de0bf6e --- /dev/null +++ b/src/mpris/rootadaptor.h @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2021 George Florea Bănuș + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#ifndef MEDIAPLAYER2_H +#define MEDIAPLAYER2_H + +#include + +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 diff --git a/src/mpv/mpvitem.cpp b/src/mpv/mpvitem.cpp index 42d07d3..89e7409 100644 --- a/src/mpv/mpvitem.cpp +++ b/src/mpv/mpvitem.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -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) diff --git a/src/mpv/mpvitem.h b/src/mpv/mpvitem.h index e650268..9cc5fa8 100644 --- a/src/mpv/mpvitem.h +++ b/src/mpv/mpvitem.h @@ -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; diff --git a/src/playlist/playlistmodel.cpp b/src/playlist/playlistmodel.cpp new file mode 100644 index 0000000..c784b87 --- /dev/null +++ b/src/playlist/playlistmodel.cpp @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "playlistmodel.h" + +#include +#include +#include +#include + +#include + +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 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 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 urls; + const QDir dir(folder); + for (const QString &name : std::as_const(names)) { + urls.append(QUrl::fromLocalFile(dir.absoluteFilePath(name))); + } + return urls; +} + +QList PlaylistModel::expand(const QList &urls) const +{ + QList 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 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(0, int(m_urls.indexOf(url)))); +} + +void PlaylistModel::append(const QList &urls) +{ + const QList 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 &urls) +{ + const QList 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(); +} diff --git a/src/playlist/playlistmodel.h b/src/playlist/playlistmodel.h new file mode 100644 index 0000000..c080b78 --- /dev/null +++ b/src/playlist/playlistmodel.h @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +// 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 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 &urls); + // Replaces the list with \a urls (folders expanded) and plays the first one. + Q_INVOKABLE void replace(const QList &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 mediaFilesIn(const QString &folder); + static bool isMediaFile(const QString &fileName); + +Q_SIGNALS: + void countChanged(); + void playingIndexChanged(); + void playRequested(const QUrl &url); + +private: + QList expand(const QList &urls) const; + void setPlayingIndex(int index); + + QList m_urls; + int m_playingIndex = -1; +};