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

+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;
};