feat: add playlist, recent files and MPRIS backends
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6dc9377de1
commit
54ad4eecf3
18 files changed
+2240
-4
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user