diff --git a/CMakeLists.txt b/CMakeLists.txt index c917a76..28e3600 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 86f48a0..649df35 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -7,6 +7,9 @@ #include "SpectrogramWidget.h" #include "EqualizerWidget.h" #include "Config.h" +#include "PlaylistCreatorDialog.h" +#include "PlaylistBrowserDialog.h" +#include "PlaylistManager.h" #include #include @@ -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"); } \ No newline at end of file diff --git a/src/MainWindow.h b/src/MainWindow.h index e9979f7..50cdf0a 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -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; diff --git a/src/engine/EqProcessor.cpp b/src/engine/EqProcessor.cpp index 9dc9c7e..f1b0417 100644 --- a/src/engine/EqProcessor.cpp +++ b/src/engine/EqProcessor.cpp @@ -80,4 +80,4 @@ void EqProcessor::recomputeBiquad(int band) { m_coeff[band][4] = float(a2 / a0); } -} // namespace engine \ No newline at end of file +} // namespace engine diff --git a/src/playlists/PlaylistBrowserDialog.cpp b/src/playlists/PlaylistBrowserDialog.cpp new file mode 100644 index 0000000..621c124 --- /dev/null +++ b/src/playlists/PlaylistBrowserDialog.cpp @@ -0,0 +1,112 @@ +#include "PlaylistBrowserDialog.h" +#include +#include +#include +#include + +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); + } +} diff --git a/src/playlists/PlaylistBrowserDialog.h b/src/playlists/PlaylistBrowserDialog.h new file mode 100644 index 0000000..8acc1c3 --- /dev/null +++ b/src/playlists/PlaylistBrowserDialog.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#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; +}; diff --git a/src/playlists/PlaylistCreatorDialog.cpp b/src/playlists/PlaylistCreatorDialog.cpp new file mode 100644 index 0000000..0b68165 --- /dev/null +++ b/src/playlists/PlaylistCreatorDialog.cpp @@ -0,0 +1,155 @@ +#include "PlaylistCreatorDialog.h" +#include "MetadataReader.h" +#include +#include +#include +#include +#include +#include + +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); + } +} diff --git a/src/playlists/PlaylistCreatorDialog.h b/src/playlists/PlaylistCreatorDialog.h new file mode 100644 index 0000000..69b1f55 --- /dev/null +++ b/src/playlists/PlaylistCreatorDialog.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include +#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; +}; diff --git a/src/playlists/PlaylistManager.cpp b/src/playlists/PlaylistManager.cpp new file mode 100644 index 0000000..bc13014 --- /dev/null +++ b/src/playlists/PlaylistManager.cpp @@ -0,0 +1,192 @@ +#include "PlaylistManager.h" +#include +#include +#include +#include +#include +#include +#include +#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(track.durationSecs); + trackObj["format"] = track.format; + + tracksArray.append(trackObj); + } + + root["tracks"] = tracksArray; + + QJsonDocument doc(root); + return QString::fromUtf8(doc.toJson()); +} diff --git a/src/playlists/PlaylistManager.h b/src/playlists/PlaylistManager.h new file mode 100644 index 0000000..5a8615f --- /dev/null +++ b/src/playlists/PlaylistManager.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include "Track.h" + +class PlaylistManager { +public: + struct Playlist { + QString name; + QList 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); +}; diff --git a/src/PlaylistModel.cpp b/src/playlists/PlaylistModel.cpp similarity index 100% rename from src/PlaylistModel.cpp rename to src/playlists/PlaylistModel.cpp diff --git a/src/PlaylistModel.h b/src/playlists/PlaylistModel.h similarity index 100% rename from src/PlaylistModel.h rename to src/playlists/PlaylistModel.h