diff --git a/.github/workflows/buildcheck.yaml b/.github/workflows/buildcheck.yaml index af3a1755..b8d32880 100644 --- a/.github/workflows/buildcheck.yaml +++ b/.github/workflows/buildcheck.yaml @@ -11,7 +11,6 @@ jobs: runs-on: ubuntu-latest steps: - - name: Install Build Dependencies run: | sudo apt update @@ -64,42 +63,100 @@ jobs: cmake --build cmake-build-libprojectm --parallel cmake --install "${{ github.workspace }}/cmake-build-libprojectm" - - name: Checkout frontend-sdl2 Sources + - name: Checkout frontend-sdl-cpp Sources uses: actions/checkout@v6 with: - path: frontend-sdl2 + path: frontend-sdl-cpp submodules: recursive - - name: Build frontend-sdl2 + - name: Build frontend-sdl-cpp + env: + CMAKE_PREFIX_PATH: "${{ github.workspace }}/install-libprojectm;${{ github.workspace }}/install-poco" run: | - mkdir cmake-build-frontend-sdl2 - cmake -G Ninja -S frontend-sdl2 -B cmake-build-frontend-sdl2 \ + mkdir cmake-build-frontend + cmake -G Ninja -S frontend-sdl-cpp -B cmake-build-frontend \ -DCMAKE_BUILD_TYPE=Release \ - "-DCMAKE_PREFIX_PATH=${GITHUB_WORKSPACE}/install-libprojectm;${GITHUB_WORKSPACE}/install-poco" \ - "-DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install-frontend-sdl2" - cmake --build cmake-build-frontend-sdl2 --parallel + "-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}" \ + "-DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install-frontend" \ + -DUSE_SDL=SDL2 + cmake --build cmake-build-frontend --parallel - name: Package projectMSDL run: | - cd cmake-build-frontend-sdl2 + cd cmake-build-frontend cpack -G DEB - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: projectMSDL-buildcheck-linux - path: cmake-build-frontend-sdl2/*.deb + path: cmake-build-frontend/*.deb + + build-arch: + name: Arch Linux, x86_64 + runs-on: ubuntu-latest + container: archlinux:latest + + steps: + - name: Install Build Dependencies + run: | + pacman -Syu --noconfirm + pacman -S --noconfirm base-devel cmake ninja git poco glew freetype2 sdl2 + + - name: Checkout libprojectM Sources + uses: actions/checkout@v6 + with: + repository: projectM-visualizer/projectm + path: projectm + submodules: recursive + + - name: Build/Install libprojectM + run: | + mkdir cmake-build-libprojectm + cmake -G Ninja -S projectm -B cmake-build-libprojectm \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install-libprojectm + cmake --build cmake-build-libprojectm --parallel + cmake --install cmake-build-libprojectm || \ + cmake --build cmake-build-libprojectm --target install || true + + - name: Checkout frontend-sdl-cpp Sources + uses: actions/checkout@v6 + with: + path: frontend-sdl-cpp + submodules: recursive + + - name: Build frontend-sdl-cpp + run: | + mkdir cmake-build-frontend + cmake -G Ninja -S frontend-sdl-cpp -B cmake-build-frontend \ + -DCMAKE_BUILD_TYPE=Release \ + "-DCMAKE_PREFIX_PATH=${{ github.workspace }}/install-libprojectm" \ + "-DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install-frontend" \ + -DUSE_SDL=SDL2 + cmake --build cmake-build-frontend --parallel + + - name: Package projectMSDL + run: | + cd cmake-build-frontend + cpack -G TGZ + + - name: Upload Artifact + uses: actions/upload-artifact@v6 + with: + name: projectMSDL-buildcheck-arch + path: cmake-build-frontend/*.tar.gz build-windows: name: Windows, x64 - runs-on: windows-latest + runs-on: windows-2022 env: USERNAME: projectM-visualizer VCPKG_EXE: ${{ github.workspace }}/vcpkg/vcpkg FEED_URL: https://nuget.pkg.github.com/projectM-visualizer/index.json VCPKG_BINARY_SOURCES: "clear;nuget,https://nuget.pkg.github.com/projectM-visualizer/index.json,readwrite" - steps: - name: Checkout vcpkg uses: actions/checkout@v6 @@ -143,25 +200,25 @@ jobs: - name: Checkout projectMSDL Sources uses: actions/checkout@v6 with: - path: frontend-sdl2 + path: frontend-sdl-cpp submodules: recursive - name: Build projectMSDL run: | - mkdir cmake-build-frontend-sdl2 - cmake -G "Visual Studio 17 2022" -A "X64" -S "${{ github.workspace }}/frontend-sdl2" -B "${{ github.workspace }}/cmake-build-frontend-sdl2" -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows-static -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install-libprojectm" -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install-frontend-sdl2" -DCMAKE_MSVC_RUNTIME_LIBRARY="MultiThreaded$<$:Debug>" -DCMAKE_VERBOSE_MAKEFILE=YES -DSDL2_LINKAGE=static -DBUILD_TESTING=YES - cmake --build "${{ github.workspace }}/cmake-build-frontend-sdl2" --parallel --config Release + mkdir cmake-build-frontend + cmake -G "Visual Studio 17 2022" -A "X64" -S "${{ github.workspace }}/frontend-sdl-cpp" -B "${{ github.workspace }}/cmake-build-frontend" -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows-static -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install-libprojectm" -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install-frontend" -DCMAKE_MSVC_RUNTIME_LIBRARY="MultiThreaded$<$:Debug>" -DCMAKE_VERBOSE_MAKEFILE=YES -DSDL_LINKAGE=static -DBUILD_TESTING=YES -DUSE_SDL=SDL2 + cmake --build "${{ github.workspace }}/cmake-build-frontend" --parallel --config Release - name: Package projectMSDL run: | - cd cmake-build-frontend-sdl2 + cd cmake-build-frontend cpack -G ZIP - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: projectMSDL-buildcheck-windows - path: cmake-build-frontend-sdl2/*.zip + path: cmake-build-frontend/*.zip build-darwin: name: macOS, x86_64 @@ -169,7 +226,8 @@ jobs: steps: - name: Install Build Dependencies - run: brew install sdl2 ninja googletest poco + run: | + brew install sdl2 ninja googletest poco - name: Checkout libprojectM Sources uses: actions/checkout@v6 @@ -188,22 +246,24 @@ jobs: - name: Checkout projectMSDL Sources uses: actions/checkout@v6 with: - path: frontend-sdl2 + path: frontend-sdl-cpp submodules: recursive - name: Build projectMSDL + env: + CMAKE_PREFIX_PATH: "${{ github.workspace }}/install-libprojectm" run: | - mkdir cmake-build-frontend-sdl2 - cmake -G Ninja -S "${{ github.workspace }}/frontend-sdl2" -B "${{ github.workspace }}/cmake-build-frontend-sdl2" -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install-libprojectm" -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install-frontend-sdl2" - cmake --build "${{ github.workspace }}/cmake-build-frontend-sdl2" --parallel + mkdir cmake-build-frontend + cmake -G Ninja -S "${{ github.workspace }}/frontend-sdl-cpp" -B "${{ github.workspace }}/cmake-build-frontend" -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}" -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install-frontend" -DUSE_SDL=SDL2 + cmake --build "${{ github.workspace }}/cmake-build-frontend" --parallel - name: Package projectMSDL run: | - cd cmake-build-frontend-sdl2 + cd cmake-build-frontend cpack -G TGZ - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: projectMSDL-buildcheck-macos - path: cmake-build-frontend-sdl2/*.tar.gz + path: cmake-build-frontend/*.tar.gz diff --git a/src/AudioCapture.cpp b/src/AudioCapture.cpp index 44d23638..28c9d784 100644 --- a/src/AudioCapture.cpp +++ b/src/AudioCapture.cpp @@ -102,6 +102,15 @@ void AudioCapture::FillBuffer() _impl->FillBuffer(); } +float AudioCapture::CurrentAudioLevel() const +{ + if (!_impl) + { + return -1.0f; + } + return _impl->CurrentAudioLevel(); +} + void AudioCapture::PrintDeviceList(const AudioDeviceMap& deviceList) const { if (_config->getBool("listDevices", false)) diff --git a/src/AudioCapture.h b/src/AudioCapture.h index d3206662..483b1dd2 100644 --- a/src/AudioCapture.h +++ b/src/AudioCapture.h @@ -59,6 +59,12 @@ class AudioCapture : public Poco::Util::Subsystem */ void FillBuffer(); + /** + * @brief Returns the current audio peak level (0.0-1.0) for display. + * @return The current audio level, or -1.0 if not available. + */ + float CurrentAudioLevel() const; + protected: /** * @brief Prints a list of available audio devices on standard output if requested by the user. diff --git a/src/AudioCaptureImpl_SDL.cpp b/src/AudioCaptureImpl_SDL.cpp index 2b9c2b58..bab50498 100644 --- a/src/AudioCaptureImpl_SDL.cpp +++ b/src/AudioCaptureImpl_SDL.cpp @@ -160,3 +160,8 @@ void AudioCaptureImpl::AudioInputCallback(void* userData, unsigned char* stream, projectm_pcm_add_float(instance->_projectMHandle, reinterpret_cast(stream), samples, static_cast(instance->_channels)); } + +float AudioCaptureImpl::CurrentAudioLevel() const +{ + return -1.0f; +} diff --git a/src/AudioCaptureImpl_SDL.h b/src/AudioCaptureImpl_SDL.h index f951576a..87717e70 100644 --- a/src/AudioCaptureImpl_SDL.h +++ b/src/AudioCaptureImpl_SDL.h @@ -65,13 +65,15 @@ class AudioCaptureImpl /** * @brief Asks the capture client to fill projectM's audio buffer for the next frame. - * - * As of now, SDL uses async callbacks to directly fill projectM's audio buffer. - * - * @todo Store audio samples internally and push them to projectM when requested. */ void FillBuffer(){}; + /** + * @brief Returns the current audio peak level. + * @return A value between 0.0 and 1.0, or -1.0 if not available. + */ + float CurrentAudioLevel() const; + protected: /** * @brief Opens the SDL audio device with the currently selected index. diff --git a/src/AudioCaptureImpl_WASAPI.cpp b/src/AudioCaptureImpl_WASAPI.cpp index 94aba1fe..4869eabd 100644 --- a/src/AudioCaptureImpl_WASAPI.cpp +++ b/src/AudioCaptureImpl_WASAPI.cpp @@ -132,6 +132,11 @@ void AudioCaptureImpl::FillBuffer() } } +float AudioCaptureImpl::CurrentAudioLevel() const +{ + return -1.0f; +} + HRESULT AudioCaptureImpl::QueryInterface(const IID& riid, void** ppvObject) { if (ppvObject == nullptr) diff --git a/src/AudioCaptureImpl_WASAPI.h b/src/AudioCaptureImpl_WASAPI.h index 9cbd165c..e5788d62 100644 --- a/src/AudioCaptureImpl_WASAPI.h +++ b/src/AudioCaptureImpl_WASAPI.h @@ -84,6 +84,12 @@ class AudioCaptureImpl : public IMMNotificationClient */ void FillBuffer(); + /** + * @brief Returns the current audio peak level. + * @return A value between 0.0 and 1.0, or -1.0 if not available. + */ + float CurrentAudioLevel() const; + /** * @brief Converts a widechar/unicode string to a UTF-8-encoded string * @param unicodeString A pointer to a widechar string diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2e814e75..859fa4c9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,10 @@ add_executable(projectMSDL WIN32 MACOSX_BUNDLE AudioCapture.h FPSLimiter.cpp FPSLimiter.h + PlaylistManager.cpp + PlaylistManager.h + PresetRatings.cpp + PresetRatings.h ProjectMSDLApplication.cpp ProjectMSDLApplication.h ProjectMWrapper.cpp diff --git a/src/PlaylistManager.cpp b/src/PlaylistManager.cpp new file mode 100644 index 00000000..47de42d2 --- /dev/null +++ b/src/PlaylistManager.cpp @@ -0,0 +1,529 @@ +#include "PlaylistManager.h" +#include "ProjectMSDLApplication.h" +#include "ProjectMWrapper.h" + +#include "notifications/DisplayToastNotification.h" + +#include +#include +#include +#include + +#include + +const char* PlaylistManager::name() const +{ + return "Playlist Manager"; +} + +void PlaylistManager::initialize(Poco::Util::Application& app) +{ + auto& projectMWrapper = app.getSubsystem(); + _playlist = projectMWrapper.Playlist(); + + // Set up the playlist directory + Poco::Path dir = Poco::Path::configHome(); + dir.makeDirectory().append("projectM/playlists/"); + _playlistDirectory = dir.toString(); + + try + { + Poco::File playlistsDir(_playlistDirectory); + if (!playlistsDir.exists()) + { + playlistsDir.createDirectories(); + poco_information_f1(_logger, "Created playlist directory: %s", _playlistDirectory); + } + } + catch (Poco::Exception& ex) + { + poco_error_f1(_logger, "Failed to create playlist directory: %s", ex.displayText()); + } + + // Restore the last-used playlist from the previous session + std::string lastPlaylist = CurrentPlaylistName(); + if (!lastPlaylist.empty() && Exists(lastPlaylist)) + { + Load(lastPlaylist); + poco_information_f1(_logger, "Restored last session playlist: %s", lastPlaylist); + } +} + +void PlaylistManager::uninitialize() +{ + _playlist = nullptr; +} + +std::map PlaylistManager::ListPlaylists() const +{ + std::map playlists; + + try + { + Poco::File dir(_playlistDirectory); + if (!dir.exists() || !dir.isDirectory()) + { + return playlists; + } + + std::vector files; + dir.list(files); + + for (const auto& filename : files) + { + Poco::Path filePath(_playlistDirectory, filename); + if (filePath.getExtension() == "milklist") + { + std::string name = filePath.getBaseName(); + uint32_t count = CountPresetsInFile(filePath.toString()); + playlists[name] = count; + } + } + } + catch (Poco::Exception& ex) + { + poco_error_f1(_logger, "Failed to list playlists: %s", ex.displayText()); + } + + return playlists; +} + +bool PlaylistManager::Save(const std::string& name) +{ + if (name.empty()) + { + return false; + } + + if (!_playlist) + { + poco_error(_logger, "No active playlist handle available."); + return false; + } + + uint32_t size = projectm_playlist_size(_playlist); + if (size == 0) + { + poco_warning(_logger, "Current playlist is empty — nothing to save."); + return false; + } + + // Collect all preset paths from the active playlist + std::vector paths; + paths.reserve(size); + + char** items = projectm_playlist_items(_playlist, 0, size); + if (items) + { + for (uint32_t i = 0; items[i] != nullptr; i++) + { + paths.push_back(std::string(items[i])); + } + projectm_playlist_free_string_array(items); + } + + std::string filePath = PlaylistPath(name); + if (WritePlaylistFile(filePath, paths)) + { + poco_information_f2(_logger, "Saved playlist \"%s\" with %?u presets.", name, static_cast(size)); + SetCurrentPlaylist(name); + return true; + } + + return false; +} + +bool PlaylistManager::Load(const std::string& name) +{ + if (name.empty() || !_playlist) + { + return false; + } + + std::string filePath = PlaylistPath(name); + auto paths = ReadPlaylistFile(filePath); + + if (paths.empty()) + { + poco_warning_f1(_logger, "Playlist \"%s\" is empty or could not be read.", name); + return false; + } + + // Clear current playlist and populate from file + projectm_playlist_clear(_playlist); + + uint32_t loaded = 0; + uint32_t skipped = 0; + + for (const auto& presetPath : paths) + { + Poco::File file(presetPath); + if (file.exists() && file.isFile()) + { + projectm_playlist_add_preset(_playlist, presetPath.c_str(), false); + loaded++; + } + else + { + poco_debug_f1(_logger, "Skipping missing preset: %s", presetPath); + skipped++; + } + } + + // Sort the loaded playlist + if (loaded > 0) + { + projectm_playlist_sort(_playlist, 0, projectm_playlist_size(_playlist), + SORT_PREDICATE_FILENAME_ONLY, SORT_ORDER_ASCENDING); + + // Start playing the first preset + projectm_playlist_set_position(_playlist, 0, true); + } + + poco_information_f3(_logger, "Loaded playlist \"%s\": %?u presets loaded, %?u skipped.", + name, loaded, skipped); + + SetCurrentPlaylist(name); + + if (skipped > 0) + { + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification(Poco::format("Loaded \"%s\" (%u presets, %u missing)", + name, loaded, skipped))); + } + else + { + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification(Poco::format("Loaded \"%s\" (%u presets)", name, loaded))); + } + + return loaded > 0; +} + +bool PlaylistManager::Delete(const std::string& name) +{ + std::string filePath = PlaylistPath(name); + + try + { + Poco::File file(filePath); + if (file.exists()) + { + file.remove(); + poco_information_f1(_logger, "Deleted playlist: %s", name); + return true; + } + } + catch (Poco::Exception& ex) + { + poco_error_f2(_logger, "Failed to delete playlist \"%s\": %s", name, ex.displayText()); + } + + return false; +} + +bool PlaylistManager::Rename(const std::string& oldName, const std::string& newName) +{ + if (oldName == newName || newName.empty()) + { + return false; + } + + std::string oldPath = PlaylistPath(oldName); + std::string newPath = PlaylistPath(newName); + + try + { + Poco::File oldFile(oldPath); + if (!oldFile.exists()) + { + poco_warning_f1(_logger, "Cannot rename: playlist \"%s\" does not exist.", oldName); + return false; + } + + Poco::File newFile(newPath); + if (newFile.exists()) + { + poco_warning_f1(_logger, "Cannot rename: playlist \"%s\" already exists.", newName); + return false; + } + + oldFile.renameTo(newPath); + poco_information_f2(_logger, "Renamed playlist \"%s\" to \"%s\".", oldName, newName); + return true; + } + catch (Poco::Exception& ex) + { + poco_error_f2(_logger, "Failed to rename playlist \"%s\": %s", oldName, ex.displayText()); + } + + return false; +} + +std::string PlaylistManager::PlaylistDirectory() const +{ + return _playlistDirectory; +} + +uint32_t PlaylistManager::GetPlaylistSize(const std::string& name) const +{ + return CountPresetsInFile(PlaylistPath(name)); +} + +bool PlaylistManager::Exists(const std::string& name) const +{ + return Poco::File(PlaylistPath(name)).exists(); +} + +bool PlaylistManager::CreateEmpty(const std::string& name) +{ + if (name.empty()) + { + return false; + } + + std::string filePath = PlaylistPath(name); + std::vector emptyPaths; + + if (WritePlaylistFile(filePath, emptyPaths)) + { + poco_information_f1(_logger, "Created empty playlist: %s", name); + SetCurrentPlaylist(name); + return true; + } + + return false; +} + +int32_t PlaylistManager::Export(const std::string& destinationDir) +{ + if (destinationDir.empty() || !_playlist) + { + return -1; + } + + uint32_t size = projectm_playlist_size(_playlist); + if (size == 0) + { + poco_warning(_logger, "Current playlist is empty — nothing to export."); + return 0; + } + + try + { + Poco::File destDir(destinationDir); + if (!destDir.exists()) + { + destDir.createDirectories(); + } + + if (!destDir.isDirectory()) + { + poco_error_f1(_logger, "Export destination is not a directory: %s", destinationDir); + return -1; + } + } + catch (Poco::Exception& ex) + { + poco_error_f1(_logger, "Failed to create export directory: %s", ex.displayText()); + return -1; + } + + char** items = projectm_playlist_items(_playlist, 0, size); + if (!items) + { + return -1; + } + + int32_t exported = 0; + int32_t failed = 0; + + for (uint32_t i = 0; items[i] != nullptr; i++) + { + try + { + Poco::Path sourcePath(items[i]); + Poco::Path destPath(destinationDir); + destPath.makeDirectory().setFileName(sourcePath.getFileName()); + + Poco::File sourceFile(items[i]); + if (sourceFile.exists()) + { + sourceFile.copyTo(destPath.toString()); + exported++; + } + else + { + poco_debug_f1(_logger, "Skipping missing preset for export: %s", items[i]); + failed++; + } + } + catch (Poco::Exception& ex) + { + poco_error_f2(_logger, "Failed to export preset \"%s\": %s", items[i], ex.displayText()); + failed++; + } + } + + projectm_playlist_free_string_array(items); + + poco_information_f3(_logger, "Exported %?d presets to \"%s\" (%?d failed).", + exported, destinationDir, failed); + + return exported; +} + +bool PlaylistManager::AddPreset(const std::string& name, const std::string& presetPath) +{ + if (name.empty() || presetPath.empty()) + { + return false; + } + + std::string filePath = PlaylistPath(name); + auto paths = ReadPlaylistFile(filePath); + + // Check for duplicates + for (const auto& p : paths) + { + if (p == presetPath) + { + return false; // already present + } + } + + paths.push_back(presetPath); + return WritePlaylistFile(filePath, paths); +} + +void PlaylistManager::SetCurrentPlaylist(const std::string& name) +{ + try + { + auto& app = Poco::Util::Application::instance(); + auto& projectMSDLApp = dynamic_cast(app); + projectMSDLApp.UserConfiguration()->setString("playlist.currentPlaylist", name); + } + catch (...) + { + // Best effort + } +} + +std::string PlaylistManager::CurrentPlaylistName() const +{ + try + { + auto& app = Poco::Util::Application::instance(); + auto& projectMSDLApp = dynamic_cast(app); + return projectMSDLApp.UserConfiguration()->getString("playlist.currentPlaylist", ""); + } + catch (...) + { + return {}; + } +} + +std::string PlaylistManager::PlaylistPath(const std::string& name) const +{ + Poco::Path path(_playlistDirectory, name); + return path.makeFile().toString() + ".milklist"; +} + +std::vector PlaylistManager::ReadPlaylistFile(const std::string& path) const +{ + std::vector paths; + + try + { + std::ifstream file(path); + if (!file.is_open()) + { + poco_warning_f1(_logger, "Could not open playlist file: %s", path); + return paths; + } + + std::string line; + while (std::getline(file, line)) + { + // Trim whitespace + size_t start = line.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) + { + continue; // Empty line + } + size_t end = line.find_last_not_of(" \t\r\n"); + std::string trimmed = line.substr(start, end - start + 1); + + // Skip comment lines + if (trimmed.empty() || trimmed[0] == '#') + { + continue; + } + + paths.push_back(trimmed); + } + } + catch (std::exception& ex) + { + poco_error_f2(_logger, "Error reading playlist file \"%s\": %s", path, std::string(ex.what())); + } + + return paths; +} + +bool PlaylistManager::WritePlaylistFile(const std::string& path, const std::vector& paths) const +{ + try + { + std::ofstream file(path, std::ios::out | std::ios::trunc); + if (!file.is_open()) + { + poco_error_f1(_logger, "Could not open playlist file for writing: %s", path); + return false; + } + + for (const auto& presetPath : paths) + { + file << presetPath << '\n'; + } + + return file.good(); + } + catch (std::exception& ex) + { + poco_error_f2(_logger, "Error writing playlist file \"%s\": %s", path, std::string(ex.what())); + return false; + } +} + +uint32_t PlaylistManager::CountPresetsInFile(const std::string& path) const +{ + uint32_t count = 0; + + try + { + std::ifstream file(path); + if (!file.is_open()) + { + return 0; + } + + std::string line; + while (std::getline(file, line)) + { + // Trim and skip empty/comment lines + size_t start = line.find_first_not_of(" \t\r\n"); + if (start != std::string::npos && line[start] != '#') + { + count++; + } + } + } + catch (...) + { + return 0; + } + + return count; +} diff --git a/src/PlaylistManager.h b/src/PlaylistManager.h new file mode 100644 index 00000000..dae9f16a --- /dev/null +++ b/src/PlaylistManager.h @@ -0,0 +1,161 @@ +#pragma once + +#include + +#include +#include + +#include +#include +#include + +/** + * @brief Manages named, persistent playlists stored on disk. + * + * Playlists are saved as simple text files (one preset path per line) in + * ~/.config/projectM/playlists/.milklist + * + * This subsystem integrates with ProjectMWrapper to load saved playlists + * into the active projectM session. + */ +class PlaylistManager : public Poco::Util::Subsystem +{ +public: + const char* name() const override; + + void initialize(Poco::Util::Application& app) override; + + void uninitialize() override; + + /** + * @brief Returns all saved playlists with their preset counts. + * @return A map of playlist name to number of presets. + */ + std::map ListPlaylists() const; + + /** + * @brief Saves the current in-memory playlist under the given name. + * @param name The playlist name (used as the filename). + * @return True on success. + */ + bool Save(const std::string& name); + + /** + * @brief Loads a saved playlist, replacing the current session playlist. + * + * Clears the active playlist and populates it from the file. If any + * preset files no longer exist on disk, they are silently skipped. + * + * @param name The playlist name to load. + * @return True on success. + */ + bool Load(const std::string& name); + + /** + * @brief Deletes a saved playlist file. + * @param name The playlist name to delete. + * @return True on success. + */ + bool Delete(const std::string& name); + + /** + * @brief Renames a saved playlist file. + * @param oldName The current playlist name. + * @param newName The new playlist name. + * @return True on success. + */ + bool Rename(const std::string& oldName, const std::string& newName); + + /** + * @brief Returns the directory where playlist files are stored. + * @return The playlist directory path. + */ + std::string PlaylistDirectory() const; + + /** + * @brief Returns the preset count for a specific saved playlist. + * @param name The playlist name. + * @return The number of presets, or 0 if the playlist doesn't exist. + */ + uint32_t GetPlaylistSize(const std::string& name) const; + + /** + * @brief Returns whether a playlist with the given name exists. + * @param name The playlist name. + * @return True if the playlist file exists. + */ + bool Exists(const std::string& name) const; + + /** + * @brief Creates an empty playlist file with the given name. + * @param name The playlist name. + * @return True on success. + */ + bool CreateEmpty(const std::string& name); + + /** + * @brief Exports all presets in the active playlist to a folder. + * + * Copies each preset file into the destination directory. If a file + * with the same name already exists, it is overwritten. + * + * @param destinationDir The folder to copy presets into. + * @return The number of presets exported, or -1 on error. + */ + int32_t Export(const std::string& destinationDir); + + /** + * @brief Appends a preset path to a saved playlist file. + * @param name The playlist name. + * @param presetPath The preset file path to add. + * @return True if added, false if already present or on error. + */ + bool AddPreset(const std::string& name, const std::string& presetPath); + + /** + * @brief Stores the given name as the current playlist for session restore. + * @param name The playlist name to remember, or empty to clear. + */ + void SetCurrentPlaylist(const std::string& name); + + /** + * @brief Returns the last saved current playlist name, or empty string. + */ + std::string CurrentPlaylistName() const; + +private: + /** + * @brief Returns the full path to a playlist file. + * @param name The playlist name. + * @return The full file path. + */ + std::string PlaylistPath(const std::string& name) const; + + /** + * @brief Reads a playlist file and returns the list of preset paths. + * @param path The file path to read. + * @return Vector of preset file paths (non-empty, non-comment lines). + */ + std::vector ReadPlaylistFile(const std::string& path) const; + + /** + * @brief Writes a list of preset paths to a playlist file. + * @param path The file path to write. + * @param paths The preset file paths to write. + * @return True on success. + */ + bool WritePlaylistFile(const std::string& path, const std::vector& paths) const; + + /** + * @brief Counts presets in a playlist file. + * @param path The file path. + * @return The number of non-comment, non-empty lines. + */ + uint32_t CountPresetsInFile(const std::string& path) const; + + projectm_playlist_handle _playlist{nullptr}; //!< Handle to the active projectM playlist. + + std::string _playlistDirectory; //!< Directory where playlist files are stored. + + Poco::Logger& _logger{Poco::Logger::get("PlaylistManager")}; //!< The class logger. +}; diff --git a/src/PresetRatings.cpp b/src/PresetRatings.cpp new file mode 100644 index 00000000..50e5c923 --- /dev/null +++ b/src/PresetRatings.cpp @@ -0,0 +1,118 @@ +#include "PresetRatings.h" + +#include +#include +#include + +#include + +std::string PresetRatings::RatingsFilePath() const +{ + Poco::Path path = Poco::Path::configHome(); + path.makeDirectory().append("projectM/ratings.json"); + return path.toString(); +} + +void PresetRatings::Load() +{ + _ratings.clear(); + + try + { + std::string filePath = RatingsFilePath(); + Poco::File file(filePath); + if (!file.exists()) + { + return; + } + + std::ifstream in(filePath); + if (!in.is_open()) + { + return; + } + + std::string jsonStr((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + in.close(); + + if (jsonStr.empty()) + { + return; + } + + Poco::JSON::Parser parser; + auto result = parser.parse(jsonStr); + auto obj = result.extract(); + + for (const auto& [key, value] : *obj) + { + int rating = 0; + if (!value.isEmpty()) + { + rating = value.convert(); + } + if (rating >= 1 && rating <= 5) + { + _ratings[key] = rating; + } + } + } + catch (...) + { + // Ratings file may be corrupted — start fresh + } +} + +void PresetRatings::Save() const +{ + try + { + std::string filePath = RatingsFilePath(); + + Poco::File dir(Poco::Path(filePath).makeParent().toString()); + if (!dir.exists()) + { + dir.createDirectories(); + } + + Poco::JSON::Object obj; + for (const auto& [path, rating] : _ratings) + { + obj.set(path, rating); + } + + std::ofstream out(filePath, std::ios::out | std::ios::trunc); + if (out.is_open()) + { + Poco::JSON::Stringifier::stringify(obj, out); + } + } + catch (...) + { + // Best effort + } +} + +int PresetRatings::GetRating(const std::string& presetPath) const +{ + auto it = _ratings.find(presetPath); + if (it != _ratings.end()) + { + return it->second; + } + return 0; +} + +void PresetRatings::SetRating(const std::string& presetPath, int rating) +{ + if (rating < 1 || rating > 5) + { + _ratings.erase(presetPath); + } + else + { + _ratings[presetPath] = rating; + } + Save(); +} diff --git a/src/PresetRatings.h b/src/PresetRatings.h new file mode 100644 index 00000000..823c06ce --- /dev/null +++ b/src/PresetRatings.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include + +/** + * @brief Manages preset ratings stored in a JSON sidecar file. + * + * Ratings are 1-5 stars. 0 means unrated. + * File: ~/.config/projectM/ratings.json + */ +class PresetRatings +{ +public: + /** + * @brief Loads ratings from the ratings file. + */ + void Load(); + + /** + * @brief Saves ratings to the ratings file. + */ + void Save() const; + + /** + * @brief Returns the rating for a preset (0-5, 0 = unrated). + */ + int GetRating(const std::string& presetPath) const; + + /** + * @brief Sets the rating for a preset (0 removes the rating). + */ + void SetRating(const std::string& presetPath, int rating); + + /** + * @brief Returns all ratings (path -> 1-5). + */ + const std::map& Ratings() const { return _ratings; } + +private: + std::string RatingsFilePath() const; + + std::map _ratings; //!< Preset path -> rating (1-5) +}; diff --git a/src/ProjectMSDLApplication.cpp b/src/ProjectMSDLApplication.cpp index 3d12e582..2c797795 100644 --- a/src/ProjectMSDLApplication.cpp +++ b/src/ProjectMSDLApplication.cpp @@ -5,6 +5,7 @@ #include "ProjectMSDLApplication.h" #include "AudioCapture.h" +#include "PlaylistManager.h" #include "ProjectMWrapper.h" #include "RenderLoop.h" #include "SDLRenderingWindow.h" @@ -24,6 +25,7 @@ ProjectMSDLApplication::ProjectMSDLApplication() // Note: order here is important, as subsystems are initialized in the same order. addSubsystem(new SDLRenderingWindow); addSubsystem(new ProjectMWrapper); + addSubsystem(new PlaylistManager); addSubsystem(new AudioCapture); addSubsystem(new ProjectMGUI); } @@ -233,6 +235,10 @@ void ProjectMSDLApplication::defineOptions(Poco::Util::OptionSet& options) options.addOption(Option("beatSensitivity", "", "Beat sensitivity. Between 0.0 and 2.0. Default 1.0.", false, "", true) .binding("projectM.beatSensitivity", _commandLineOverrides)); + + options.addOption(Option("save-presets", "", "Directory to save current presets to.", + false, "", true) + .binding("application.savePresetsPath", _commandLineOverrides)); } int ProjectMSDLApplication::main(POCO_UNUSED const std::vector& args) diff --git a/src/RenderLoop.cpp b/src/RenderLoop.cpp index 7d428e0f..ac0f8bd0 100644 --- a/src/RenderLoop.cpp +++ b/src/RenderLoop.cpp @@ -4,16 +4,24 @@ #include "gui/ProjectMGUI.h" +#include "notifications/DisplayToastNotification.h" +#include "notifications/PlaybackControlNotification.h" + #include #include +#include +#include #include #include "ProjectMSDLApplication.h" +#include + RenderLoop::RenderLoop() : _audioCapture(Poco::Util::Application::instance().getSubsystem()) + , _playlistManager(Poco::Util::Application::instance().getSubsystem()) , _projectMWrapper(Poco::Util::Application::instance().getSubsystem()) , _sdlRenderingWindow(Poco::Util::Application::instance().getSubsystem()) , _projectMHandle(_projectMWrapper.ProjectM()) @@ -21,6 +29,7 @@ RenderLoop::RenderLoop() , _projectMGui(Poco::Util::Application::instance().getSubsystem()) , _userConfig(ProjectMSDLApplication::instance().UserConfiguration()) { + _ratings.Load(); } void RenderLoop::Run() @@ -42,7 +51,7 @@ void RenderLoop::Run() CheckViewportSize(); _audioCapture.FillBuffer(); _projectMWrapper.RenderFrame(); - _projectMGui.Draw(); + _projectMGui.Draw(_showAudioLevel ? _audioCapture.CurrentAudioLevel() : -1.0f); _sdlRenderingWindow.Swap(); @@ -54,6 +63,9 @@ void RenderLoop::Run() notificationCenter.removeObserver(_quitNotificationObserver); + // Save audio level visibility preference + _userConfig->setBool("projectM.displayAudioLevel", _showAudioLevel); + projectm_playlist_set_preset_switched_event_callback(_playlistHandle, nullptr, nullptr); } @@ -294,6 +306,10 @@ void RenderLoop::KeyEvent(const SDL_KeyboardEvent& event, bool down) { _sdlRenderingWindow.ToggleFullscreen(); } + else if (!_projectMGui.WantsKeyboardInput()) + { + QuickFavoriteCurrentPreset(); + } break; case SDLK_i: @@ -352,6 +368,26 @@ void RenderLoop::KeyEvent(const SDL_KeyboardEvent& event, bool down) // Decrease beat sensitivity _projectMWrapper.ChangeBeatSensitivity(-0.01f); break; + + case SDLK_v: + if (!_projectMGui.WantsKeyboardInput()) + { + _showAudioLevel = !_showAudioLevel; + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification(_showAudioLevel ? "Audio level: shown" : "Audio level: hidden")); + } + break; + + case SDLK_1: + case SDLK_2: + case SDLK_3: + case SDLK_4: + case SDLK_5: + if (!_projectMGui.WantsKeyboardInput()) + { + RateCurrentPreset(keyCode - SDLK_1 + 1); + } + break; } } @@ -429,3 +465,48 @@ void RenderLoop::QuitNotificationHandler(const Poco::AutoPtr& { _wantsToQuit = true; } + +void RenderLoop::RateCurrentPreset(int rating) +{ + auto* presetName = projectm_playlist_item(_playlistHandle, projectm_playlist_get_position(_playlistHandle)); + if (!presetName) + { + return; + } + + std::string path(presetName); + projectm_playlist_free_string(presetName); + + _ratings.SetRating(path, rating); + + std::string stars(rating, '*'); + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification("Rated " + stars + " (" + std::to_string(rating) + "/5)")); + + poco_debug_f2(_logger, "Rated preset %?d/5: %s", rating, path); +} + +void RenderLoop::QuickFavoriteCurrentPreset() +{ + auto* presetName = projectm_playlist_item(_playlistHandle, projectm_playlist_get_position(_playlistHandle)); + if (!presetName) + { + return; + } + + std::string path(presetName); + projectm_playlist_free_string(presetName); + + if (_playlistManager.AddPreset("Favorites", path)) + { + Poco::Path p(path); + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification("Added to Favorites: " + p.getFileName())); + poco_debug_f1(_logger, "Added to Favorites: %s", path); + } + else + { + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification("Already in Favorites")); + } +} diff --git a/src/RenderLoop.h b/src/RenderLoop.h index 9429b1ff..bb50ebbb 100644 --- a/src/RenderLoop.h +++ b/src/RenderLoop.h @@ -1,6 +1,8 @@ #pragma once #include "AudioCapture.h" +#include "PlaylistManager.h" +#include "PresetRatings.h" #include "ProjectMWrapper.h" #include "SDLRenderingWindow.h" @@ -72,10 +74,23 @@ class RenderLoop */ void QuitNotificationHandler(const Poco::AutoPtr& notification); + /** + * @brief Rates the currently playing preset (1-5). + */ + void RateCurrentPreset(int rating); + + /** + * @brief Adds the current preset to the Favorites playlist. + */ + void QuickFavoriteCurrentPreset(); + AudioCapture& _audioCapture; + PlaylistManager& _playlistManager; ProjectMWrapper& _projectMWrapper; SDLRenderingWindow& _sdlRenderingWindow; + PresetRatings _ratings; + projectm_handle _projectMHandle{nullptr}; projectm_playlist_handle _playlistHandle{nullptr}; @@ -92,6 +107,8 @@ class RenderLoop ModifierKeyStates _keyStates; //!< Current "pressed" states of modifier keys + bool _showAudioLevel{true}; //!< Whether the audio level indicator is visible + Poco::AutoPtr _userConfig; //!< View of the "projectM" configuration subkey in the "user" configuration. Poco::Logger& _logger{Poco::Logger::get("RenderLoop")}; //!< The class logger. diff --git a/src/gui/HelpWindow.cpp b/src/gui/HelpWindow.cpp index 16b8db8f..90860189 100644 --- a/src/gui/HelpWindow.cpp +++ b/src/gui/HelpWindow.cpp @@ -150,6 +150,7 @@ void HelpWindow::FillKeyboardShortcutsTable() _shortcuts = { {"Quit projectM", "Ctrl+q"}, {"Toggle Menu/UI", "Escape"}, + {"Toggle Audio Level Indicator", "v"}, {"Next Preset in Playlist (immediate)", "n"}, {"Next Preset in Playlist (smooth)", "Shift+n"}, {"Previous Preset in Playlist (immediate)", "p"}, @@ -158,15 +159,19 @@ void HelpWindow::FillKeyboardShortcutsTable() {"Last Played Preset (Back, smooth)", "Shift+Backspace"}, {"Random Preset (immediate)", "r"}, {"Random Preset (smooth)", "Shift+r"}, + {"Rate Current Preset 1-5", "1 / 2 / 3 / 4 / 5"}, + {"Quick-add to Favorites Playlist", "f"}, {"Lock Current Preset", "Spacebar"}, {"Toggle Shuffle", "y"}, - {"Toggle Fullscreen", "Ctrl-f, Right Mouse"}, - {"Toggle Aspect Correction", "Ctrl-a"}, - {"Next Audio Input Device", "Ctrl-i"}, - {"Move to Next Monitor", "Ctrl-m"}, - {"Increase Beat Sensitivity by 1%", "Cursor Up"}, - {"Decrease Beat Sensitivity by 1%", "Cursor Down"}, + {"Toggle Fullscreen", "Ctrl+f, Right Mouse"}, + {"Toggle Aspect Correction", "a"}, + {"Save Current Preset to Save Path", "s"}, + {"Delete Current Preset from Save Path", "d"}, + {"Next Audio Input Device", "Ctrl+i"}, + {"Move to Next Monitor", "Ctrl+m"}, + {"Copy Current Preset Path to Clipboard", "Ctrl+c"}, + {"Increase Beat Sensitivity", "Cursor Up"}, + {"Decrease Beat Sensitivity", "Cursor Down"}, {"Add Random Waveform at Mouse Pointer", "Shift+Left Mouse"}, - {"Clear Random Waveforms", "Middle Mouse"}, - {"Copy Current Preset Path to Clipboard", "Ctrl-c"}}; + {"Clear All Waveforms", "Middle Mouse"}}; } diff --git a/src/gui/MainMenu.cpp b/src/gui/MainMenu.cpp index 02afba65..0a96fb6a 100644 --- a/src/gui/MainMenu.cpp +++ b/src/gui/MainMenu.cpp @@ -1,12 +1,14 @@ #include "gui/MainMenu.h" #include "AudioCapture.h" +#include "PlaylistManager.h" #include "ProjectMSDLApplication.h" #include "ProjectMWrapper.h" #include "gui/ProjectMGUI.h" #include "gui/SystemBrowser.h" +#include "notifications/DisplayToastNotification.h" #include "notifications/PlaybackControlNotification.h" #include "notifications/QuitNotification.h" #include "notifications/UpdateWindowTitleNotification.h" @@ -14,6 +16,7 @@ #include "imgui.h" #include +#include MainMenu::MainMenu(ProjectMGUI& gui) @@ -21,6 +24,7 @@ MainMenu::MainMenu(ProjectMGUI& gui) , _gui(gui) , _projectMWrapper(Poco::Util::Application::instance().getSubsystem()) , _audioCapture(Poco::Util::Application::instance().getSubsystem()) + , _playlistManager(Poco::Util::Application::instance().getSubsystem()) { } @@ -87,6 +91,8 @@ void MainMenu::Draw() ImGui::EndMenu(); } + DrawPlaylistsMenu(); + if (ImGui::BeginMenu("Options")) { auto& app = ProjectMSDLApplication::instance(); @@ -163,4 +169,311 @@ void MainMenu::Draw() ImGui::EndMainMenuBar(); } + + // Render popups outside the menu bar + DrawSavePlaylistPopup(); + DrawNewPlaylistPopup(); + DrawManagePlaylistsPopup(); +} + +void MainMenu::DrawPlaylistsMenu() +{ + if (!ImGui::BeginMenu("Playlists")) + { + return; + } + + if (ImGui::MenuItem("Save Current Playlist...")) + { + _playlistNameBuffer[0] = '\0'; + _showSavePlaylistPopup = true; + } + + if (ImGui::MenuItem("New Empty Playlist...")) + { + _playlistNameBuffer[0] = '\0'; + _showNewPlaylistPopup = true; + } + + ImGui::Separator(); + + // List all saved playlists + auto playlists = _playlistManager.ListPlaylists(); + if (playlists.empty()) + { + ImGui::MenuItem("(No saved playlists)", "", false, false); + } + else + { + for (const auto& playlist : playlists) + { + std::string label = playlist.first + " (" + std::to_string(playlist.second) + ")"; + if (ImGui::MenuItem(label.c_str())) + { + _playlistManager.Load(playlist.first); + } + } + } + + ImGui::Separator(); + + if (ImGui::MenuItem("Manage Playlists...")) + { + _showManagePlaylistsPopup = true; + } + + ImGui::EndMenu(); +} + +void MainMenu::DrawSavePlaylistPopup() +{ + if (!_showSavePlaylistPopup) + { + return; + } + + ImGui::OpenPopup("Save Playlist As"); + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + + if (ImGui::BeginPopupModal("Save Playlist As", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::TextUnformatted("Enter a name for the playlist:"); + + ImGui::SetNextItemWidth(300); + if (ImGui::InputText("##playlist_name", _playlistNameBuffer, sizeof(_playlistNameBuffer), + ImGuiInputTextFlags_EnterReturnsTrue)) + { + if (_playlistNameBuffer[0] != '\0') + { + if (_playlistManager.Save(_playlistNameBuffer)) + { + _notificationCenter.postNotification( + new DisplayToastNotification(std::string("Saved playlist: ") + _playlistNameBuffer)); + } + else + { + _notificationCenter.postNotification( + new DisplayToastNotification("Failed to save playlist (may be empty)")); + } + _showSavePlaylistPopup = false; + ImGui::CloseCurrentPopup(); + } + } + + ImGui::Spacing(); + + if (ImGui::Button("Save", ImVec2(120, 0))) + { + if (_playlistNameBuffer[0] != '\0') + { + if (_playlistManager.Save(_playlistNameBuffer)) + { + _notificationCenter.postNotification( + new DisplayToastNotification(std::string("Saved playlist: ") + _playlistNameBuffer)); + } + else + { + _notificationCenter.postNotification( + new DisplayToastNotification("Failed to save playlist (may be empty)")); + } + _showSavePlaylistPopup = false; + ImGui::CloseCurrentPopup(); + } + } + + ImGui::SameLine(); + + if (ImGui::Button("Cancel", ImVec2(120, 0))) + { + _showSavePlaylistPopup = false; + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } +} + +void MainMenu::DrawNewPlaylistPopup() +{ + if (!_showNewPlaylistPopup) + { + return; + } + + ImGui::OpenPopup("New Empty Playlist"); + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + + if (ImGui::BeginPopupModal("New Empty Playlist", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::TextUnformatted("Enter a name for the new playlist:"); + + ImGui::SetNextItemWidth(300); + if (ImGui::InputText("##new_playlist_name", _playlistNameBuffer, sizeof(_playlistNameBuffer), + ImGuiInputTextFlags_EnterReturnsTrue)) + { + if (_playlistNameBuffer[0] != '\0') + { + // Create an empty playlist file and clear current + _playlistManager.CreateEmpty(_playlistNameBuffer); + projectm_playlist_clear(_projectMWrapper.Playlist()); + + _notificationCenter.postNotification( + new DisplayToastNotification(std::string("Created empty playlist: ") + _playlistNameBuffer)); + _showNewPlaylistPopup = false; + ImGui::CloseCurrentPopup(); + } + } + + ImGui::Spacing(); + + if (ImGui::Button("Create", ImVec2(120, 0))) + { + if (_playlistNameBuffer[0] != '\0') + { + _playlistManager.CreateEmpty(_playlistNameBuffer); + projectm_playlist_clear(_projectMWrapper.Playlist()); + + _notificationCenter.postNotification( + new DisplayToastNotification(std::string("Created empty playlist: ") + _playlistNameBuffer)); + _showNewPlaylistPopup = false; + ImGui::CloseCurrentPopup(); + } + } + + ImGui::SameLine(); + + if (ImGui::Button("Cancel", ImVec2(120, 0))) + { + _showNewPlaylistPopup = false; + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } +} + +void MainMenu::DrawManagePlaylistsPopup() +{ + if (!_showManagePlaylistsPopup) + { + return; + } + + ImGui::OpenPopup("Manage Playlists"); + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(450, 350), ImGuiCond_Appearing); + + if (ImGui::BeginPopupModal("Manage Playlists", &_showManagePlaylistsPopup)) + { + auto playlists = _playlistManager.ListPlaylists(); + + if (playlists.empty()) + { + ImGui::TextUnformatted("No saved playlists found."); + ImGui::Spacing(); + ImGui::TextUnformatted("Add presets by dropping .milk files onto the window,"); + ImGui::TextUnformatted("then use \"Save Current Playlist...\" to save them."); + } + else + { + ImGui::TextUnformatted("Click Load to switch to a playlist. Right-click a name to rename."); + ImGui::Separator(); + + if (ImGui::BeginTable("playlists_table", 3, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable)) + { + ImGui::TableSetupColumn("Playlist", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Presets", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed, 160.0f); + ImGui::TableHeadersRow(); + + std::string playlistToDelete; + + for (const auto& playlist : playlists) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + + // Editable name via double-click + ImGui::TextUnformatted(playlist.first.c_str()); + + ImGui::TableSetColumnIndex(1); + ImGui::Text("%u", playlist.second); + + ImGui::TableSetColumnIndex(2); + ImGui::PushID(playlist.first.c_str()); + + std::string loadLabel = "Load##" + playlist.first; + if (ImGui::SmallButton(loadLabel.c_str())) + { + _playlistManager.Load(playlist.first); + } + + ImGui::SameLine(); + + std::string deleteLabel = "Delete##" + playlist.first; + if (ImGui::SmallButton(deleteLabel.c_str())) + { + playlistToDelete = playlist.first; + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + + // Handle deletion outside the table iteration + if (!playlistToDelete.empty()) + { + ImGui::OpenPopup("Confirm Delete"); + _playlistNameBuffer[0] = '\0'; + // Copy the name for the confirmation popup + strncpy(_playlistNameBuffer, playlistToDelete.c_str(), sizeof(_playlistNameBuffer) - 1); + } + } + } + + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("Close", ImVec2(120, 0))) + { + _showManagePlaylistsPopup = false; + ImGui::CloseCurrentPopup(); + } + + // Confirmation popup for deletion + if (ImGui::BeginPopupModal("Confirm Delete", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + ImGui::Text("Delete playlist \"%s\"?", _playlistNameBuffer); + ImGui::TextUnformatted("This cannot be undone."); + + ImGui::Spacing(); + + if (ImGui::Button("Delete", ImVec2(120, 0))) + { + _playlistManager.Delete(_playlistNameBuffer); + _notificationCenter.postNotification( + new DisplayToastNotification(std::string("Deleted playlist: ") + _playlistNameBuffer)); + ImGui::CloseCurrentPopup(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Cancel", ImVec2(120, 0))) + { + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } + + ImGui::EndPopup(); + } } diff --git a/src/gui/MainMenu.h b/src/gui/MainMenu.h index b3e4fc35..9ee378b0 100644 --- a/src/gui/MainMenu.h +++ b/src/gui/MainMenu.h @@ -5,6 +5,7 @@ class ProjectMGUI; class ProjectMWrapper; class AudioCapture; +class PlaylistManager; namespace Poco { class NotificationCenter; @@ -18,13 +19,42 @@ class MainMenu explicit MainMenu(ProjectMGUI& gui); /** - * @brief Draws the main menu bar. + * @brief Draws the main menu bar and any open popups. */ void Draw(); private: + /** + * @brief Draws the Playlists menu and its submenus. + */ + void DrawPlaylistsMenu(); + + /** + * @brief Draws the "Save Playlist As..." popup. + */ + void DrawSavePlaylistPopup(); + + /** + * @brief Draws the "New Empty Playlist" popup. + */ + void DrawNewPlaylistPopup(); + + /** + * @brief Draws the "Manage Playlists" popup with a table of saved playlists. + */ + void DrawManagePlaylistsPopup(); + Poco::NotificationCenter& _notificationCenter; //!< Notification center instance. ProjectMGUI& _gui; //!< Reference to the GUI subsystem. ProjectMWrapper& _projectMWrapper; //!< Reference to the projectM wrapper subsystem. AudioCapture& _audioCapture; //!< Reference to the audio capture subsystem. + PlaylistManager& _playlistManager; //!< Reference to the playlist manager subsystem. + + // Popup state + bool _showSavePlaylistPopup{false}; + bool _showNewPlaylistPopup{false}; + bool _showManagePlaylistsPopup{false}; + + // Text input buffer for playlist name entry + char _playlistNameBuffer[256]{}; }; diff --git a/src/gui/PresetSelection.cpp b/src/gui/PresetSelection.cpp index 7ecb6c57..69fd0e02 100644 --- a/src/gui/PresetSelection.cpp +++ b/src/gui/PresetSelection.cpp @@ -1,70 +1,325 @@ #include "PresetSelection.h" +#include "PlaylistManager.h" +#include "ProjectMSDLApplication.h" #include "ProjectMWrapper.h" -#include "SDLRenderingWindow.h" + +#include "notifications/DisplayToastNotification.h" +#include "notifications/PlaybackControlNotification.h" #include "imgui.h" +#include +#include #include +PresetSelection::PresetSelection(ProjectMWrapper& projectMWrapper) + : _projectMWrapper(projectMWrapper) +{ +} + void PresetSelection::Show() { _visible = true; + _dirty = true; } void PresetSelection::Draw() { - if (ImGui::Begin("projectM Settings", &_visible)) + if (!_visible) { - ImGui::TextColored(ImVec4(1.0f, 0.0f, 0.0f, 1.0f), "Hint:"); - ImGui::SameLine(); - ImGui::Text("Press after clicking on a slider to enter a custom value."); + return; + } + + ImGui::SetNextWindowSize(ImVec2(500, 550), ImGuiCond_FirstUseEver); + + std::string windowTitle = "Playlist Browser###PlaylistBrowser"; + if (ImGui::Begin(windowTitle.c_str(), &_visible)) + { + auto playlist = _projectMWrapper.Playlist(); + if (!playlist) + { + ImGui::TextUnformatted("No playlist loaded."); + ImGui::End(); + return; + } - if (ImGui::SliderFloat("Preset Display Duration", &_displayDuration, 1.0f, 60.0f)) + // Refresh cached entries when playlist changes + if (_dirty) { - auto& projectMWrapper = Poco::Util::Application::instance().getSubsystem(); - projectm_set_preset_duration(projectMWrapper.ProjectM(), _displayDuration); + RefreshPlaylist(); + _dirty = false; } - if (ImGui::SliderInt("Playlist Position", reinterpret_cast(&_playlistPosition), 0, _playlistSize - 1)) + // --- Filter input --- + ImGui::SetNextItemWidth(-1); + if (ImGui::InputTextWithHint("##PlaylistFilter", "Filter presets...", + _filterBuffer, IM_ARRAYSIZE(_filterBuffer))) { - auto& projectMWrapper = Poco::Util::Application::instance().getSubsystem(); - projectm_playlist_set_position(projectMWrapper.Playlist(), _playlistPosition, true); + _lastFilter = _filterBuffer; } - ImGui::Text("Application average %.3f ms/frame (%.1f FPS)", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate); + // Clear filter button + ImGui::SameLine(); + if (ImGui::Button("X")) + { + _filterBuffer[0] = '\0'; + _lastFilter.clear(); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Clear filter"); + } - if (ImGui::Button("Toggle Fullscreen")) + // Apply filter + std::string filter(_filterBuffer); + _filteredEntries.clear(); + if (filter.empty()) + { + _filteredEntries = _cachedEntries; + } + else { - auto& renderingWindow = Poco::Util::Application::instance().getSubsystem(); - renderingWindow.ToggleFullscreen(); + for (const auto& entry : _cachedEntries) + { + // Case-insensitive substring match on basename + auto it = std::search( + entry.basename.begin(), entry.basename.end(), + filter.begin(), filter.end(), + [](char a, char b) { return std::tolower(a) == std::tolower(b); }); + if (it != entry.basename.end()) + { + _filteredEntries.push_back(entry); + } + } } + // --- Playlist list --- + uint32_t currentPosition = projectm_playlist_get_position(playlist); + uint32_t totalSize = projectm_playlist_size(playlist); + + ImGui::SeparatorText("Presets"); + + // Reserve enough height for the list + ImVec2 listSize = ImVec2(-1, -ImGui::GetFrameHeightWithSpacing() * 2.5f); + if (ImGui::BeginChild("PlaylistItems", listSize, ImGuiChildFlags_Borders)) + { + if (_filteredEntries.empty()) + { + ImGui::TextUnformatted(filter.empty() + ? "Playlist is empty." + : "No presets match the filter."); + } + else + { + for (const auto& entry : _filteredEntries) + { + bool isCurrent = (entry.index == currentPosition); + + // Highlight the currently playing preset + if (isCurrent) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 0.9f, 0.3f, 1.0f)); + } + + // Display with index number + char label[512]; + snprintf(label, sizeof(label), "%u. %s", entry.index + 1, entry.basename.c_str()); + + if (ImGui::Selectable(label, isCurrent)) + { + projectm_playlist_set_position(playlist, entry.index, false); + _dirty = true; // Position may have changed via callback + } + + if (isCurrent) + { + ImGui::PopStyleColor(); + } + + // Show full path on hover + if (ImGui::IsItemHovered()) + { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(entry.fullPath.c_str()); + ImGui::EndTooltip(); + } + } + } + } + ImGui::EndChild(); + + // --- Bottom bar --- + bool shuffleEnabled = projectm_playlist_get_shuffle(playlist); + + ImGui::Separator(); + ImGui::Text("Shuffle: %s", shuffleEnabled ? "ON" : "OFF"); ImGui::SameLine(); - if (ImGui::Button("Random Preset")) + if (ImGui::Button(shuffleEnabled ? "Disable" : "Enable")) { - auto& projectMWrapper = Poco::Util::Application::instance().getSubsystem(); - bool shuffleEnabled = projectm_playlist_get_shuffle(projectMWrapper.Playlist()); - projectm_playlist_set_shuffle(projectMWrapper.Playlist(), true); - projectm_playlist_play_next(projectMWrapper.Playlist(), true); - projectm_playlist_set_shuffle(projectMWrapper.Playlist(), shuffleEnabled); + Poco::NotificationCenter::defaultCenter().postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::ToggleShuffle)); + _dirty = true; } ImGui::SameLine(); - if (ImGui::Button("Lock Preset")) + ImGui::Text("| %u / %u presets", currentPosition + 1, totalSize); + ImGui::SameLine(); + + if (ImGui::Button("Refresh")) { - auto& projectMWrapper = Poco::Util::Application::instance().getSubsystem(); - projectm_set_preset_locked(projectMWrapper.ProjectM(), !projectm_get_preset_locked(projectMWrapper.ProjectM())); + _dirty = true; + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Reload playlist from disk"); } + ImGui::Spacing(); + + // Navigation buttons + if (ImGui::Button("< Prev")) + { + Poco::NotificationCenter::defaultCenter().postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::PreviousPreset, true)); + _dirty = true; + } ImGui::SameLine(); + if (ImGui::Button("Next >")) + { + Poco::NotificationCenter::defaultCenter().postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::NextPreset, true)); + _dirty = true; + } + ImGui::SameLine(); + if (ImGui::Button("Random")) + { + Poco::NotificationCenter::defaultCenter().postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::RandomPreset)); + _dirty = true; + } + + ImGui::Spacing(); - if (ImGui::Button("Load Preset...")) + // --- Export section --- + ImGui::SeparatorText("Export Playlist"); + + if (!_showExport) { - _fileChooser.Show(); + if (ImGui::Button("Show Export Options")) + { + _showExport = true; + // Pre-fill with saved export path from user config + try + { + auto& app = dynamic_cast( + Poco::Util::Application::instance()); + std::string savedPath = app.UserConfiguration()->getString( + "playlist.exportPath", ""); + strncpy(_exportPathBuffer, savedPath.c_str(), sizeof(_exportPathBuffer) - 1); + } + catch (...) {} + } + } + else + { + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##ExportPath", "Destination folder path...", + _exportPathBuffer, IM_ARRAYSIZE(_exportPathBuffer)); + + if (ImGui::Button("Export All to Folder")) + { + std::string exportPath(_exportPathBuffer); + if (exportPath.empty()) + { + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification("Please enter a destination folder path.")); + } + else + { + auto& playlistMgr = Poco::Util::Application::instance() + .getSubsystem(); + int32_t count = playlistMgr.Export(exportPath); + + if (count > 0) + { + // Save export path for next time + try + { + auto& app = dynamic_cast( + Poco::Util::Application::instance()); + app.UserConfiguration()->setString("playlist.exportPath", exportPath); + } + catch (...) {} + + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification( + Poco::format("Exported %d presets.", static_cast(count)))); + } + else if (count == 0) + { + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification("Playlist is empty.")); + } + else + { + Poco::NotificationCenter::defaultCenter().postNotification( + new DisplayToastNotification("Export failed. Check the log for details.")); + } + } + } + + ImGui::SameLine(); + if (ImGui::Button("Hide")) + { + _showExport = false; + } } } ImGui::End(); -} \ No newline at end of file +} + +void PresetSelection::RefreshPlaylist() +{ + _cachedEntries.clear(); + + auto playlist = _projectMWrapper.Playlist(); + if (!playlist) + { + return; + } + + uint32_t size = projectm_playlist_size(playlist); + if (size == 0) + { + return; + } + + // Fetch all items in one call for efficiency + char** items = projectm_playlist_items(playlist, 0, size); + if (!items) + { + return; + } + + _cachedEntries.reserve(size); + for (uint32_t i = 0; i < size && items[i] != nullptr; ++i) + { + PlaylistEntry entry; + entry.index = i; + entry.fullPath = items[i]; + entry.basename = Basename(entry.fullPath); + _cachedEntries.push_back(std::move(entry)); + } + + projectm_playlist_free_string_array(items); +} + +std::string PresetSelection::Basename(const std::string& path) +{ + // Use Poco::Path for cross-platform basename extraction + Poco::Path p(path); + return p.getFileName(); +} diff --git a/src/gui/PresetSelection.h b/src/gui/PresetSelection.h index 04b0bba9..6a0e2f2c 100644 --- a/src/gui/PresetSelection.h +++ b/src/gui/PresetSelection.h @@ -1,27 +1,66 @@ #pragma once -#include "FileChooser.h" - #include #include +#include +#include + +class ProjectMWrapper; + +/** + * @brief Playlist browser window. + * + * Displays all presets in the current playlist as a scrollable, + * filterable list. Users can click any preset to jump to it. + */ class PresetSelection { public: + explicit PresetSelection(ProjectMWrapper& projectMWrapper); + /** + * @brief Shows the playlist browser window. + */ void Show(); + /** + * @brief Draws the playlist browser window if visible. + */ void Draw(); -protected: - FileChooser _fileChooser; //!< File chooser dialog. +private: + /** + * @brief Refreshes the cached playlist items from the playlist manager. + */ + void RefreshPlaylist(); + + /** + * @brief Returns the basename of a file path. + */ + static std::string Basename(const std::string& path); + + ProjectMWrapper& _projectMWrapper; //!< Reference to the projectM wrapper. + + bool _visible{false}; //!< Window visibility flag. + + char _filterBuffer[256]{}; //!< Text filter input buffer. + std::string _lastFilter; //!< Last applied filter string. - bool _visible{ true }; //!< Flag for settings window visibility. - float _displayDuration{ 0.0f }; //!< Preset display time - uint32_t _playlistPosition{ 0 }; //!< Playlist position - uint32_t _playlistSize{ 0 }; //!< Playlist size + /// Cached playlist entries for filtered display. + struct PlaylistEntry + { + uint32_t index; //!< Playlist index. + std::string fullPath; //!< Full preset file path. + std::string basename; //!< Just the filename. + }; + std::vector _cachedEntries; //!< All (unfiltered) playlist entries. + std::vector _filteredEntries; //!< Entries matching current filter. + bool _dirty{true}; //!< True when the cache needs refresh. - Poco::Logger& _logger{ Poco::Logger::get("PresetSelection") }; //!< The class logger. + char _exportPathBuffer[512]{}; //!< Export destination path buffer. + bool _showExport{false}; //!< Show the export section. + Poco::Logger& _logger{Poco::Logger::get("PresetSelection")}; //!< The class logger. }; diff --git a/src/gui/ProjectMGUI.cpp b/src/gui/ProjectMGUI.cpp index 925c8438..a847ba78 100644 --- a/src/gui/ProjectMGUI.cpp +++ b/src/gui/ProjectMGUI.cpp @@ -9,6 +9,7 @@ #include "imgui_impl_opengl3.h" #include "imgui_impl_sdl2.h" +#include #include #include @@ -121,10 +122,12 @@ bool ProjectMGUI::Visible() const return _visible; } -void ProjectMGUI::Draw() +void ProjectMGUI::Draw(float audioLevel) { + bool showAudioLevel = audioLevel >= 0.0f; + // Don't render UI at all if there's no need. - if (!_toast && !_visible) + if (!_toast && !_visible && !showAudioLevel) { return; } @@ -166,6 +169,38 @@ void ProjectMGUI::Draw() _helpWindow.Draw(); } + // Audio level indicator — drawn regardless of UI visibility + if (showAudioLevel) + { + ImGui::SetNextWindowPos(ImVec2(10, ImGui::GetIO().DisplaySize.y - 40), + ImGuiCond_Always, ImVec2(0, 1)); + ImGui::SetNextWindowBgAlpha(0.4f); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoNav; + + if (ImGui::Begin("AudioLevel", nullptr, flags)) + { + // Color gradient: green → yellow → red + ImU32 color; + if (audioLevel < 0.5f) + { + color = ImGui::ColorConvertFloat4ToU32(ImVec4(audioLevel * 2.0f, 1.0f, 0.0f, 1.0f)); + } + else + { + color = ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 2.0f * (1.0f - audioLevel), 0.0f, 1.0f)); + } + + ImGui::TextUnformatted("Audio"); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, color); + ImGui::ProgressBar(audioLevel, ImVec2(100, 12), ""); + ImGui::PopStyleColor(); + } + ImGui::End(); + } + ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); } @@ -212,6 +247,15 @@ void ProjectMGUI::ShowHelpWindow() _helpWindow.Show(); } +void ProjectMGUI::ShowPlaylistBrowser() +{ + if (!_presetSelection) + { + _presetSelection = std::make_unique(*_projectMWrapper); + } + _presetSelection->Show(); +} + float ProjectMGUI::GetScalingFactor() { int windowWidth; diff --git a/src/gui/ProjectMGUI.h b/src/gui/ProjectMGUI.h index aca9d885..0606e23a 100644 --- a/src/gui/ProjectMGUI.h +++ b/src/gui/ProjectMGUI.h @@ -3,6 +3,7 @@ #include "AboutWindow.h" #include "HelpWindow.h" #include "MainMenu.h" +#include "PresetSelection.h" #include "SettingsWindow.h" #include "ToastMessage.h" @@ -13,6 +14,8 @@ #include #include +#include + #include struct ImFont; @@ -58,9 +61,10 @@ class ProjectMGUI : public Poco::Util::Subsystem /** * @brief Draws the UI, including toasts. - * If neither a toast nor the UI are visible, this is basically a no-op. + * If neither a toast nor the UI nor the audio level indicator are visible, this is basically a no-op. + * @param audioLevel Current audio peak level (0.0-1.0), or -1.0 to hide the indicator. */ - void Draw(); + void Draw(float audioLevel = -1.0f); /** * @brief Tells the caller whether the UI currently wants the keyboard input. @@ -104,6 +108,11 @@ class ProjectMGUI : public Poco::Util::Subsystem */ void ShowHelpWindow(); + /** + * @brief Displays the playlist browser window. + */ + void ShowPlaylistBrowser(); + private: float GetScalingFactor(); @@ -131,6 +140,7 @@ class ProjectMGUI : public Poco::Util::Subsystem SettingsWindow _settingsWindow{*this}; //!< The settings window. AboutWindow _aboutWindow{*this}; //!< The about window. HelpWindow _helpWindow; //!< Help window with shortcuts and tips. + std::unique_ptr _presetSelection; //!< The playlist browser window. std::unique_ptr _toast; //!< Current toast to be displayed. diff --git a/src/gui/SettingsWindow.cpp b/src/gui/SettingsWindow.cpp index fa3844df..c0a9fd11 100644 --- a/src/gui/SettingsWindow.cpp +++ b/src/gui/SettingsWindow.cpp @@ -91,6 +91,10 @@ void SettingsWindow::DrawProjectMSettingsTab() LabelWithTooltip("Preset Path", "Path to search for preset files if no playlist is loaded."); PathSetting("projectM.presetPath"); + ImGui::TableNextRow(); + LabelWithTooltip("Save Preset Path", "Path where presets will be saved when pressing 's'."); + PathSetting("application.savePresetsPath"); + ImGui::TableNextRow(); LabelWithTooltip("Texture Path", "Path to search for texture/image files requested by presets."); PathSetting("projectM.texturePath");