add user custom playlist support

This commit is contained in:
2026-06-07 19:58:08 +02:00
parent 4edb2e933d
commit 94bba76ce2
12 changed files with 631 additions and 5 deletions
+14 -4
View File
@@ -4,7 +4,7 @@ project(SubWave LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia OpenGL OpenGLWidgets)
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia OpenGL OpenGLWidgets Core)
# require ffmpeg
find_package(PkgConfig REQUIRED)
@@ -35,7 +35,10 @@ set(SOURCES
src/GradientDialog.cpp
src/EqualizerWidget.cpp
src/TrackDelegate.cpp
src/PlaylistModel.cpp
src/playlists/PlaylistModel.cpp
src/playlists/PlaylistManager.cpp
src/playlists/PlaylistCreatorDialog.cpp
src/playlists/PlaylistBrowserDialog.cpp
src/MainWindow.cpp
)
@@ -50,7 +53,10 @@ set(HEADERS
src/GradientDialog.h
src/EqualizerWidget.h
src/TrackDelegate.h
src/PlaylistModel.h
src/playlists/PlaylistModel.h
src/playlists/PlaylistManager.h
src/playlists/PlaylistCreatorDialog.h
src/playlists/PlaylistBrowserDialog.h
src/MainWindow.h
)
@@ -66,7 +72,11 @@ if(WIN32)
set_target_properties(SubWave PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
target_include_directories(SubWave PRIVATE src src/engine)
target_include_directories(SubWave PRIVATE
src
src/engine
src/playlists
)
target_link_libraries(SubWave PRIVATE
Qt6::Widgets
+59
View File
@@ -7,6 +7,9 @@
#include "SpectrogramWidget.h"
#include "EqualizerWidget.h"
#include "Config.h"
#include "PlaylistCreatorDialog.h"
#include "PlaylistBrowserDialog.h"
#include "PlaylistManager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -187,6 +190,15 @@ void MainWindow::buildMenuBar()
quitAct->setShortcut(QKeySequence::Quit);
connect(quitAct, &QAction::triggered, qApp, &QApplication::quit);
// Playlists menu
auto *playlists = mb->addMenu("&Playlists");
auto *newPlaylistAct = playlists->addAction("Create Playlist…");
connect(newPlaylistAct, &QAction::triggered, this, &MainWindow::onCreatePlaylist);
auto *loadPlaylistAct = playlists->addAction("Load Playlist…");
connect(loadPlaylistAct, &QAction::triggered, this, &MainWindow::onLoadPlaylist);
auto *help = mb->addMenu("&Help");
auto *about = help->addAction("About SubWave");
connect(about, &QAction::triggered, this, [this]{
@@ -358,4 +370,51 @@ void MainWindow::onUiTimer()
if (m_engine->state() == AudioEngine::State::Stopped)
m_btnPlay->setText("Play");
}
// ── Playlist Management ─────────────────────────────────────────────────────
void MainWindow::onCreatePlaylist()
{
PlaylistCreatorDialog dialog(this);
if (dialog.exec() == QDialog::Accepted) {
// Playlist was created and saved
// Optionally auto-load it
auto playlist = dialog.getPlaylist();
loadPlaylistIntoModel(playlist.name);
}
}
void MainWindow::onLoadPlaylist()
{
PlaylistBrowserDialog dialog(this);
if (dialog.exec() == QDialog::Accepted) {
QString playlistName = dialog.getSelectedPlaylist();
if (!playlistName.isEmpty()) {
loadPlaylistIntoModel(playlistName);
}
}
}
void MainWindow::loadPlaylistIntoModel(const QString &playlistName)
{
auto playlist = PlaylistManager::loadPlaylist(playlistName);
if (playlist.tracks.isEmpty()) {
QMessageBox::warning(this, "Empty Playlist",
QString("Playlist \"%1\" is empty.").arg(playlistName));
return;
}
m_model->setTracks(playlist.tracks);
m_lblStatus->setText(
QString("Playlist: %1 (%2 tracks)")
.arg(playlistName)
.arg(playlist.tracks.size())
);
// Reset playback state
m_currentIndex = -1;
m_engine->stop();
m_btnPlay->setText("Play");
}
+5
View File
@@ -17,6 +17,8 @@ class PlaylistModel;
class CoverArtWidget;
class SpectrogramWidget;
class EqualizerWidget;
class PlaylistCreatorDialog;
class PlaylistBrowserDialog;
class MainWindow : public QMainWindow
{
@@ -43,6 +45,9 @@ private:
void playNext();
void playPrev();
void onUiTimer();
void onCreatePlaylist();
void onLoadPlaylist();
void loadPlaylistIntoModel(const QString &playlistName);
// Core
AudioEngine *m_engine;
+1 -1
View File
@@ -80,4 +80,4 @@ void EqProcessor::recomputeBiquad(int band) {
m_coeff[band][4] = float(a2 / a0);
}
} // namespace engine
} // namespace engine
+112
View File
@@ -0,0 +1,112 @@
#include "PlaylistBrowserDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
PlaylistBrowserDialog::PlaylistBrowserDialog(QWidget *parent)
: QDialog(parent)
{
setWindowTitle("Load Playlist");
setMinimumWidth(400);
setMinimumHeight(350);
buildUI();
refreshPlaylistList();
}
void PlaylistBrowserDialog::buildUI()
{
auto *vlay = new QVBoxLayout(this);
vlay->addWidget(new QLabel("Available Playlists:"));
m_playlistList = new QListWidget;
m_playlistList->setSelectionMode(QAbstractItemView::SingleSelection);
vlay->addWidget(m_playlistList);
connect(m_playlistList, &QListWidget::itemDoubleClicked,
this, &PlaylistBrowserDialog::onPlaylistDoubleClicked);
// Buttons
auto *btnRow = new QHBoxLayout;
btnRow->addStretch();
m_deleteBtn = new QPushButton("Delete");
m_loadBtn = new QPushButton("Load");
btnRow->addWidget(m_deleteBtn);
btnRow->addWidget(m_loadBtn);
auto *closeBtn = new QPushButton("Close");
btnRow->addWidget(closeBtn);
vlay->addLayout(btnRow);
connect(m_deleteBtn, &QPushButton::clicked, this, &PlaylistBrowserDialog::onDeletePlaylist);
connect(m_loadBtn, &QPushButton::clicked, this, &PlaylistBrowserDialog::onLoadPlaylist);
connect(closeBtn, &QPushButton::clicked, this, &QDialog::reject);
}
void PlaylistBrowserDialog::onPlaylistDoubleClicked(QListWidgetItem *item)
{
if (item) {
m_selectedPlaylist = item->text();
accept();
}
}
void PlaylistBrowserDialog::onLoadPlaylist()
{
QListWidgetItem *current = m_playlistList->currentItem();
if (!current) {
QMessageBox::warning(this, "No Selection", "Please select a playlist to load.");
return;
}
m_selectedPlaylist = current->text();
accept();
}
void PlaylistBrowserDialog::onDeletePlaylist()
{
QListWidgetItem *current = m_playlistList->currentItem();
if (!current) {
QMessageBox::warning(this, "No Selection", "Please select a playlist to delete.");
return;
}
QString playlistName = current->text();
int ret = QMessageBox::warning(this, "Confirm Delete",
QString("Are you sure you want to delete the playlist \"%1\"?").arg(playlistName),
QMessageBox::Yes | QMessageBox::No);
if (ret == QMessageBox::Yes) {
if (PlaylistManager::deletePlaylist(playlistName)) {
QMessageBox::information(this, "Success",
QString("Playlist \"%1\" deleted.").arg(playlistName));
refreshPlaylistList();
} else {
QMessageBox::critical(this, "Error", "Failed to delete playlist.");
}
}
}
void PlaylistBrowserDialog::refreshPlaylistList()
{
m_playlistList->clear();
QStringList playlists = PlaylistManager::listPlaylists();
for (const QString &name : playlists) {
m_playlistList->addItem(name);
}
if (playlists.isEmpty()) {
m_playlistList->addItem("(No playlists created)");
m_deleteBtn->setEnabled(false);
m_loadBtn->setEnabled(false);
} else {
m_deleteBtn->setEnabled(true);
m_loadBtn->setEnabled(true);
}
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QDialog>
#include <QPushButton>
#include <QListWidget>
#include "PlaylistManager.h"
class PlaylistBrowserDialog : public QDialog {
Q_OBJECT
public:
explicit PlaylistBrowserDialog(QWidget *parent = nullptr);
QString getSelectedPlaylist() const { return m_selectedPlaylist; }
private slots:
void onPlaylistDoubleClicked(QListWidgetItem *item);
void onDeletePlaylist();
void onLoadPlaylist();
void refreshPlaylistList();
private:
void buildUI();
QListWidget *m_playlistList;
QPushButton *m_deleteBtn;
QPushButton *m_loadBtn;
QString m_selectedPlaylist;
};
+155
View File
@@ -0,0 +1,155 @@
#include "PlaylistCreatorDialog.h"
#include "MetadataReader.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QFileDialog>
#include <QMessageBox>
#include <QFileInfo>
PlaylistCreatorDialog::PlaylistCreatorDialog(QWidget *parent)
: QDialog(parent)
{
setWindowTitle("Create Playlist");
setMinimumWidth(500);
setMinimumHeight(400);
buildUI();
}
void PlaylistCreatorDialog::buildUI()
{
auto *vlay = new QVBoxLayout(this);
// Name input
auto *nameRow = new QHBoxLayout;
nameRow->addWidget(new QLabel("Playlist Name:"));
m_nameEdit = new QLineEdit;
m_nameEdit->setPlaceholderText("e.g., My Favorite Songs");
nameRow->addWidget(m_nameEdit);
vlay->addLayout(nameRow);
// Tracks list
vlay->addWidget(new QLabel("Tracks:"));
m_trackList = new QListWidget;
m_trackList->setSelectionMode(QAbstractItemView::SingleSelection);
vlay->addWidget(m_trackList);
// Buttons for track management
auto *trackBtnRow = new QHBoxLayout;
m_addBtn = new QPushButton("Add Files...");
m_removeBtn = new QPushButton("Remove Selected");
trackBtnRow->addWidget(m_addBtn);
trackBtnRow->addWidget(m_removeBtn);
trackBtnRow->addStretch();
vlay->addLayout(trackBtnRow);
connect(m_addBtn, &QPushButton::clicked, this, &PlaylistCreatorDialog::onAddFiles);
connect(m_removeBtn, &QPushButton::clicked, this, &PlaylistCreatorDialog::onRemoveSelected);
// Dialog buttons
vlay->addSpacing(10);
auto *dialogBtnRow = new QHBoxLayout;
dialogBtnRow->addStretch();
m_createBtn = new QPushButton("Create Playlist");
auto *cancelBtn = new QPushButton("Cancel");
dialogBtnRow->addWidget(m_createBtn);
dialogBtnRow->addWidget(cancelBtn);
vlay->addLayout(dialogBtnRow);
connect(m_createBtn, &QPushButton::clicked, this, &PlaylistCreatorDialog::onCreatePlaylist);
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
}
void PlaylistCreatorDialog::onAddFiles()
{
QStringList files = QFileDialog::getOpenFileNames(
this,
"Select audio files",
QDir::homePath(),
"Audio Files (*.mp3 *.flac *.wav *.ogg *.aac *.m4a *.opus *.wma *.ape *.aiff *.alac);;All Files (*)"
);
for (const QString &filePath : files) {
if (filePath.isEmpty()) continue;
// Check if already in playlist
bool alreadyAdded = false;
for (const Track &t : m_playlist.tracks) {
if (t.filePath == filePath) {
alreadyAdded = true;
break;
}
}
if (!alreadyAdded) {
Track track;
track.filePath = filePath;
// Set default metadata
QString ext = QFileInfo(filePath).suffix().toLower();
track.format = ext.toUpper();
track.title = QFileInfo(filePath).completeBaseName();
track.artist = "Unknown Artist";
track.album = "Unknown Album";
// Extract actual metadata and cover art
MetadataReader::populate(track);
m_playlist.tracks.append(track);
}
}
updateTrackList();
}
void PlaylistCreatorDialog::onRemoveSelected()
{
int row = m_trackList->currentRow();
if (row >= 0 && row < m_playlist.tracks.size()) {
m_playlist.tracks.removeAt(row);
updateTrackList();
}
}
void PlaylistCreatorDialog::onCreatePlaylist()
{
QString name = m_nameEdit->text().trimmed();
if (name.isEmpty()) {
QMessageBox::warning(this, "Invalid Name", "Please enter a playlist name.");
return;
}
if (m_playlist.tracks.isEmpty()) {
QMessageBox::warning(this, "No Tracks", "Please add at least one track to the playlist.");
return;
}
m_playlist.name = name;
if (PlaylistManager::savePlaylist(m_playlist)) {
QMessageBox::information(this, "Success",
QString("Playlist \"%1\" created with %2 tracks.")
.arg(name)
.arg(m_playlist.tracks.size()));
accept();
} else {
QMessageBox::critical(this, "Error", "Failed to save playlist.");
}
}
void PlaylistCreatorDialog::updateTrackList()
{
m_trackList->clear();
for (const Track &track : m_playlist.tracks) {
QString display = track.displayTitle();
if (!track.artist.isEmpty()) {
display = track.artist + " - " + display;
}
m_trackList->addItem(display);
}
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <QDialog>
#include <QPushButton>
#include <QLineEdit>
#include <QListWidget>
#include "PlaylistManager.h"
class PlaylistCreatorDialog : public QDialog {
Q_OBJECT
public:
explicit PlaylistCreatorDialog(QWidget *parent = nullptr);
PlaylistManager::Playlist getPlaylist() const { return m_playlist; }
private slots:
void onAddFiles();
void onRemoveSelected();
void onCreatePlaylist();
private:
void buildUI();
void updateTrackList();
PlaylistManager::Playlist m_playlist;
QLineEdit *m_nameEdit;
QListWidget *m_trackList;
QPushButton *m_addBtn;
QPushButton *m_removeBtn;
QPushButton *m_createBtn;
};
+192
View File
@@ -0,0 +1,192 @@
#include "PlaylistManager.h"
#include <QDir>
#include <QFile>
#include <QStandardPaths>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QFileInfo>
#include "MetadataReader.h"
QString PlaylistManager::playlistsDir()
{
return QDir::homePath() + "/.subwave/playlists";
}
bool PlaylistManager::ensurePlaylistsDir()
{
QDir dir;
return dir.mkpath(playlistsDir());
}
QString PlaylistManager::playlistFilePath(const QString &playlistName)
{
QString sanitized = sanitizePlaylistName(playlistName);
return playlistsDir() + "/" + sanitized + ".json";
}
QString PlaylistManager::sanitizePlaylistName(const QString &name)
{
QString result = name;
result.replace('/', "_")
.replace('\\', "_")
.replace(':', "_")
.replace('"', "_")
.replace('?', "_")
.replace('*', "_")
.replace('<', "_")
.replace('>', "_")
.replace('|', "_");
return result.trimmed();
}
PlaylistManager::Playlist PlaylistManager::loadPlaylist(const QString &playlistName)
{
Playlist playlist;
playlist.name = playlistName;
playlist.filePath = playlistFilePath(playlistName);
QFile file(playlist.filePath);
if (!file.open(QIODevice::ReadOnly)) {
return playlist;
}
QString json = QString::fromUtf8(file.readAll());
file.close();
playlist = parsePlaylistJson(json);
playlist.name = playlistName;
playlist.filePath = playlistFilePath(playlistName);
return playlist;
}
bool PlaylistManager::savePlaylist(const Playlist &playlist)
{
if (!ensurePlaylistsDir()) {
return false;
}
QString filePath = playlistFilePath(playlist.name);
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
QString json = playlistToJson(playlist);
file.write(json.toUtf8());
file.close();
return true;
}
bool PlaylistManager::deletePlaylist(const QString &playlistName)
{
QString filePath = playlistFilePath(playlistName);
QFile file(filePath);
return file.remove();
}
QStringList PlaylistManager::listPlaylists()
{
QStringList result;
QDir dir(playlistsDir());
if (!dir.exists()) {
return result;
}
QFileInfoList files = dir.entryInfoList(
QStringList() << "*.json",
QDir::Files,
QDir::Name
);
for (const QFileInfo &info : files) {
QString name = info.baseName();
result.append(name);
}
return result;
}
void PlaylistManager::addTrackToPlaylist(Playlist &playlist, const Track &track)
{
playlist.tracks.append(track);
}
void PlaylistManager::removeTrackFromPlaylist(Playlist &playlist, int index)
{
if (index >= 0 && index < playlist.tracks.size()) {
playlist.tracks.removeAt(index);
}
}
void PlaylistManager::clearPlaylist(Playlist &playlist)
{
playlist.tracks.clear();
}
PlaylistManager::Playlist PlaylistManager::parsePlaylistJson(const QString &json)
{
Playlist playlist;
QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8());
if (!doc.isObject()) {
return playlist;
}
QJsonObject obj = doc.object();
if (obj.contains("tracks") && obj["tracks"].isArray()) {
QJsonArray tracksArray = obj["tracks"].toArray();
for (int i = 0; i < tracksArray.size(); ++i) {
QJsonObject trackObj = tracksArray[i].toObject();
Track track;
track.filePath = trackObj["filePath"].toString();
track.title = trackObj["title"].toString();
track.artist = trackObj["artist"].toString();
track.album = trackObj["album"].toString();
track.year = trackObj["year"].toInt(0);
track.trackNumber = trackObj["trackNumber"].toInt(0);
track.durationSecs = trackObj["durationSecs"].toInt(0);
track.format = trackObj["format"].toString();
if (!track.filePath.isEmpty()) {
// Re-extract metadata and cover art from the file
// in case it has changed or cover art wasn't saved
MetadataReader::populate(track);
playlist.tracks.append(track);
}
}
}
return playlist;
}
QString PlaylistManager::playlistToJson(const Playlist &playlist)
{
QJsonObject root;
QJsonArray tracksArray;
for (const Track &track : playlist.tracks) {
QJsonObject trackObj;
trackObj["filePath"] = track.filePath;
trackObj["title"] = track.title;
trackObj["artist"] = track.artist;
trackObj["album"] = track.album;
trackObj["year"] = track.year;
trackObj["trackNumber"] = track.trackNumber;
trackObj["durationSecs"] = static_cast<int>(track.durationSecs);
trackObj["format"] = track.format;
tracksArray.append(trackObj);
}
root["tracks"] = tracksArray;
QJsonDocument doc(root);
return QString::fromUtf8(doc.toJson());
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <QString>
#include <QList>
#include "Track.h"
class PlaylistManager {
public:
struct Playlist {
QString name;
QList<Track> tracks;
QString filePath;
};
static QString playlistsDir();
static bool ensurePlaylistsDir();
// Playlist management
static Playlist loadPlaylist(const QString &playlistName);
static bool savePlaylist(const Playlist &playlist);
static bool deletePlaylist(const QString &playlistName);
static QStringList listPlaylists();
// Track management
static void addTrackToPlaylist(Playlist &playlist, const Track &track);
static void removeTrackFromPlaylist(Playlist &playlist, int index);
static void clearPlaylist(Playlist &playlist);
private:
static QString playlistFilePath(const QString &playlistName);
static QString sanitizePlaylistName(const QString &name);
static Playlist parsePlaylistJson(const QString &json);
static QString playlistToJson(const Playlist &playlist);
};