diff --git a/RetroFE/Source/CMakeLists.txt b/RetroFE/Source/CMakeLists.txt index 9e24b0b99..5d2b03cc0 100644 --- a/RetroFE/Source/CMakeLists.txt +++ b/RetroFE/Source/CMakeLists.txt @@ -216,6 +216,7 @@ set(RETROFE_HEADERS "${RETROFE_DIR}/Source/Graphics/Component/Component.h" "${RETROFE_DIR}/Source/Graphics/Component/Image.h" "${RETROFE_DIR}/Source/Graphics/Component/ImageBuilder.h" + "${RETROFE_DIR}/Source/Graphics/Component/MusicPlayerComponent.h" "${RETROFE_DIR}/Source/Graphics/Component/ReloadableHiscores.h" "${RETROFE_DIR}/Source/Graphics/Component/ReloadableMedia.h" "${RETROFE_DIR}/Source/Graphics/Component/ReloadableText.h" @@ -231,6 +232,7 @@ set(RETROFE_HEADERS "${RETROFE_DIR}/Source/Graphics/ThreadPool.h" "${RETROFE_DIR}/Source/Menu/Menu.h" "${RETROFE_DIR}/Source/Sound/Sound.h" + "${RETROFE_DIR}/Source/Sound/MusicPlayer.h" "${RETROFE_DIR}/Source/Utility/Log.h" "${RETROFE_DIR}/Source/Utility/Utils.h" "${RETROFE_DIR}/Source/Video/IVideo.h" @@ -278,6 +280,7 @@ set(RETROFE_SOURCES "${RETROFE_DIR}/Source/Graphics/Component/Component.cpp" "${RETROFE_DIR}/Source/Graphics/Component/Image.cpp" "${RETROFE_DIR}/Source/Graphics/Component/ImageBuilder.cpp" + "${RETROFE_DIR}/Source/Graphics/Component/MusicPlayerComponent.cpp" "${RETROFE_DIR}/Source/Graphics/Component/ReloadableHiscores.cpp" "${RETROFE_DIR}/Source/Graphics/Component/Text.cpp" "${RETROFE_DIR}/Source/Graphics/Component/ReloadableMedia.cpp" @@ -288,6 +291,7 @@ set(RETROFE_SOURCES "${RETROFE_DIR}/Source/Graphics/Component/VideoComponent.cpp" "${RETROFE_DIR}/Source/Menu/Menu.cpp" "${RETROFE_DIR}/Source/Sound/Sound.cpp" + "${RETROFE_DIR}/Source/Sound/MusicPlayer.cpp" "${RETROFE_DIR}/Source/Utility/Log.cpp" "${RETROFE_DIR}/Source/Utility/Utils.cpp" "${RETROFE_DIR}/Source/Video/GStreamerVideo.cpp" diff --git a/RetroFE/Source/Control/UserInput.cpp b/RetroFE/Source/Control/UserInput.cpp index d6ca3ada5..3850e19f2 100644 --- a/RetroFE/Source/Control/UserInput.cpp +++ b/RetroFE/Source/Control/UserInput.cpp @@ -83,6 +83,11 @@ bool UserInput::initialize() MapKey("toggleBuildInfo", KeyCodeToggleBuildInfo, false); MapKey("settings", KeyCodeSettings, false); MapKey("quickPlaylist", KeyCodeQuickList, false); + MapKey("musicPlayer.playPause", KeyCodeMusicPlayPause, false); + MapKey("musicPlayer.next", KeyCodeMusicNext, false); + MapKey("musicPlayer.prev", KeyCodeMusicPrev, false); + MapKey("musicPlayer.volUp", KeyCodeMusicVolumeUp, false); + MapKey("musicPlayer.volDown", KeyCodeMusicVolumeDown, false); std::string jbKey; if(config_.getProperty(OPTION_JUKEBOX, jbKey)) { diff --git a/RetroFE/Source/Control/UserInput.h b/RetroFE/Source/Control/UserInput.h index ec9f469c8..f65c86667 100644 --- a/RetroFE/Source/Control/UserInput.h +++ b/RetroFE/Source/Control/UserInput.h @@ -89,6 +89,13 @@ class UserInput KeyCodeToggleBuildInfo, KeyCodeSettings, KeyCodeQuickList, + KeyCodeMusicPlayPause, + KeyCodeMusicNext, + KeyCodeMusicPrev, + KeyCodeMusicVolumeUp, + KeyCodeMusicVolumeDown, + KeyCodeMusicToggleShuffle, + KeyCodeMusicToggleLoop, // leave KeyCodeMax at the end KeyCodeMax, }; diff --git a/RetroFE/Source/Database/Configuration.cpp b/RetroFE/Source/Database/Configuration.cpp index ed6ab7d53..bd8018ae5 100644 --- a/RetroFE/Source/Database/Configuration.cpp +++ b/RetroFE/Source/Database/Configuration.cpp @@ -300,6 +300,25 @@ bool Configuration::getProperty(const std::string& key, int& value) return retVal; } +bool Configuration::getProperty(const std::string& key, float& value) +{ + std::string strValue; + bool retVal = getProperty(key, strValue); + + if (retVal) { + try { + value = std::stof(strValue); + } + catch (const std::invalid_argument&) { + LOG_WARNING("RetroFE", "Invalid float format for key: " + key); + } + catch (const std::out_of_range&) { + LOG_WARNING("RetroFE", "Float out of range for key: " + key); + } + } + return retVal; +} + bool Configuration::getProperty(const std::string& key, bool& value) { diff --git a/RetroFE/Source/Database/Configuration.h b/RetroFE/Source/Database/Configuration.h index 72829701c..9899ddc4b 100644 --- a/RetroFE/Source/Database/Configuration.h +++ b/RetroFE/Source/Database/Configuration.h @@ -34,6 +34,7 @@ class Configuration bool import(const std::string& collection, const std::string& keyPrefix, const std::string& file, bool mustExist = true); bool getProperty(const std::string& key, std::string &value); bool getProperty(const std::string& key, int &value); + bool getProperty(const std::string& key, float& value); bool getProperty(const std::string& key, bool &value); void childKeyCrumbs(const std::string& parent, std::vector &children); void setProperty(const std::string& key, const std::string& value); diff --git a/RetroFE/Source/Execute/AttractMode.cpp b/RetroFE/Source/Execute/AttractMode.cpp index 6a8e8728d..e4f7ab6c5 100644 --- a/RetroFE/Source/Execute/AttractMode.cpp +++ b/RetroFE/Source/Execute/AttractMode.cpp @@ -55,6 +55,9 @@ AttractMode::AttractMode() } void AttractMode::reset(bool set) { + if (idleTime <= 0) + return; + elapsedTime_ = 0; isActive_ = false; isSet_ = set; @@ -78,6 +81,10 @@ void AttractMode::reset(bool set) { } int AttractMode::update(float dt, Page& page) { + + if (idleTime <= 0) + return 0; + // Track total time for state management float currentTime = elapsedTime_ + dt; diff --git a/RetroFE/Source/Execute/Launcher.cpp b/RetroFE/Source/Execute/Launcher.cpp index bfda0b6c9..93eb1185d 100644 --- a/RetroFE/Source/Execute/Launcher.cpp +++ b/RetroFE/Source/Execute/Launcher.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #ifdef WIN32 #include #pragma comment(lib, "Xinput.lib") @@ -41,6 +42,7 @@ #include "PacDrive.h" #include "StdAfx.h" #include +#include #endif #if defined(__linux__) || defined(__APPLE__) #include @@ -419,7 +421,47 @@ std::string replaceVariables(std::string str, #ifdef WIN32 // Utility function to terminate a process and all its child processes -void TerminateProcessAndChildren(DWORD processId) { +void TerminateProcessAndChildren(DWORD processId, const std::string& originalExeName = "", std::set& processedIds = std::set()) { + // Check if we've already processed this process ID to avoid infinite recursion + if (processedIds.find(processId) != processedIds.end()) { + LOG_DEBUG("Launcher", "Process ID: " + std::to_string(processId) + " already processed, skipping."); + return; + } + + // Add this process to the set of processed IDs + processedIds.insert(processId); + + // Verify this is the expected process if we have an original name + bool shouldTerminate = true; + if (!originalExeName.empty()) { + HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, processId); + if (hProcess != nullptr) { + char processName[MAX_PATH] = { 0 }; + if (GetModuleFileNameExA(hProcess, nullptr, processName, MAX_PATH) > 0) { + std::string currentName = processName; + std::string baseName = currentName.substr(currentName.find_last_of("\\/") + 1); + + // Case-insensitive comparison for Windows filenames + std::string lowerBaseName = baseName; + std::string lowerOriginalName = originalExeName; + std::transform(lowerBaseName.begin(), lowerBaseName.end(), lowerBaseName.begin(), ::tolower); + std::transform(lowerOriginalName.begin(), lowerOriginalName.end(), lowerOriginalName.begin(), ::tolower); + + if (lowerBaseName != lowerOriginalName) { + LOG_WARNING("Launcher", "Process ID " + std::to_string(processId) + + " is " + baseName + ", not " + originalExeName + + ". Skipping termination."); + shouldTerminate = false; + } + } + CloseHandle(hProcess); + } + } + + if (!shouldTerminate) { + return; + } + LOG_INFO("Launcher", "Terminating process ID: " + std::to_string(processId)); HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); @@ -436,6 +478,7 @@ void TerminateProcessAndChildren(DWORD processId) { if (Process32First(hSnap, &pe32)) { do { if (pe32.th32ParentProcessID == processId) { + // For child processes, we don't verify the name childPids.push_back(pe32.th32ProcessID); } } while (Process32Next(hSnap, &pe32)); @@ -445,10 +488,10 @@ void TerminateProcessAndChildren(DWORD processId) { // Terminate children first for (DWORD childPid : childPids) { - TerminateProcessAndChildren(childPid); + TerminateProcessAndChildren(childPid, "", processedIds); } - // Now terminate the main process + // Now terminate the main process if it passed our verification HANDLE hProcess = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, processId); if (hProcess != nullptr) { if (TerminateProcess(hProcess, 1)) { @@ -1157,7 +1200,9 @@ bool Launcher::execute(std::string executable, std::string args, std::string cur // Timer elapsed case (process still running) DWORD processId = GetProcessId(hLaunchedProcess); if (processId != 0) { - TerminateProcessAndChildren(processId); + // Extract the executable base name to ensure we terminate the right process + std::string exeName = exePathStr.substr(exePathStr.find_last_of("\\/") + 1); + TerminateProcessAndChildren(processId, exeName); } else { LOG_WARNING("Launcher", "Could not get process ID for termination"); diff --git a/RetroFE/Source/Graphics/Component/MusicPlayerComponent.cpp b/RetroFE/Source/Graphics/Component/MusicPlayerComponent.cpp new file mode 100644 index 000000000..d536384fa --- /dev/null +++ b/RetroFE/Source/Graphics/Component/MusicPlayerComponent.cpp @@ -0,0 +1,1552 @@ +/* This file is part of RetroFE. + * + * RetroFE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * RetroFE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with RetroFE. If not, see . + */ + +#include "MusicPlayerComponent.h" +#include "ImageBuilder.h" +#include "Text.h" +#include "Image.h" +#include "../Page.h" +#include "../ViewInfo.h" +#include "../../Sound/MusicPlayer.h" +#include "../../Database/Configuration.h" +#include "../../Database/GlobalOpts.h" +#include "../../Utility/Log.h" +#include "../../Utility/Utils.h" +#include "../../SDL.h" +#include +#include +#include +#include + +MusicPlayerComponent::MusicPlayerComponent(Configuration& config, bool commonMode, const std::string& type, Page& p, int monitor, FontManager* font) + : Component(p) + , currentPage_(&p) + , config_(config) + , commonMode_(commonMode) + , loadedComponent_(nullptr) + , type_(type) + , musicPlayer_(MusicPlayer::getInstance()) + , font_(font) + , lastState_("") + , refreshInterval_(0.25f) + , refreshTimer_(0.0f) + , directionDisplayTimer_(0.0f) + , directionDisplayDuration_(0.5f) + , albumArtTexture_(nullptr) + , albumArtTrackIndex_(-1) + , renderer_(nullptr) + , albumArtTextureWidth_(0) + , albumArtTextureHeight_(0) + , albumArtNeedsUpdate_{ false } + , isAlbumArt_(Utils::toLower(type) == "albumart") + , volumeEmptyTexture_(nullptr) + , volumeFullTexture_(nullptr) + , volumeBarTexture_(nullptr) + , volumeBarWidth_(0) + , volumeBarHeight_(0) + , lastVolumeValue_(-1) + , volumeBarNeedsUpdate_{ false } + , isVolumeBar_(Utils::toLower(type) == "volbar") + , isProgressBar_(Utils::toLower(type) == "progress") + , currentDisplayAlpha_(0.0f) // Start invisible + , targetAlpha_(0.0f) // Start with target of invisible + , fadeSpeed_(3.0f) // Fade in/out in about 1/3 second + , volumeStableTimer_(0.0f) // Reset timer + , volumeFadeDelay_(1.5f) // Wait 1.5 seconds before fading out + , volumeChanging_(false) // Not changing initially + , isVuMeter_(Utils::toLower(type) == "vumeter") + , vuBarCount_(40) // Default number of bars + , vuDecayRate_(2.0f) // How quickly the bars fall + , vuPeakDecayRate_(0.4f) // How quickly the peak markers fall + , vuMeterTexture_(nullptr) + , vuMeterTextureWidth_(0) + , vuMeterTextureHeight_(0) + , vuMeterNeedsUpdate_(true) + , vuMeterIsMono_(false) // Initialize to default (stereo) + , vuBottomColor_({ 0, 220, 0, 255 }) + , vuMiddleColor_({ 220, 220, 0, 255 }) + , vuTopColor_({ 220, 0, 0, 255 }) + , vuBackgroundColor_({ 40, 40, 40, 255 }) + , vuPeakColor_({ 255, 255, 255, 255 }) + , vuGreenThreshold_(0.4f) + , vuYellowThreshold_(0.6f) + , totalSegments_{ 0 } + , useSegmentedVolume_{ false } { + // Set the monitor for this component + baseViewInfo.Monitor = monitor; + + // Get refresh interval from config if available + int configRefreshInterval; + if (config.getProperty("musicPlayer.refreshRate", configRefreshInterval)) { + refreshInterval_ = static_cast(configRefreshInterval) / 1000.0f; // Convert from ms to seconds + } + + allocateGraphicsMemory(); +} + +MusicPlayerComponent::~MusicPlayerComponent() { + freeGraphicsMemory(); +} + +void MusicPlayerComponent::freeGraphicsMemory() { + Component::freeGraphicsMemory(); + + // Clean up volume bar textures + if (volumeEmptyTexture_ != nullptr) { + SDL_DestroyTexture(volumeEmptyTexture_); + volumeEmptyTexture_ = nullptr; + } + if (volumeFullTexture_ != nullptr) { + SDL_DestroyTexture(volumeFullTexture_); + volumeFullTexture_ = nullptr; + } + if (volumeBarTexture_ != nullptr) { + SDL_DestroyTexture(volumeBarTexture_); + volumeBarTexture_ = nullptr; + } + + if (vuMeterTexture_ != nullptr) { + SDL_DestroyTexture(vuMeterTexture_); + vuMeterTexture_ = nullptr; + } + + if (loadedComponent_ != nullptr) { + loadedComponent_->freeGraphicsMemory(); + delete loadedComponent_; + loadedComponent_ = nullptr; + } +} + +void MusicPlayerComponent::allocateGraphicsMemory() { + Component::allocateGraphicsMemory(); + + // Get the renderer if we're going to handle album art or volume bar + if (isAlbumArt_ || isVolumeBar_ || isProgressBar_ || isVuMeter_) { + renderer_ = SDL::getRenderer(baseViewInfo.Monitor); + } + + if (isVuMeter_) { + musicPlayer_->registerVisualizerCallback(); + + config_.getProperty("musicPlayer.vuMeter.mono", vuMeterIsMono_); // Reads boolean, defaults to false if not found + if (vuMeterIsMono_) { + LOG_INFO("MusicPlayerComponent", "VU Meter configured for mono display."); + } + else { + LOG_INFO("MusicPlayerComponent", "VU Meter configured for stereo display (default)."); + } + + int configBarCount; + if (config_.getProperty("musicPlayer.vuMeter.barCount", configBarCount)) { + vuBarCount_ = std::max(1, std::min(32, configBarCount)); // Limit to reasonable range + } + + float configDecayRate; + if (config_.getProperty("musicPlayer.vuMeter.decayRate", configDecayRate)) { + vuDecayRate_ = std::max(0.1f, configDecayRate); + } + + float configPeakDecayRate; + if (config_.getProperty("musicPlayer.vuMeter.peakDecayRate", configPeakDecayRate)) { + vuPeakDecayRate_ = std::max(0.1f, configPeakDecayRate); + } + + // Load color configurations + std::string colorStr; + SDL_Color parsedColor; // Temporary variable for parsing result + + // Load Bottom Color + if (config_.getProperty("musicPlayer.vuMeter.bottomColor", colorStr)) { + if (parseHexColor(colorStr, parsedColor)) { + vuBottomColor_ = parsedColor; + } + else { + LOG_WARNING("MusicPlayerComponent", "Invalid format for musicPlayer.vuMeter.bottomColor: '" + colorStr + "'. Using default."); + } + } // else: default vuBottomColor_ is used + + // Load Middle Color + if (config_.getProperty("musicPlayer.vuMeter.middleColor", colorStr)) { + if (parseHexColor(colorStr, parsedColor)) { + vuMiddleColor_ = parsedColor; + } + else { + LOG_WARNING("MusicPlayerComponent", "Invalid format for musicPlayer.vuMeter.middleColor: '" + colorStr + "'. Using default."); + } + } // else: default vuMiddleColor_ is used + + // Load Top Color + if (config_.getProperty("musicPlayer.vuMeter.topColor", colorStr)) { + if (parseHexColor(colorStr, parsedColor)) { + vuTopColor_ = parsedColor; + } + else { + LOG_WARNING("MusicPlayerComponent", "Invalid format for musicPlayer.vuMeter.topColor: '" + colorStr + "'. Using default."); + } + } // else: default vuTopColor_ is used + + // Load Background Color + if (config_.getProperty("musicPlayer.vuMeter.backgroundColor", colorStr)) { + if (parseHexColor(colorStr, parsedColor)) { + vuBackgroundColor_ = parsedColor; + } + else { + LOG_WARNING("MusicPlayerComponent", "Invalid format for musicPlayer.vuMeter.backgroundColor: '" + colorStr + "'. Using default."); + } + } // else: default vuBackgroundColor_ is used + + // Load Peak Color + if (config_.getProperty("musicPlayer.vuMeter.peakColor", colorStr)) { + if (parseHexColor(colorStr, parsedColor)) { + vuPeakColor_ = parsedColor; + } + else { + LOG_WARNING("MusicPlayerComponent", "Invalid format for musicPlayer.vuMeter.peakColor: '" + colorStr + "'. Using default."); + } + } // else: default vuPeakColor_ is used + + // Load thresholds + float threshold; + if (config_.getProperty("musicPlayer.vuMeter.greenThreshold", threshold)) { + vuGreenThreshold_ = std::max(0.0f, std::min(1.0f, threshold)); + } + + if (config_.getProperty("musicPlayer.vuMeter.yellowThreshold", threshold)) { + vuYellowThreshold_ = std::max(0.0f, std::min(1.0f, threshold)); + // Ensure yellow threshold is greater than green + vuYellowThreshold_ = std::max(vuYellowThreshold_, vuGreenThreshold_); + } + + // Initialize the VU level arrays + vuLevels_.resize(vuBarCount_, 0.0f); + vuPeaks_.resize(vuBarCount_, 0.0f); + } + + + // If this is a volume bar, load the necessary textures + if (isVolumeBar_) { + // Load volume bar textures + loadVolumeBarTextures(); + + // Load user configuration for fade duration (in milliseconds) + int fadeDurationMs = 333; // Default: 333ms (1/3 second) + + // Try to get user-defined fade duration from config + if (config_.getProperty("musicPlayer.volumeBar.fadeDuration", fadeDurationMs)) { + // Ensure value is at least 1ms to avoid division by zero + fadeDurationMs = std::max(1, fadeDurationMs); + + // Convert from milliseconds to seconds, then to fadeSpeed (which is 1/duration) + float fadeDurationSeconds = static_cast(fadeDurationMs) / 1000.0f; + fadeSpeed_ = 1.0f / fadeDurationSeconds; + + // Log the setting + LOG_INFO("MusicPlayerComponent", + "Volume bar fade duration set to " + std::to_string(fadeDurationMs) + "ms"); + } + + int fadeDelayMs = 1500; // Default: 1500ms (1.5 seconds) + if (config_.getProperty("musicPlayer.volumeBar.fadeDelay", fadeDelayMs)) { + // Convert from milliseconds to seconds + volumeFadeDelay_ = static_cast(fadeDelayMs) / 1000.0f; + } + } + // Only create loadedComponent if this isn't a special type we handle directly + else { + // Create the component based on the specified type + loadedComponent_ = reloadComponent(); + + if (loadedComponent_ != nullptr) { + loadedComponent_->allocateGraphicsMemory(); + } + } +} + +void MusicPlayerComponent::loadVolumeBarTextures() { + // Get layout name from config + std::string layoutName; + config_.getProperty(OPTION_LAYOUT, layoutName); + + // Base paths for volume bar images + std::vector searchPaths; + + std::string collectionName; + if (config_.getProperty("collection", collectionName) && !collectionName.empty()) { + searchPaths.push_back(Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, + "collections", collectionName, "volbar")); + } + + searchPaths.push_back(Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, + "collections", "_common", "medium_artwork", "volbar")); + searchPaths.push_back(Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, "volbar")); + + // Find empty and full images + std::string emptyPath, fullPath; + std::vector extensions = { ".png", ".jpg", ".jpeg" }; + + for (const auto& basePath : searchPaths) { + for (const auto& ext : extensions) { + std::string path = Utils::combinePath(basePath, "empty" + ext); + if (std::filesystem::exists(path)) { + emptyPath = path; + break; + } + } + for (const auto& ext : extensions) { + std::string path = Utils::combinePath(basePath, "full" + ext); + if (std::filesystem::exists(path)) { + fullPath = path; + break; + } + } + if (!emptyPath.empty() && !fullPath.empty()) break; + } + + if (emptyPath.empty() || fullPath.empty()) { + LOG_ERROR("MusicPlayerComponent", "Could not find empty.png and full.png for volume bar"); + return; + } + + // Load empty texture directly + volumeEmptyTexture_ = IMG_LoadTexture(renderer_, emptyPath.c_str()); + + SDL_Surface* fullSurfaceRaw = IMG_Load(fullPath.c_str()); + if (!fullSurfaceRaw || !volumeEmptyTexture_) { + LOG_ERROR("MusicPlayerComponent", "Failed to load volume bar assets"); + if (fullSurfaceRaw) SDL_FreeSurface(fullSurfaceRaw); + if (volumeEmptyTexture_) { + SDL_DestroyTexture(volumeEmptyTexture_); + volumeEmptyTexture_ = nullptr; + } + return; + } + + // Convert to 32-bit RGBA format + SDL_Surface* fullSurface = SDL_ConvertSurfaceFormat(fullSurfaceRaw, SDL_PIXELFORMAT_RGBA8888, 0); + SDL_FreeSurface(fullSurfaceRaw); // no longer needed + if (!fullSurface) { + LOG_ERROR("MusicPlayerComponent", "Failed to convert full surface to RGBA8888"); + SDL_DestroyTexture(volumeEmptyTexture_); + volumeEmptyTexture_ = nullptr; + return; + } + + + totalSegments_ = detectSegmentsFromSurface(fullSurface); + if (totalSegments_ > 0) { + // As long as we detected segments and it's a reasonable number, use segmented mode + // (Add an upper sanity limit to avoid extremely small segments) + if (totalSegments_ <= 50) { // Arbitrary upper limit to avoid unreasonable segment counts + useSegmentedVolume_ = true; + LOG_INFO("MusicPlayerComponent", "Using segmented volume bar with " + std::to_string(totalSegments_) + " segments"); + } + else { + LOG_WARNING("MusicPlayerComponent", "Segment count too high (" + std::to_string(totalSegments_) + "), using proportional volume bar"); + totalSegments_ = 0; + useSegmentedVolume_ = false; + } + } + else { + LOG_INFO("MusicPlayerComponent", "No segments detected, using proportional volume bar"); + totalSegments_ = 0; + useSegmentedVolume_ = false; + } + + // Convert surface to texture + volumeFullTexture_ = SDL_CreateTextureFromSurface(renderer_, fullSurface); + volumeBarWidth_ = fullSurface->w; + volumeBarHeight_ = fullSurface->h; + baseViewInfo.ImageWidth = static_cast(volumeBarWidth_); + baseViewInfo.ImageHeight = static_cast(volumeBarHeight_); + SDL_FreeSurface(fullSurface); + + if (!volumeFullTexture_) { + LOG_ERROR("MusicPlayerComponent", "Failed to create texture from full surface"); + SDL_DestroyTexture(volumeEmptyTexture_); + volumeEmptyTexture_ = nullptr; + return; + } + + // Create the render target + volumeBarTexture_ = SDL_CreateTexture( + renderer_, + SDL_PIXELFORMAT_RGBA8888, + SDL_TEXTUREACCESS_TARGET, + volumeBarWidth_, + volumeBarHeight_ + ); + + if (volumeBarTexture_) { + SDL_SetTextureBlendMode(volumeBarTexture_, SDL_BLENDMODE_BLEND); + } + else { + LOG_ERROR("MusicPlayerComponent", "Failed to create volume bar render target texture"); + } + + updateVolumeBarTexture(); +} + +int MusicPlayerComponent::detectSegmentsFromSurface(SDL_Surface* surface) { + if (!surface || !surface->pixels) { + LOG_ERROR("MusicPlayerComponent", "Invalid surface or pixel data"); + return 0; + } + + int texW = surface->w; + int texH = surface->h; + + // Ensure the surface is in a compatible format + if (surface->format->BytesPerPixel != 4) { + LOG_ERROR("MusicPlayerComponent", "Surface format is not 32-bit, cannot detect segments"); + return 0; + } + + const Uint8 alphaThreshold = 50; // Pixels with alpha below this are considered transparent + + // Lock surface if needed + if (SDL_MUSTLOCK(surface)) { + SDL_LockSurface(surface); + } + + // Check multiple rows to find the one with the most likely segment pattern + // We'll store the best result found + int bestSegmentCount = 0; + std::vector bestSegmentStarts; + + // Sample rows at different positions throughout the image height + // Try more rows for taller images + int numRowsToCheck = std::min(20, texH); // Cap at 20 rows to avoid excessive processing + + for (int rowNum = 0; rowNum < numRowsToCheck; rowNum++) { + // Sample rows at regular intervals throughout the height + int y = (texH * rowNum) / numRowsToCheck; + + // Skip rows that are too close to the edges (may contain frame borders) + if (y < texH * 0.1 || y > texH * 0.9) { + continue; + } + + // Access the row's pixel data + Uint8* pixelData = static_cast(surface->pixels); + Uint32* row = reinterpret_cast(pixelData + y * surface->pitch); + + // For this row, find segments + std::vector segmentStartXs; + bool inSolidSegment = false; + int currentSegmentWidth = 0; + int lastSegmentStart = -1; + + // Scan this row for segments + for (int x = 0; x < texW; ++x) { + Uint32 pixel = row[x]; + Uint8 r, g, b, a; + SDL_GetRGBA(pixel, surface->format, &r, &g, &b, &a); + + bool isVisible = (a > alphaThreshold); + + // State transition: entering a solid segment + if (isVisible && !inSolidSegment) { + inSolidSegment = true; + lastSegmentStart = x; + currentSegmentWidth = 1; + } + // Continuing a solid segment + else if (isVisible && inSolidSegment) { + currentSegmentWidth++; + } + // State transition: exiting a solid segment + else if (!isVisible && inSolidSegment) { + inSolidSegment = false; + + // Only count segments of reasonable width (not single pixels) + if (currentSegmentWidth >= 2) { + segmentStartXs.push_back(lastSegmentStart); + } + currentSegmentWidth = 0; + } + } + + // Handle case where the image ends with a solid segment + if (inSolidSegment && currentSegmentWidth >= 2) { + segmentStartXs.push_back(lastSegmentStart); + } + + // We need at least 2 segments to consider this a valid segmented bar + if (segmentStartXs.size() < 2) { + continue; // Skip this row, not enough segments + } + + // Validate that segments are evenly spaced + bool evenlySpaced = true; + double averageSpacing = 0.0; + std::vector spacings; + + // Calculate spacings between segments + for (size_t i = 1; i < segmentStartXs.size(); ++i) { + int spacing = segmentStartXs[i] - segmentStartXs[i - 1]; + spacings.push_back(spacing); + averageSpacing += spacing; + } + + if (!spacings.empty()) { + averageSpacing /= static_cast(spacings.size()); + + // Calculate standard deviation to measure consistency + double varianceSum = 0.0; + for (int spacing : spacings) { + double diff = spacing - averageSpacing; + varianceSum += diff * diff; + } + double stdDev = std::sqrt(varianceSum / static_cast(spacings.size())); + + // If standard deviation is too high relative to average spacing, + // segments aren't evenly spaced + if (stdDev > (averageSpacing * 0.2)) { // Allow 20% variation + evenlySpaced = false; + } + } + + // If segments are evenly spaced and we found more than before, update best result + if (evenlySpaced && segmentStartXs.size() > static_cast(bestSegmentCount)) { + bestSegmentCount = static_cast(segmentStartXs.size()); + bestSegmentStarts = segmentStartXs; + + // If we found a good number of segments, we can stop early + if (bestSegmentCount >= 10) { + LOG_INFO("MusicPlayerComponent", "Found good segment pattern at row " + std::to_string(y) + + " with " + std::to_string(bestSegmentCount) + " segments"); + break; + } + } + } + + // Unlock surface + if (SDL_MUSTLOCK(surface)) { + SDL_UnlockSurface(surface); + } + + if (bestSegmentCount > 0) { + LOG_INFO("MusicPlayerComponent", "Detected " + std::to_string(bestSegmentCount) + + " segments in volume bar image"); + } + else { + LOG_INFO("MusicPlayerComponent", "No segments detected in volume bar image"); + } + + return bestSegmentCount; +} + +void MusicPlayerComponent::updateVolumeBarTexture() { + if (!renderer_ || !volumeEmptyTexture_ || !volumeFullTexture_ || !volumeBarTexture_) { + return; + } + + SDL_Texture* previousTarget = SDL::getRenderTarget(baseViewInfo.Monitor); + + int volumeRaw = std::clamp(musicPlayer_->getLogicalVolume(), 0, 128); + + SDL_SetRenderTarget(renderer_, volumeBarTexture_); + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0); + SDL_RenderClear(renderer_); + + if (useSegmentedVolume_ && totalSegments_ > 0) { + int segmentWidth = volumeBarWidth_ / totalSegments_; + int activeSegments = (volumeRaw * totalSegments_) / 128; + + for (int i = 0; i < totalSegments_; ++i) { + SDL_Rect rect = { + i * segmentWidth, + 0, + segmentWidth, + volumeBarHeight_ + }; + + if (i < activeSegments) { + SDL_RenderCopy(renderer_, volumeFullTexture_, &rect, &rect); + } + else { + SDL_RenderCopy(renderer_, volumeEmptyTexture_, &rect, &rect); + } + } + } + else { + // Fallback: proportional fill + int visibleWidth = (volumeBarWidth_ * volumeRaw) / 128; + + if (visibleWidth > 0) { + SDL_Rect src = { 0, 0, visibleWidth, volumeBarHeight_ }; + SDL_Rect dst = { 0, 0, visibleWidth, volumeBarHeight_ }; + SDL_RenderCopy(renderer_, volumeFullTexture_, &src, &dst); + } + + if (visibleWidth < volumeBarWidth_) { + SDL_Rect src = { visibleWidth, 0, volumeBarWidth_ - visibleWidth, volumeBarHeight_ }; + SDL_Rect dst = { visibleWidth, 0, volumeBarWidth_ - visibleWidth, volumeBarHeight_ }; + SDL_RenderCopy(renderer_, volumeEmptyTexture_, &src, &dst); + } + } + + SDL_SetRenderTarget(renderer_, previousTarget); + lastVolumeValue_ = volumeRaw; +} + +std::string_view MusicPlayerComponent::filePath() { + if (loadedComponent_ != nullptr) { + return loadedComponent_->filePath(); + } + return ""; +} + +bool MusicPlayerComponent::update(float dt) { + // Update refresh timer + refreshTimer_ += dt; + + if (!musicPlayer_->hasStartedPlaying()) + return Component::update(dt); + + if (isVuMeter_) { + // Update the VU levels + updateVuLevels(); + + // Apply decay to current levels + for (int i = 0; i < vuBarCount_; i++) { + // Decay the main level + vuLevels_[i] = std::max(0.0f, vuLevels_[i] - (vuDecayRate_ * dt)); + + // Decay the peak level more slowly + if (vuPeaks_[i] > vuLevels_[i]) { + vuPeaks_[i] = std::max(vuLevels_[i], vuPeaks_[i] - (vuPeakDecayRate_ * dt)); + } + } + + // Flag texture needs update + vuMeterNeedsUpdate_ = true; + + return Component::update(dt); + } + + // Special handling for album art + if (isAlbumArt_ && refreshTimer_ >= refreshInterval_) { + refreshTimer_ = 0.0f; + int currentTrackIndex = musicPlayer_->getCurrentTrackIndex(); + + if (currentTrackIndex != albumArtTrackIndex_) { + albumArtTrackIndex_ = currentTrackIndex; + albumArtNeedsUpdate_ = true; + lastState_ = std::to_string(currentTrackIndex); + } + return Component::update(dt); + } + + if (isVolumeBar_) { + int volumeRaw = std::clamp(musicPlayer_->getLogicalVolume(), 0, 128); + bool buttonPressed = musicPlayer_->getButtonPressed(); + bool volumeChanged = (volumeRaw != lastVolumeValue_); + + // Always update last volume value so we have the correct value + // for when we rebuild the texture + if (volumeChanged) { + lastVolumeValue_ = volumeRaw; + volumeBarNeedsUpdate_ = true; + } + + // Handle visibility state + if (volumeChanged || buttonPressed) { + // Reset visibility state for either volume changes or button presses + volumeChanging_ = true; + volumeStableTimer_ = 0.0f; + + if (buttonPressed) { + musicPlayer_->setButtonPressed(false); + } + } + // Only count down the timer if we're not actively changing + else if (volumeChanging_) { + volumeStableTimer_ += dt; + + // After delay, stop considering it "changing" + if (volumeStableTimer_ >= volumeFadeDelay_) { + volumeChanging_ = false; + } + } + + // Alpha calculation + if (baseViewInfo.Alpha <= 0.0f) { + // Layout says fully invisible + targetAlpha_ = 0.0f; + currentDisplayAlpha_ = 0.0f; + } + else { + targetAlpha_ = volumeChanging_ ? baseViewInfo.Alpha : 0.0f; + + // Animate current alpha toward target + if (currentDisplayAlpha_ != targetAlpha_) { + float maxAlphaChange = dt * fadeSpeed_; + + if (currentDisplayAlpha_ < targetAlpha_) { + currentDisplayAlpha_ = std::min(currentDisplayAlpha_ + maxAlphaChange, targetAlpha_); + } + else { + currentDisplayAlpha_ = std::max(currentDisplayAlpha_ - maxAlphaChange, targetAlpha_); + } + } + } + + return Component::update(dt); + } + + // Determine current state + std::string currentState; + + if (type_ == "state") { + // Get the unified state + auto state = musicPlayer_->getPlaybackState(); + // Convert the state to a string representation. + switch (state) { + case MusicPlayer::PlaybackState::NEXT: + currentState = "next"; + break; + case MusicPlayer::PlaybackState::PREVIOUS: + currentState = "previous"; + break; + case MusicPlayer::PlaybackState::PLAYING: + currentState = "playing"; + break; + case MusicPlayer::PlaybackState::PAUSED: + currentState = "paused"; + break; + default: + currentState = "unknown"; + break; + } + + // For NEXT/PREVIOUS, display the directional state for a set duration. + if (state == MusicPlayer::PlaybackState::NEXT || state == MusicPlayer::PlaybackState::PREVIOUS) { + directionDisplayTimer_ = directionDisplayDuration_; + } + else { + if (directionDisplayTimer_ > 0.0f) { + directionDisplayTimer_ -= dt; + if (directionDisplayTimer_ <= 0.0f && musicPlayer_->getPlaybackState() != MusicPlayer::PlaybackState::PAUSED) { + // After timer expiration, revert to playing state. + musicPlayer_->setPlaybackState(MusicPlayer::PlaybackState::PLAYING); + currentState = "playing"; + } + } + } + } + else if (type_ == "shuffle") { + currentState = musicPlayer_->getShuffle() ? "on" : "off"; + } + else if (type_ == "loop") { + currentState = musicPlayer_->getLoop() ? "on" : "off"; + } + else if (type_ == "time") { + // For time, update on every refresh interval + currentState = std::to_string(musicPlayer_->getCurrent()); + } + else if (isProgressBar_) { + currentState = std::to_string(musicPlayer_->getCurrent()); + } + else { + // For track/artist/album types, use the currently playing track + currentState = musicPlayer_->getFormattedTrackInfo(); + } + + if ((currentState != lastState_) || (refreshTimer_ >= refreshInterval_)) { + refreshTimer_ = 0.0f; + lastState_ = currentState; + + Component* newComponent = reloadComponent(); + if (newComponent != nullptr && newComponent != loadedComponent_) { + if (loadedComponent_ != nullptr) { + loadedComponent_->freeGraphicsMemory(); + delete loadedComponent_; + } + loadedComponent_ = newComponent; + loadedComponent_->allocateGraphicsMemory(); + } + } + + // Update the loaded component + if (loadedComponent_ != nullptr) { + loadedComponent_->update(dt); + } + + return Component::update(dt); +} + +void MusicPlayerComponent::draw() { + Component::draw(); + + // If the overall alpha is 0, there's no need to draw any components. + if (baseViewInfo.Alpha <= 0.0f) { + return; + } + + // Update album art if needed + if (isAlbumArt_ && albumArtNeedsUpdate_) { + loadAlbumArt(); + albumArtNeedsUpdate_ = false; + } + + // Update volume bar texture if needed + if (isVolumeBar_ && volumeBarNeedsUpdate_) { + updateVolumeBarTexture(); + volumeBarNeedsUpdate_ = false; + } + + if (isVuMeter_) { + drawVuMeter(); + return; + } + + if (isAlbumArt_) { + drawAlbumArt(); + return; + } + + if (isVolumeBar_) { + drawVolumeBar(); + return; + } + + if (isProgressBar_) { + drawProgressBar(); + return; + } + + if (loadedComponent_ != nullptr) { + loadedComponent_->baseViewInfo = baseViewInfo; + loadedComponent_->draw(); + } +} + + +void MusicPlayerComponent::createVuMeterTextureIfNeeded() { + if (!renderer_ || vuMeterTexture_ != nullptr) { + return; // Already created or renderer not available + } + + // Get the dimensions from baseViewInfo + vuMeterTextureWidth_ = static_cast(baseViewInfo.ScaledWidth()); + vuMeterTextureHeight_ = static_cast(baseViewInfo.ScaledHeight()); + + // Ensure we have valid dimensions + if (vuMeterTextureWidth_ <= 0 || vuMeterTextureHeight_ <= 0) { + return; + } + + // Create the render target texture + vuMeterTexture_ = SDL_CreateTexture( + renderer_, + SDL_PIXELFORMAT_RGBA8888, + SDL_TEXTUREACCESS_TARGET, + vuMeterTextureWidth_, + vuMeterTextureHeight_ + ); + + if (vuMeterTexture_) { + SDL_SetTextureBlendMode(vuMeterTexture_, SDL_BLENDMODE_BLEND); + vuMeterNeedsUpdate_ = true; + } + else { + LOG_ERROR("MusicPlayerComponent", "Failed to create VU meter texture"); + } +} + +// Add new method to update the VU meter texture +void MusicPlayerComponent::updateVuMeterTexture() { + if (!renderer_ || !vuMeterTexture_ || !vuMeterNeedsUpdate_) { + return; + } + + // Save current render target + SDL_Texture* previousTarget = SDL::getRenderTarget(baseViewInfo.Monitor); + + // Set the VU meter texture as the render target + SDL_SetRenderTarget(renderer_, vuMeterTexture_); + + // Clear the texture + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0); + SDL_RenderClear(renderer_); + + // Calculate bar dimensions + float barWidth = static_cast(vuMeterTextureWidth_) / static_cast(vuBarCount_); + float barSpacing = barWidth * 0.1f; // 10% of bar width for spacing + float actualBarWidth = barWidth - barSpacing; + + // Set blend mode for transparency + SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND); + + // Draw each bar to the texture + for (int i = 0; i < vuBarCount_; i++) { + float barX = static_cast(i * barWidth); + + // Calculate the height of this bar based on its level + float barHeight = static_cast(vuMeterTextureHeight_) * vuLevels_[i]; + float peakHeight = static_cast(vuMeterTextureHeight_) * vuPeaks_[i]; + + // Background/border for bar + SDL_SetRenderDrawColor( + renderer_, + vuBackgroundColor_.r, + vuBackgroundColor_.g, + vuBackgroundColor_.b, + 255 // Full opacity on the texture + ); + SDL_FRect bgRect = { barX, 0, actualBarWidth, static_cast(vuMeterTextureHeight_) }; + SDL_RenderFillRectF(renderer_, &bgRect); + + // Calculate zone heights based on thresholds + float greenZone = vuMeterTextureHeight_ * vuGreenThreshold_; + float yellowZone = vuMeterTextureHeight_ * (vuYellowThreshold_ - vuGreenThreshold_); + float redZone = vuMeterTextureHeight_ * (1.0f - vuYellowThreshold_); + + // Draw the green segment + if (barHeight > 0) { + SDL_SetRenderDrawColor( + renderer_, + vuBottomColor_.r, + vuBottomColor_.g, + vuBottomColor_.b, + 255 // Full opacity on the texture + ); + float segmentHeight = std::min(barHeight, greenZone); + SDL_FRect greenRect = { + barX + barSpacing * 0.5f, + vuMeterTextureHeight_ - segmentHeight, + actualBarWidth - barSpacing, + segmentHeight + }; + SDL_RenderFillRectF(renderer_, &greenRect); + } + + // Draw the yellow segment + if (barHeight > greenZone) { + SDL_SetRenderDrawColor( + renderer_, + vuMiddleColor_.r, + vuMiddleColor_.g, + vuMiddleColor_.b, + 255 // Full opacity on the texture + ); + float segmentHeight = std::min(barHeight - greenZone, yellowZone); + SDL_FRect yellowRect = { + barX + barSpacing * 0.5f, + vuMeterTextureHeight_ - greenZone - segmentHeight, + actualBarWidth - barSpacing, + segmentHeight + }; + SDL_RenderFillRectF(renderer_, &yellowRect); + } + + // Draw the red segment + if (barHeight > greenZone + yellowZone) { + SDL_SetRenderDrawColor( + renderer_, + vuTopColor_.r, + vuTopColor_.g, + vuTopColor_.b, + 255 // Full opacity on the texture + ); + float segmentHeight = std::min(barHeight - greenZone - yellowZone, redZone); + SDL_FRect redRect = { + barX + barSpacing * 0.5f, + vuMeterTextureHeight_ - greenZone - yellowZone - segmentHeight, + actualBarWidth - barSpacing, + segmentHeight + }; + SDL_RenderFillRectF(renderer_, &redRect); + } + + // Draw peak marker + if (peakHeight > 0 && peakHeight >= barHeight) { + SDL_SetRenderDrawColor( + renderer_, + vuPeakColor_.r, + vuPeakColor_.g, + vuPeakColor_.b, + 255 // Full opacity on the texture + ); + + SDL_FRect peakRect = { + barX + barSpacing * 0.5f, + vuMeterTextureHeight_ - peakHeight - 2, + actualBarWidth - barSpacing, + 2 // 2-pixel height for the peak marker + }; + SDL_RenderFillRectF(renderer_, &peakRect); + } + } + + // Restore previous render target + SDL_SetRenderTarget(renderer_, previousTarget); + + // Mark as updated + vuMeterNeedsUpdate_ = false; +} + + +bool MusicPlayerComponent::parseHexColor(const std::string& hexString, SDL_Color& outColor) { + std::string_view sv = hexString; + + // Must be exactly 6 characters long + if (sv.length() != 6) { + LOG_WARNING("MusicPlayerComponent", "Invalid length for hex string: " + hexString); + return false; + } + + // Check if all characters are hex digits + for (char c : sv) { + if (!std::isxdigit(static_cast(c))) { + LOG_WARNING("parseHexColor", "Non-hex character found in string: " + hexString); + return false; + } + } + + // Use std::from_chars for safe conversion (C++17) + unsigned int r, g, b; + // Using .data() and .data() + length is correct for string_view with from_chars + auto result_r = std::from_chars(sv.data(), sv.data() + 2, r, 16); + auto result_g = std::from_chars(sv.data() + 2, sv.data() + 4, g, 16); + auto result_b = std::from_chars(sv.data() + 4, sv.data() + 6, b, 16); + + // Check if parsing succeeded for all components + if (result_r.ec != std::errc() || result_g.ec != std::errc() || result_b.ec != std::errc() || + result_r.ptr != sv.data() + 2 || // Ensure exactly 2 chars were consumed for R + result_g.ptr != sv.data() + 4 || // Ensure exactly 2 chars were consumed for G + result_b.ptr != sv.data() + 6) // Ensure exactly 2 chars were consumed for B + { + LOG_WARNING("MusicPlayerComponent", "Hex conversion failed for string: " + hexString); // Optional debug log + return false; + } + + + // Assign to SDL_Color (values are already guaranteed 0-255 by hex format and successful parse) + outColor.r = static_cast(r); + outColor.g = static_cast(g); + outColor.b = static_cast(b); + outColor.a = 255; // Full opacity + + return true; +} + +void MusicPlayerComponent::updateVuLevels() { + // Get audio levels from the music player + const std::vector& audioLevels = musicPlayer_->getAudioLevels(); + int channels = musicPlayer_->getAudioChannels(); // Still potentially useful + + if (!musicPlayer_->isPlaying() || audioLevels.empty()) { + // If not playing, rapidly reduce all levels + for (auto& level : vuLevels_) { + level *= 0.8f; // Faster decay when not playing + } + for (auto& peak : vuPeaks_) { + peak *= 0.9f; + } + return; + } + + // Amplification factor + const float amplification = 5.0f; + + // --- MONO / STEREO SPLIT --- + if (vuMeterIsMono_) { + // --- MONO MODE --- + // Calculate average level across all available channels + float averageLevel = 0.0f; + float sum = 0.0f; + for (float level : audioLevels) { + sum += level; + } + if (!audioLevels.empty()) { + averageLevel = sum / static_cast(audioLevels.size()); + } + + // Amplify and clamp the average level + float monoLevel = std::min(1.0f, averageLevel * amplification); + + // Apply this monoLevel to all bars using the existing mono pattern logic + for (int i = 0; i < vuBarCount_; i++) { + // Create a pattern (reusing logic from original mono implementation) + float barPos = static_cast(i) / vuBarCount_; + float patternFactor; + if (i % 2 == 0) { + patternFactor = 1.0f - std::abs(barPos - 0.5f) * 0.6f; + } + else { + patternFactor = 1.0f + 0.3f * std::sin(barPos * 3.14159f * 4); + } + float randomFactor = 1.0f + ((rand() % 25) - 10) / 100.0f; + float newLevel = monoLevel * patternFactor * randomFactor; + newLevel = std::min(1.0f, std::pow(newLevel, 0.75f)); + + if (newLevel > vuLevels_[i]) { + vuLevels_[i] = newLevel; + vuPeaks_[i] = std::max(vuPeaks_[i], newLevel); + } + } + } + else { + // --- STEREO MODE (or fallback if not explicitly mono) --- + if (channels >= 2) { + // Stereo mode: left channel for left half, right channel for right half + int leftBars = vuBarCount_ / 2; + int rightBars = vuBarCount_ - leftBars; + + // Left channel (first half of bars) + float leftLevel = std::min(1.0f, audioLevels[0] * amplification); + for (int i = 0; i < leftBars; i++) { + float barFactor = 1.0f - (static_cast(i) / leftBars) * 0.5f; + float randomFactor = 1.0f + ((rand() % 20) - 10) / 100.0f; + float newLevel = leftLevel * barFactor * randomFactor; + newLevel = std::min(1.0f, std::pow(newLevel, 0.8f)); + if (newLevel > vuLevels_[i]) { + vuLevels_[i] = newLevel; + vuPeaks_[i] = std::max(vuPeaks_[i], newLevel); + } + } + + // Right channel (second half of bars) + float rightLevel = std::min(1.0f, audioLevels[1] * amplification); + for (int i = 0; i < rightBars; i++) { + int barIndex = leftBars + i; + float barFactor = 1.0f - (static_cast(i) / rightBars) * 0.5f; + float randomFactor = 1.0f + ((rand() % 20) - 10) / 100.0f; + float newLevel = rightLevel * barFactor * randomFactor; + newLevel = std::min(1.0f, std::pow(newLevel, 0.8f)); + if (newLevel > vuLevels_[barIndex]) { + vuLevels_[barIndex] = newLevel; + vuPeaks_[barIndex] = std::max(vuPeaks_[barIndex], newLevel); + } + } + + // Add extra dynamics: occasionally boost random bars + if ((rand() % 10) < 3) { // 30% chance each update + int barToBoost = rand() % vuBarCount_; + vuLevels_[barToBoost] = std::min(1.0f, vuLevels_[barToBoost] * 1.3f); + vuPeaks_[barToBoost] = std::max(vuPeaks_[barToBoost], vuLevels_[barToBoost]); + } + } + else { + // Fallback for single channel audio when not in explicit mono mode + // Treat the single channel as mono input + float level = std::min(1.0f, audioLevels[0] * amplification); + for (int i = 0; i < vuBarCount_; i++) { + // Simple distribution: apply level directly, maybe with slight variation + float randomFactor = 1.0f + ((rand() % 10) - 5) / 100.0f; // +/- 5% + float newLevel = std::min(1.0f, level * randomFactor); + + if (newLevel > vuLevels_[i]) { + vuLevels_[i] = newLevel; + vuPeaks_[i] = std::max(vuPeaks_[i], newLevel); + } + } + } + } +} + +void MusicPlayerComponent::drawVuMeter() { + if (!renderer_ || !musicPlayer_->hasStartedPlaying()) { + return; + } + + // Create texture if needed + createVuMeterTextureIfNeeded(); + + // If texture creation failed, return + if (!vuMeterTexture_) { + return; + } + + // Update the texture if needed + if (vuMeterNeedsUpdate_) { + updateVuMeterTexture(); + } + + // Draw the texture + SDL_FRect rect; + rect.x = baseViewInfo.XRelativeToOrigin(); + rect.y = baseViewInfo.YRelativeToOrigin(); + rect.w = baseViewInfo.ScaledWidth(); + rect.h = baseViewInfo.ScaledHeight(); + + SDL::renderCopyF( + vuMeterTexture_, + baseViewInfo.Alpha, + nullptr, + &rect, + baseViewInfo, + page.getLayoutWidth(baseViewInfo.Monitor), + page.getLayoutHeight(baseViewInfo.Monitor) + ); +} + +void MusicPlayerComponent::drawProgressBar() { + if (!renderer_) { + return; + } + + // Get current track progress + float current = static_cast(musicPlayer_->getCurrent()); + float duration = static_cast(musicPlayer_->getDuration()); + + if (duration <= 0) { + return; // Avoid division by zero + } + + float progressPercent = current / duration; + + // Use layout-defined dimensions + float barX = baseViewInfo.XRelativeToOrigin(); + float barY = baseViewInfo.YRelativeToOrigin(); + float barWidth = baseViewInfo.ScaledWidth(); // Full width from layout + float barHeight = baseViewInfo.ScaledHeight(); // Height from layout + + float filledWidth = barWidth * progressPercent; + SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND); + + // Set the background bar color (black) + SDL_SetRenderDrawColor(renderer_, 0, 0, 0, static_cast(baseViewInfo.Alpha * 255)); + + // Draw the full background bar + SDL_FRect backgroundRect = { barX, barY, barWidth, barHeight }; + SDL_RenderFillRectF(renderer_, &backgroundRect); + + // Set the progress bar color (white) + SDL_SetRenderDrawColor(renderer_, 255, 255, 255, static_cast(baseViewInfo.Alpha * 255)); + + // Draw the filled portion (progress) + SDL_FRect progressRect = { barX, barY, filledWidth, barHeight }; + SDL_RenderFillRectF(renderer_, &progressRect); +} + + + +void MusicPlayerComponent::drawAlbumArt() { + if (!renderer_) { + return; + } + + // Since update(dt) is responsible for loading, simply render if the texture exists. + if (albumArtTexture_ != nullptr) { + SDL_FRect rect; + rect.x = baseViewInfo.XRelativeToOrigin(); + rect.y = baseViewInfo.YRelativeToOrigin(); + rect.w = baseViewInfo.ScaledWidth(); + rect.h = baseViewInfo.ScaledHeight(); + SDL::renderCopyF( + albumArtTexture_, + baseViewInfo.Alpha, + nullptr, + &rect, + baseViewInfo, + page.getLayoutWidth(baseViewInfo.Monitor), + page.getLayoutHeight(baseViewInfo.Monitor) + ); + } +} + +void MusicPlayerComponent::loadAlbumArt() { + if (albumArtTexture_ != nullptr) { + SDL_DestroyTexture(albumArtTexture_); + albumArtTexture_ = nullptr; + } + // Try to get album art from the music player + std::vector albumArtData; + if (musicPlayer_->getAlbumArt(albumArtTrackIndex_, albumArtData) && !albumArtData.empty()) { + SDL_RWops* rw = SDL_RWFromConstMem(albumArtData.data(), static_cast(albumArtData.size())); + if (rw) { + albumArtTexture_ = IMG_LoadTexture_RW(renderer_, rw, 1); // 1 means auto-close + if (albumArtTexture_) { + SDL_QueryTexture(albumArtTexture_, nullptr, nullptr, + &albumArtTextureWidth_, &albumArtTextureHeight_); + baseViewInfo.ImageWidth = static_cast(albumArtTextureWidth_); + baseViewInfo.ImageHeight = static_cast(albumArtTextureHeight_); + LOG_INFO("MusicPlayerComponent", "Created album art texture"); + return; + } + } + } + // Fallback: load default album art if none found or on error + albumArtTexture_ = loadDefaultAlbumArt(); +} + +SDL_Texture* MusicPlayerComponent::loadDefaultAlbumArt() { + // Get layout name from config + std::string layoutName; + config_.getProperty(OPTION_LAYOUT, layoutName); + + // Try different paths for default album art + std::vector searchPaths = { + Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, + "collections", "_common", "medium_artwork", "albumart", "default.png"), + Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, + "collections", "_common", "medium_artwork", "albumart", "default.jpg"), + Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, + "collections", "_common", "medium_artwork", "music", "default.png"), + Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, + "collections", "_common", "medium_artwork", "music", "default.jpg") + }; + + for (const auto& path : searchPaths) { + if (std::filesystem::exists(path)) { + SDL_Texture* texture = IMG_LoadTexture(renderer_, path.c_str()); + if (texture) { + // Get dimensions for the default texture + SDL_QueryTexture(texture, nullptr, nullptr, + &albumArtTextureWidth_, &albumArtTextureHeight_); + baseViewInfo.ImageWidth = static_cast(albumArtTextureWidth_); + baseViewInfo.ImageHeight = static_cast(albumArtTextureHeight_); + LOG_INFO("MusicPlayerComponent", "Loaded default album art from: " + path); + return texture; + } + } + } + + LOG_WARNING("MusicPlayerComponent", "Failed to load default album art"); + return nullptr; +} + +void MusicPlayerComponent::drawVolumeBar() { + if (!renderer_ || !volumeBarTexture_) { + return; + } + + // Draw the volume bar texture + SDL_FRect rect; + + // Use the baseViewInfo for position and size calculations + rect.x = baseViewInfo.XRelativeToOrigin(); + rect.y = baseViewInfo.YRelativeToOrigin(); + rect.w = baseViewInfo.ScaledWidth(); + rect.h = baseViewInfo.ScaledHeight(); + + SDL::renderCopyF( + volumeBarTexture_, + currentDisplayAlpha_, + nullptr, + &rect, + baseViewInfo, + page.getLayoutWidth(baseViewInfo.Monitor), + page.getLayoutHeight(baseViewInfo.Monitor) + ); +} + +Component* MusicPlayerComponent::reloadComponent() { + // Album art is handled directly, don't create a component for it + if (isAlbumArt_ || isVolumeBar_ || !musicPlayer_->hasStartedPlaying()) { + return nullptr; + } + + Component* component = nullptr; + std::string typeLC = Utils::toLower(type_); + std::string basename; + + // Determine the basename based on component type + if (typeLC == "state") { + // Use the unified PlaybackState from MusicPlayer. + MusicPlayer::PlaybackState state = musicPlayer_->getPlaybackState(); + + // If we have a directional state (NEXT or PREVIOUS) and fading is done, + // reset the state to PLAYING if music is playing. + if ((state == MusicPlayer::PlaybackState::NEXT || state == MusicPlayer::PlaybackState::PREVIOUS) && + !musicPlayer_->isFading()) { + if (musicPlayer_->isPlaying()) { + musicPlayer_->setPlaybackState(MusicPlayer::PlaybackState::PLAYING); + } + } + // Update our local copy of the state. + state = musicPlayer_->getPlaybackState(); + + // Set basename based on the unified state. + if (state == MusicPlayer::PlaybackState::NEXT) { + basename = "next"; + } + else if (state == MusicPlayer::PlaybackState::PREVIOUS) { + basename = "previous"; + } + else if (state == MusicPlayer::PlaybackState::PLAYING) { + basename = "playing"; + } + else if (state == MusicPlayer::PlaybackState::PAUSED) { + basename = "paused"; + } + } + else if (typeLC == "shuffle") { + basename = musicPlayer_->getShuffle() ? "on" : "off"; + } + else if (typeLC == "loop" || typeLC == "repeat") { + basename = musicPlayer_->getLoop() ? "on" : "off"; + } + else if (typeLC == "filename") { + std::string fileName = musicPlayer_->getCurrentTrackNameWithoutExtension(); + + if (loadedComponent_) { + loadedComponent_->setText(fileName); + } + else { + loadedComponent_ = new Text(fileName, page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else if (typeLC == "trackinfo") { + std::string trackName = musicPlayer_->getFormattedTrackInfo(); + if (trackName.empty()) { + trackName = "No track playing"; + } + + if (loadedComponent_) { + loadedComponent_->setText(trackName); + } + else { + loadedComponent_ = new Text(trackName, page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else if (typeLC == "title") { + std::string titleName = musicPlayer_->getCurrentTitle(); + if (titleName.empty()) { + titleName = "Unknown"; + } + if (loadedComponent_) { + loadedComponent_->setText(titleName); + } + else { + loadedComponent_ = new Text(titleName, page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else if (typeLC == "artist") { + std::string artistName = musicPlayer_->getCurrentArtist(); + if (artistName.empty()) { + artistName = "Unknown Artist"; + } + + if (loadedComponent_) { + loadedComponent_->setText(artistName); + } + else { + loadedComponent_ = new Text(artistName, page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else if (typeLC == "album") { + std::string albumName = musicPlayer_->getCurrentAlbum(); + if (albumName.empty()) { + albumName = "Unknown Album"; + } + + if (loadedComponent_) { + loadedComponent_->setText(albumName); + } + else { + loadedComponent_ = new Text(albumName, page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else if (typeLC == "time") { + auto [currentSec, durationSec] = musicPlayer_->getCurrentAndDurationSec(); + + if (currentSec < 0) + return nullptr; + + int currentMin = currentSec / 60; + int currentRemSec = currentSec % 60; + int durationMin = durationSec / 60; + int durationRemSec = durationSec % 60; + + std::stringstream ss; + int minWidth = durationMin >= 10 ? 2 : 1; + + ss << std::setfill('0') << std::setw(minWidth) << currentMin << ":" + << std::setfill('0') << std::setw(2) << currentRemSec << "/" + << std::setfill('0') << std::setw(minWidth) << durationMin << ":" + << std::setfill('0') << std::setw(2) << durationRemSec; + + if (loadedComponent_) { + loadedComponent_->setText(ss.str()); + } + else { + loadedComponent_ = new Text(ss.str(), page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else if (typeLC == "volume") { + int volumeRaw = musicPlayer_->getVolume(); + int volumePercentage = static_cast((volumeRaw / 128.0f) * 100.0f + 0.5f); + std::string volumeStr = std::to_string(volumePercentage); + + if (loadedComponent_) { + loadedComponent_->setText(volumeStr); + } + else { + loadedComponent_ = new Text(volumeStr, page, font_, baseViewInfo.Monitor); + } + return loadedComponent_; + } + else { + // Default basename for other types + basename = typeLC; + } + + // Get the layout name from configuration + std::string layoutName; + config_.getProperty(OPTION_LAYOUT, layoutName); + + // Construct path to the image + std::string imagePath; + if (commonMode_) { + // Use common path for music player components + imagePath = Utils::combinePath(Configuration::absolutePath, "layouts", layoutName, "collections", "_common", "medium_artwork", typeLC); + } + else { + // Use a specific path if not in common mode + imagePath = Utils::combinePath(Configuration::absolutePath, "music", typeLC); + } + + // Use ImageBuilder to create the image component + ImageBuilder imageBuild{}; + component = imageBuild.CreateImage(imagePath, page, basename, baseViewInfo.Monitor, baseViewInfo.Additive, true); + + return component; +} + +void MusicPlayerComponent::pause() { + if (musicPlayer_->isPlaying()) { + musicPlayer_->pauseMusic(); + } + else { + musicPlayer_->playMusic(); + } +} + +unsigned long long MusicPlayerComponent::getCurrent() { + return static_cast(musicPlayer_->getCurrent()); +} + +unsigned long long MusicPlayerComponent::getDuration() { + return static_cast(musicPlayer_->getDuration()); +} + +bool MusicPlayerComponent::isPaused() { + return musicPlayer_->isPaused(); +} + +bool MusicPlayerComponent::isPlaying() { + return musicPlayer_->isPlaying(); +} \ No newline at end of file diff --git a/RetroFE/Source/Graphics/Component/MusicPlayerComponent.h b/RetroFE/Source/Graphics/Component/MusicPlayerComponent.h new file mode 100644 index 000000000..211dca914 --- /dev/null +++ b/RetroFE/Source/Graphics/Component/MusicPlayerComponent.h @@ -0,0 +1,138 @@ +/* This file is part of RetroFE. + * + * RetroFE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * RetroFE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with RetroFE. If not, see . + */ +#pragma once + +#include "Component.h" +#include "../../Sound/MusicPlayer.h" +#include +#include + +class Configuration; +class Image; +class FontManager; + +class MusicPlayerComponent : public Component +{ +public: + MusicPlayerComponent(Configuration& config, bool commonMode, const std::string& type, Page& p, int monitor, FontManager* font = nullptr); + ~MusicPlayerComponent() override; + + bool update(float dt) override; + void triggerImmediateUpdate(); + void draw() override; + void drawProgressBar(); + void drawAlbumArt(); + SDL_Texture* loadDefaultAlbumArt(); + void drawVolumeBar(); + void freeGraphicsMemory() override; + void allocateGraphicsMemory() override; + std::string_view filePath() override; // Add to match other components + + // Control functions for interacting with the music player + void pause() override; + unsigned long long getCurrent() override; + unsigned long long getDuration() override; + bool isPaused() override; + bool isPlaying() override; + + // Set the component type + void setType(const std::string& type) { type_ = type; } + +private: + // Find and load appropriate component based on type and state + Component* reloadComponent(); + Page* currentPage_; + Configuration& config_; + bool commonMode_; + Component* loadedComponent_; + std::string type_; // Type of MusicPlayer component: "state", "shuffle", "loop", etc. + MusicPlayer* musicPlayer_; + FontManager* font_; // Font for text display + + // State tracking + std::string lastState_; // Tracks the last state (playing/paused/etc.) + float refreshInterval_; // How often to update in seconds + float refreshTimer_; + float directionDisplayTimer_; + const float directionDisplayDuration_; + + // Album art tracking + SDL_Texture* albumArtTexture_; + int albumArtTrackIndex_; + SDL_Renderer* renderer_; + int albumArtTextureWidth_; + int albumArtTextureHeight_; + bool albumArtNeedsUpdate_; + bool isAlbumArt_; + void loadAlbumArt(); + + // Volume bar textures and data + SDL_Texture* volumeEmptyTexture_; + SDL_Texture* volumeFullTexture_; + SDL_Texture* volumeBarTexture_; + int volumeBarWidth_; + int volumeBarHeight_; + int lastVolumeValue_; + bool volumeBarNeedsUpdate_; + bool isVolumeBar_; + + // Progress bar + bool isProgressBar_; + + // Create a volume bar texture based on current volume + void loadVolumeBarTextures(); + int detectSegmentsFromSurface(SDL_Surface* surface); + void updateVolumeBarTexture(); + + // Alpha animation for volume bar + float currentDisplayAlpha_; // Current display alpha (for fading) + float targetAlpha_; // Target alpha to fade towards + float fadeSpeed_; // How fast alpha changes (units per second) + float volumeStableTimer_; // How long volume has been stable + float volumeFadeDelay_; // How long to wait before fading out + bool volumeChanging_; // Is volume currently changing + + // VU meter data and rendering + bool isVuMeter_; + int vuBarCount_; + std::vector vuLevels_; + std::vector vuPeaks_; + float vuDecayRate_; + float vuPeakDecayRate_; + void drawVuMeter(); + void createVuMeterTextureIfNeeded(); + void updateVuMeterTexture(); + bool parseHexColor(const std::string& hexString, SDL_Color& outColor); + void updateVuLevels(); + SDL_Texture* vuMeterTexture_; // Target texture for VU meter rendering + int vuMeterTextureWidth_; + int vuMeterTextureHeight_; + bool vuMeterNeedsUpdate_; // Flag to track when texture update is needed + bool vuMeterIsMono_; + + // VU meter theming + SDL_Color vuBottomColor_; + SDL_Color vuMiddleColor_; + SDL_Color vuTopColor_; + SDL_Color vuBackgroundColor_; + SDL_Color vuPeakColor_; + float vuGreenThreshold_; // Level threshold for green (0.0-1.0) + float vuYellowThreshold_; // Level threshold for yellow (0.0-1.0) + + int totalSegments_; + bool useSegmentedVolume_; + +}; \ No newline at end of file diff --git a/RetroFE/Source/Graphics/Component/ReloadableText.cpp b/RetroFE/Source/Graphics/Component/ReloadableText.cpp index f35865e1d..18168e68f 100644 --- a/RetroFE/Source/Graphics/Component/ReloadableText.cpp +++ b/RetroFE/Source/Graphics/Component/ReloadableText.cpp @@ -18,6 +18,7 @@ #include "../../Database/Configuration.h" #include "../../Database/GlobalOpts.h" #include "../../Database/Configuration.h" +#include "../../Sound/MusicPlayer.h" #include "../../SDL.h" #include "../../Utility/Log.h" #include "../../Utility/Utils.h" @@ -66,6 +67,16 @@ bool ReloadableText::update(float dt) lastFileReloadTime_ = now; } } + else if (type_ == "trackInfo") + { + // Get the MusicPlayer instance + MusicPlayer* musicPlayer = MusicPlayer::getInstance(); + + // Check if the music player exists and if the track has changed + if (musicPlayer && (musicPlayer->hasTrackChanged())) { + ReloadTexture(); + } + } // needs to be ran at the end to prevent the NewItemSelected flag from being detected return Component::update(dt); } @@ -177,6 +188,30 @@ void ReloadableText::ReloadTexture() return; } } + else if (type_ == "trackInfo") { + MusicPlayer const* musicPlayer = MusicPlayer::getInstance(); + + if (!musicPlayer || musicPlayer->getCurrentTrackName().empty()) { + text = ""; + } + else { + // Simply get the current information - no need to compare with previous state + std::string currentArtist = musicPlayer->getCurrentArtist(); + std::string currentTitle = musicPlayer->getCurrentTitle(); + + // Format the display text + if (!currentArtist.empty() && !currentTitle.empty()) { + text = currentArtist + " - " + currentTitle; + } + else if (!currentTitle.empty()) { + text = currentTitle; + } + else { + // Fallback to track name + text = musicPlayer->getCurrentTrackName(); + } + } + } else if (type_ == "time") { // If timeFormat_ is undefined, assign a reasonable default if (timeFormat_.empty()) { @@ -427,30 +462,33 @@ void ReloadableText::ReloadTexture() ss << text; } - // Update the tracked attributes + const std::string newText = ss.str(); + bool typeChanged = (currentType_ != type_); - bool valueChanged = (currentValue_ != ss.str()); + bool valueChanged = (currentValue_ != newText); - if (!typeChanged && !valueChanged && imageInst_ != nullptr) - { - // No changes and the image instance already exists, so no need to recreate it + currentType_ = type_; + currentValue_ = newText; + + if (!typeChanged && !valueChanged && imageInst_ != nullptr) { return; } - // Delete the old component if a new one is required or if it's missing - if (imageInst_ != nullptr) - { + if (imageInst_) { + if (!typeChanged && valueChanged) { + // Only the text changed — reuse component + imageInst_->setText(newText); + return; + } + + // Type changed or reallocation needed + imageInst_->freeGraphicsMemory(); delete imageInst_; imageInst_ = nullptr; } - currentType_ = type_; - currentValue_ = ss.str(); - - // Create a new image instance - if (!ss.str().empty()) - { - imageInst_ = new Text(ss.str(), page, fontInst_, baseViewInfo.Monitor); + if (!newText.empty()) { + imageInst_ = new Text(newText, page, fontInst_, baseViewInfo.Monitor); } } diff --git a/RetroFE/Source/Graphics/Component/VideoComponent.cpp b/RetroFE/Source/Graphics/Component/VideoComponent.cpp index a2dab972e..1b524ad3a 100644 --- a/RetroFE/Source/Graphics/Component/VideoComponent.cpp +++ b/RetroFE/Source/Graphics/Component/VideoComponent.cpp @@ -62,30 +62,15 @@ bool VideoComponent::update(float dt) { return Component::update(dt); } - if ((currentPage_->getIsLaunched() && baseViewInfo.Monitor == 0)) { - if (videoInst_->isPaused()) { - videoInst_->pause(); // Ensure paused during launch - } + videoInst_->messageHandler(dt); + + if (videoInst_->hasError()) { return Component::update(dt); } - videoInst_->messageHandler(dt); - - // Check for errors first - bool hasError = videoInst_->hasError(); - if (hasError) { - LOG_DEBUG("VideoComponent", "Detected error in video instance for " + - Utils::getFileName(videoFile_) + ", destroying and creating new instance"); - instanceReady_ = false; - videoInst_.reset(); - videoInst_ = VideoFactory::createVideo(monitor_, numLoops_, softOverlay_, listId_, perspectiveCorners_); - if (videoInst_) { - instanceReady_ = videoInst_->play(videoFile_); - dimensionsUpdated_ = false; // Reset flag for new instance - if (!instanceReady_) { - LOG_ERROR("VideoComponent", "Failed to start playback with new instance: " + - Utils::getFileName(videoFile_)); - } + if ((currentPage_->getIsLaunched() && baseViewInfo.Monitor == 0)) { + if (videoInst_->isPaused()) { + videoInst_->pause(); // Ensure paused during launch } return Component::update(dt); } @@ -114,8 +99,8 @@ bool VideoComponent::update(float dt) { dimensionsUpdated_ = true; // Mark dimensions as updated LOG_DEBUG("VideoComponent", "Updated video dimensions: " + - std::to_string(videoWidth) + "x" + std::to_string(videoHeight) + - " for " + Utils::getFileName(videoFile_)); + std::to_string(static_cast(videoWidth)) + "x" + std::to_string(static_cast(videoHeight)) + + " for " + videoFile_); } } @@ -125,13 +110,13 @@ bool VideoComponent::update(float dt) { } if (baseViewInfo.PauseOnScroll) { - if (!isCurrentlyVisible && !isPaused && !currentPage_->isMenuFastScrolling()) { + if (!isCurrentlyVisible && !isPaused) { pause(); - LOG_DEBUG("VideoComponent", "Paused " + Utils::getFileName(videoFile_)); + LOG_DEBUG("VideoComponent", "Paused " + videoFile_); } else if (isCurrentlyVisible && isPaused) { pause(); - LOG_DEBUG("VideoComponent", "Resumed " + Utils::getFileName(videoFile_)); + LOG_DEBUG("VideoComponent", "Resumed " + videoFile_); } } diff --git a/RetroFE/Source/Graphics/Page.cpp b/RetroFE/Source/Graphics/Page.cpp index f30e0b0c8..d119c73f9 100644 --- a/RetroFE/Source/Graphics/Page.cpp +++ b/RetroFE/Source/Graphics/Page.cpp @@ -1429,7 +1429,7 @@ void Page::draw() { // Draw all components in the layer for (Component* component : LayerComponents_[i]) { if (!component) { - LOG_WARNING("Page::draw", "Null component in LayerComponents_[" + std::to_string(i) + "]."); + //LOG_WARNING("Page::draw", "Null component in LayerComponents_[" + std::to_string(i) + "]."); continue; } component->draw(); @@ -1445,7 +1445,7 @@ void Page::draw() { for (Component* c : menu->getComponents()) { if (!c) { - LOG_WARNING("Page::draw", "Null component in menu->getComponents()."); + //LOG_WARNING("Page::draw", "Null component in menu->getComponents()."); continue; } if (c->baseViewInfo.Layer == i) { diff --git a/RetroFE/Source/Graphics/PageBuilder.cpp b/RetroFE/Source/Graphics/PageBuilder.cpp index f72f70f3d..2978643ae 100644 --- a/RetroFE/Source/Graphics/PageBuilder.cpp +++ b/RetroFE/Source/Graphics/PageBuilder.cpp @@ -26,6 +26,7 @@ #include "Component/ReloadableHiscores.h" #include "Component/ScrollingList.h" #include "Component/VideoBuilder.h" +#include "Component/MusicPlayerComponent.h" #include "Animate/AnimationEvents.h" #include "Animate/TweenTypes.h" #include "../Sound/Sound.h" @@ -725,6 +726,7 @@ bool PageBuilder::buildComponents(xml_node<>* layout, Page* page, const std::str loadReloadableImages(layout, "reloadableText", page); loadReloadableImages(layout, "reloadableScrollingText", page); loadReloadableImages(layout, "reloadableHiscores", page); + loadReloadableImages(layout, "musicPlayer", page); return true; } @@ -862,6 +864,16 @@ void PageBuilder::loadReloadableImages(const xml_node<>* layout, const std::stri excludedColumns, baseColumnPadding, baseRowPadding, maxRows); } + else if (tagName == "musicPlayer") { + std::string typeValue = type->value(); + + // Create font for text-based music player components + FontManager* font = addFont(componentXml, nullptr, cMonitor); + + // Create MusicPlayerComponent with common mode enabled + c = new MusicPlayerComponent(config_, true, typeValue, *page, cMonitor, font); + } + else { xml_attribute<> const* jukeboxXml = componentXml->first_attribute("jukebox"); bool jukebox = (jukeboxXml && Utils::toLower(jukeboxXml->value()) == "true"); diff --git a/RetroFE/Source/RetroFE.cpp b/RetroFE/Source/RetroFE.cpp index 2ebf66cd1..e16c13a73 100644 --- a/RetroFE/Source/RetroFE.cpp +++ b/RetroFE/Source/RetroFE.cpp @@ -27,6 +27,7 @@ #include "Graphics/Component/ScrollingList.h" #include "Graphics/Page.h" #include "Graphics/PageBuilder.h" +#include "Sound/MusicPlayer.h" #include "Menu/Menu.h" #include "SDL.h" #include "Utility/Log.h" @@ -41,12 +42,13 @@ #include #include #include +#include #if __has_include() -#include + #include #elif __has_include() -#include + #include #else -#error "Cannot find SDL_ttf header" + #error "Cannot find SDL_ttf header" #endif #if defined(__linux) || defined(__APPLE__) @@ -72,7 +74,7 @@ RetroFE::RetroFE(Configuration& c) : initialized(false), initializeError(false), initializeThread(NULL), config_(c), db_(NULL), metadb_(NULL), input_(config_), currentPage_(NULL), keyInputDisable_(0), currentTime_(0), lastLaunchReturnTime_(0), keyLastTime_(0), keyDelayTime_(.3f), reboot_(false), kioskLock_(false), paused_(false), buildInfo_(false), - collectionInfo_(false), gameInfo_(false) + collectionInfo_(false), gameInfo_(false), musicPlayer_(nullptr) { menuMode_ = false; attractMode_ = false; @@ -180,6 +182,9 @@ int RetroFE::initialize(void* context) return -1; } + instance->initializeMusicPlayer(); + + // Initialize HiScores std::string zipPath = Utils::combinePath(Configuration::absolutePath, "hi2txt", "hi2txt_defaults.zip"); std::string overridePath = Utils::combinePath(Configuration::absolutePath, "hi2txt", "scores"); @@ -190,24 +195,80 @@ int RetroFE::initialize(void* context) return 0; } +void RetroFE::initializeMusicPlayer() +{ + // Initialize music player + bool musicPlayerEnabled = false; + config_.getProperty("musicPlayer.enabled", musicPlayerEnabled); + if (musicPlayerEnabled) + { + if(Mix_Init(MIX_INIT_MP3) != 8){ + LOG_ERROR("MusicPlayer", "Failed to initialize SDL_mixer for MP3 support"); + } + else + { + LOG_INFO("MusicPlayer", "SDL_mixer initialized for MP3 support"); + } + musicPlayer_ = MusicPlayer::getInstance(); + if (!musicPlayer_->initialize(config_)) + { + LOG_ERROR("RetroFE", "Failed to initialize music player"); + } + else + { + LOG_INFO("RetroFE", "Music player initialized successfully"); + } + } + else { + LOG_INFO("RetroFE", "Music player disabled by configuration"); + } +} + // Launch a game/program void RetroFE::launchEnter() { currentPage_->setIsLaunched(true); // Disable window focus SDL_SetWindowGrab(SDL::getWindow(0), SDL_FALSE); - // Free the textures, and take down SDL if unloadSDL flag is set + // Free textures and shut down SDL if unloadSDL flag is set bool unloadSDL = false; config_.getProperty(OPTION_UNLOADSDL, unloadSDL); if (unloadSDL) { freeGraphicsMemory(); } - // If on MacOS disable relative mouse mode to handoff mouse to game/program #ifdef __APPLE__ SDL_SetRelativeMouseMode(SDL_FALSE); #endif + if (musicPlayer_) { + bool musicPlayerPlayInGame = false; + config_.getProperty("musicPlayer.playInGame", musicPlayerPlayInGame); + if (musicPlayerPlayInGame) + { + int musicPlayerPlayInGameVol = -1; + if (config_.getProperty("musicPlayer.playInGameVol", musicPlayerPlayInGameVol)) + { + // Only proceed if the value is in the valid range (0�100). + if (musicPlayerPlayInGameVol >= 0 && musicPlayerPlayInGameVol <= 100) + { + // Get current volume (0�128) and convert to percentage (0�100) + int currentVolume = musicPlayer_->getVolume(); + int currentVolumePercent = static_cast((currentVolume / static_cast(MIX_MAX_VOLUME)) * 100.0f + 0.5f); + + // Only perform the fade if the current volume is greater than or equal to the target. + if (currentVolumePercent >= musicPlayerPlayInGameVol) { + musicPlayer_->fadeToVolume(musicPlayerPlayInGameVol); + } + // Otherwise, no fade is performed. + } + } + } + else + { + musicPlayer_->pauseMusic(); + } + } #ifdef WIN32 Utils::postMessage("MediaplayerHiddenWindow", 0x8001, 75, 0); #endif @@ -217,7 +278,6 @@ void RetroFE::launchEnter() void RetroFE::launchExit() { currentPage_->setIsLaunched(false); - // Set up SDL, and load the textures if unloadSDL flag is set bool unloadSDL = false; config_.getProperty(OPTION_UNLOADSDL, unloadSDL); if (unloadSDL) @@ -225,12 +285,10 @@ void RetroFE::launchExit() allocateGraphicsMemory(); } - // Restore the SDL settings SDL_RestoreWindow(SDL::getWindow(0)); SDL_RaiseWindow(SDL::getWindow(0)); SDL_SetWindowGrab(SDL::getWindow(0), SDL_TRUE); - // Empty event queue, but handle joystick add/remove events SDL_Event e; while (SDL_PollEvent(&e)) { @@ -240,30 +298,51 @@ void RetroFE::launchExit() } } input_.resetStates(); - //attract_.reset(); currentPage_->updateReloadables(0); currentPage_->onNewItemSelected(); - currentPage_->reallocateMenuSpritePoints(false); // skip updating playlist menu + currentPage_->reallocateMenuSpritePoints(false); - // Restore time settings currentTime_ = static_cast(SDL_GetTicks()) / 1000; keyLastTime_ = currentTime_; lastLaunchReturnTime_ = currentTime_; #ifndef __APPLE__ - // If not MacOS, warp cursor top right. game/program may warp elsewhere SDL_WarpMouseInWindow(SDL::getWindow(0), SDL::getWindowWidth(0), 0); #endif + bool musicPlayerPlayInGame = false; + config_.getProperty("musicPlayer.playInGame", musicPlayerPlayInGame); + if (musicPlayer_ && musicPlayerPlayInGame) + { + int musicPlayerPlayInGameVol = -1; + if (config_.getProperty("musicPlayer.playInGameVol", musicPlayerPlayInGameVol)) + { + if (musicPlayerPlayInGameVol >= 0 && musicPlayerPlayInGameVol <= 100) + { + // Convert the target volume from percentage to MIX's range (0�128) + int targetMixVolume = static_cast((musicPlayerPlayInGameVol / 100.0f) * MIX_MAX_VOLUME + 0.5f); + // Allow for a small rounding tolerance + if (std::abs(musicPlayer_->getVolume() - targetMixVolume) <= 1) + { + // Restore the previous volume that was stored when fadeToVolume was called. + musicPlayer_->fadeBackToPreviousVolume(); + } + // Otherwise, do nothing (i.e. no fade-back is needed). + } + } + } + else if (musicPlayer_ && !musicPlayerPlayInGame) + { + musicPlayer_->resumeMusic(); + } + #ifdef WIN32 Utils::postMessage("MediaplayerHiddenWindow", 0x8001, 76, 0); #endif - // If on MacOS enable relative mouse mode #ifdef __APPLE__ SDL_SetRelativeMouseMode(SDL_TRUE); #endif } - // Free the textures, and optionall take down SDL void RetroFE::freeGraphicsMemory() { @@ -342,6 +421,11 @@ bool RetroFE::deInitialize() db_ = nullptr; } + if (musicPlayer_) + { + musicPlayer_->shutdown(); + } + initialized = false; if (reboot_) @@ -487,7 +571,7 @@ bool RetroFE::run() config_.getProperty(OPTION_ATTRACTMODELAUNCHMINMAXSCROLLS, attractModeLaunchMinMaxScrolls); std::vector attMinMaxVec; Utils::listToVector(attractModeLaunchMinMaxScrolls, attMinMaxVec, ','); - + attract_.idleTime = static_cast(attractModeTime); attract_.idleNextTime = static_cast(attractModeNextTime); attract_.idlePlaylistTime = static_cast(attractModePlaylistTime); @@ -753,6 +837,27 @@ bool RetroFE::run() currentPage_->reallocateMenuSpritePoints(); // Update playlist menu splashMode = false; + + if (musicPlayer_) { + // Check if music should auto-start + bool autoStart = false; + if (config_.getProperty("musicPlayer.autostart", autoStart) && autoStart) + { + LOG_INFO("RetroFE", "Auto-starting music player"); + bool shuffle = true; + config_.getProperty("musicPlayer.shuffle", shuffle); + + if (shuffle) + { + musicPlayer_->shuffle(); + } + else + { + musicPlayer_->playMusic(0); // Start with first track + } + } + } + state = RETROFE_LOAD_ART; } break; @@ -1154,7 +1259,7 @@ bool RetroFE::run() lastMenuPlaylists_[collectionName] = currentPage_->getPlaylistName(); } config_.setProperty("lastCollection", collectionName); - + state = RETROFE_PLAYLIST_REQUEST; if (quickListCollection != "" && quickListCollection != collectionName) { @@ -2091,7 +2196,8 @@ bool RetroFE::run() currentPage_->allocateGraphicsMemory(); currentPage_->setLocked(kioskLock_); - } else { + } + else { // Same layout case - just pop collection if (!currentPage_->popCollection()) { LOG_ERROR("RetroFE", "Failed to pop collection during back navigation"); @@ -2110,7 +2216,8 @@ bool RetroFE::run() if (std::string settingPrefix = "collections." + currentPage_->getCollectionName() + "."; config_.propertyExists(settingPrefix + OPTION_AUTOPLAYLIST)) { config_.getProperty(settingPrefix + OPTION_AUTOPLAYLIST, autoPlaylist); - } else { + } + else { config_.getProperty(OPTION_AUTOPLAYLIST, autoPlaylist); } @@ -2122,7 +2229,7 @@ bool RetroFE::run() // Check if we should return to remembered playlist bool rememberMenu = false; config_.getProperty(OPTION_REMEMBERMENU, rememberMenu); - bool returnToRememberedPlaylist = rememberMenu && + bool returnToRememberedPlaylist = rememberMenu && lastMenuPlaylists_.find(collectionName) != lastMenuPlaylists_.end(); // Set appropriate playlist @@ -2133,7 +2240,8 @@ bool RetroFE::run() if (lastMenuOffsets_.find(collectionName) != lastMenuOffsets_.end()) { currentPage_->setScrollOffsetIndex(lastMenuOffsets_[collectionName]); } - } else { + } + else { // Use auto playlist with fallback to "all" currentPage_->selectPlaylist(autoPlaylist); if (currentPage_->getPlaylistName() != autoPlaylist) { @@ -2507,6 +2615,72 @@ void RetroFE::goToNextAttractModePlaylistByCycle(std::vector cycleV } } +// Add this function implementation to RetroFE.cpp +void RetroFE::handleMusicControls(UserInput::KeyCode_E input) +{ + if (!musicPlayer_) { + return; + } + switch (input) + { + case UserInput::KeyCodeMusicPlayPause: + if (musicPlayer_->isPlaying()) + { + musicPlayer_->pauseMusic(); + } + else if (musicPlayer_->isPaused()) + { + musicPlayer_->resumeMusic(); + } + else + { + musicPlayer_->playMusic(); + } + // Reset attract mode + attract_.reset(); + break; + + case UserInput::KeyCodeMusicNext: + musicPlayer_->nextTrack(); + // Reset attract mode + attract_.reset(); + break; + + case UserInput::KeyCodeMusicPrev: + musicPlayer_->previousTrack(); + // Reset attract mode + attract_.reset(); + break; + + case UserInput::KeyCodeMusicVolumeUp: + { + musicPlayer_->changeVolume(true); + // Reset attract mode + attract_.reset(); + } + break; + + case UserInput::KeyCodeMusicVolumeDown: + { + musicPlayer_->changeVolume(false); + // Reset attract mode + attract_.reset(); + } + break; + + case UserInput::KeyCodeMusicToggleShuffle: + musicPlayer_->setShuffle(!musicPlayer_->getShuffle()); + break; + + case UserInput::KeyCodeMusicToggleLoop: + musicPlayer_->setLoop(!musicPlayer_->getLoop()); + break; + + default: + break; // Do nothing for other key codes + } +} + // Process the user input RetroFE::RETROFE_STATE RetroFE::processUserInput(Page* page) { @@ -2537,6 +2711,8 @@ RetroFE::RETROFE_STATE RetroFE::processUserInput(Page* page) } } + + if (screensaver && ssExitInputs[e.type]) { #ifdef WIN32 @@ -2657,9 +2833,53 @@ RetroFE::RETROFE_STATE RetroFE::processUserInput(Page* page) } } + if (input_.keystate(UserInput::KeyCodeMusicVolumeUp)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicVolumeUp); + return state; + } + else if (input_.keystate(UserInput::KeyCodeMusicVolumeDown)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicVolumeDown); + return state; + } + // don't wait for idle if (currentTime_ - keyLastTime_ > keyDelayTime_) { + if (input_.keystate(UserInput::KeyCodeMusicPlayPause)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicPlayPause); + return state; + } + else if (input_.keystate(UserInput::KeyCodeMusicNext)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicNext); + return state; + } + else if (input_.keystate(UserInput::KeyCodeMusicPrev)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicPrev); + return state; + } + else if (input_.keystate(UserInput::KeyCodeMusicToggleShuffle)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicToggleShuffle); + return state; + } + else if (input_.keystate(UserInput::KeyCodeMusicToggleLoop)) + { + keyLastTime_ = currentTime_; + handleMusicControls(UserInput::KeyCodeMusicToggleLoop); + return state; + } + // lock or unlock playlist/collection/menu nav and fav toggle if (page->isIdle() && input_.keystate(UserInput::KeyCodeKisok)) { @@ -3173,6 +3393,7 @@ RetroFE::RETROFE_STATE RetroFE::processUserInput(Page* page) } } + if (state != RETROFE_IDLE) { keyLastTime_ = currentTime_; @@ -3500,4 +3721,4 @@ void RetroFE::resetInfoToggle() MetadataDatabase* RetroFE::getMetaDb() { return metadb_; -} \ No newline at end of file +} diff --git a/RetroFE/Source/RetroFE.h b/RetroFE/Source/RetroFE.h index 26929eed4..d687340f1 100644 --- a/RetroFE/Source/RetroFE.h +++ b/RetroFE/Source/RetroFE.h @@ -25,6 +25,7 @@ #include "Video/IVideo.h" #include "Video/VideoFactory.h" #include "Video/GStreamerVideo.h" +#include "Sound/MusicPlayer.h" #include #if __has_include() #include @@ -75,6 +76,8 @@ class RetroFE SDL_Thread *initializeThread; static int initialize( void *context ); + void initializeMusicPlayer(); + enum RETROFE_STATE { RETROFE_IDLE, @@ -155,6 +158,7 @@ class RetroFE bool isStandalonePlaylist(std::string playlist); bool isInAttractModeSkipPlaylist(std::string playlist); void goToNextAttractModePlaylistByCycle(std::vector cycleVector); + void handleMusicControls(UserInput::KeyCode_E input); void quit( ); Page *loadPage(const std::string& collectionName); Page *loadSplashPage( ); @@ -176,6 +180,7 @@ class RetroFE MetadataDatabase *metadb_; UserInput input_; Page *currentPage_; + MusicPlayer* musicPlayer_; std::stack pages_; float keyInputDisable_; diff --git a/RetroFE/Source/SDL.cpp b/RetroFE/Source/SDL.cpp index a2140cd75..d542f07f2 100644 --- a/RetroFE/Source/SDL.cpp +++ b/RetroFE/Source/SDL.cpp @@ -48,7 +48,7 @@ bool SDL::initialize(Configuration& config) { int audioRate = MIX_DEFAULT_FREQUENCY; Uint16 audioFormat = MIX_DEFAULT_FORMAT; /* 16-bit stereo */ - int audioChannels = 1; + int audioChannels = 2; int audioBuffers = 4096; bool hideMouse; diff --git a/RetroFE/Source/Sound/MusicPlayer.cpp b/RetroFE/Source/Sound/MusicPlayer.cpp new file mode 100644 index 000000000..adc0ee063 --- /dev/null +++ b/RetroFE/Source/Sound/MusicPlayer.cpp @@ -0,0 +1,1933 @@ +/* This file is part of RetroFE. + * + * RetroFE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * RetroFE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with RetroFE. If not, see . + */ + +#include "MusicPlayer.h" +#include "../Utility/Log.h" +#include "../Utility/Utils.h" +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +MusicPlayer* MusicPlayer::instance_ = nullptr; + +MusicPlayer* MusicPlayer::getInstance() +{ + if (!instance_) + { + instance_ = new MusicPlayer(); + } + return instance_; +} + +MusicPlayer::MusicPlayer() + : config_(nullptr) + , currentMusic_(nullptr) + , musicFiles_() // default empty vector + , musicNames_() // default empty vector + , shuffledIndices_() // default empty vector + , currentShufflePos_(-1) + , currentIndex_(-1) + , volume_(MIX_MAX_VOLUME) + , logicalVolume_(volume_) + , loopMode_(false) + , shuffleMode_(false) + , isShuttingDown_(false) + , rng_() // will be seeded below + , isPendingPause_(false) + , pausedMusicPosition_(0.0) + , isPendingTrackChange_(false) + , pendingTrackIndex_(-1) + , fadeMs_(1500) + , previousVolume_(volume_) + , buttonPressed_(false) + , lastCheckedTrackPath_("") + , hasStartedPlaying_(false) + , lastVolumeChangeTime_(0) + , volumeChangeIntervalMs_(0) + , audioLevels_() + , audioChannels_(2) // Default to stereo + , hasVisualizer_(false) + , sampleSize_(2) // Default to 16-bit samples +{ + // Seed the random number generator with current time + uint64_t seed = SDL_GetTicks64(); + std::seed_seq seq{ + static_cast(seed & 0xFFFFFFFF), + static_cast((seed >> 32) & 0xFFFFFFFF) + }; + rng_.seed(seq); + + audioLevels_.resize(audioChannels_, 0.0f); + +} + +MusicPlayer::~MusicPlayer() +{ + isShuttingDown_ = true; + stopMusic(); + if (currentMusic_) + { + Mix_FreeMusic(currentMusic_); + currentMusic_ = nullptr; + } +} + +bool MusicPlayer::initialize(Configuration& config) +{ + this->config_ = &config; + + // Get volume from config if available + int configVolume; + if (config.getProperty("musicPlayer.volume", configVolume)) + { + configVolume = std::max(0, std::min(100, configVolume)); + // Convert from percentage (0-100) to internal volume (0-128) + volume_ = static_cast((configVolume / 100.0f) * MIX_MAX_VOLUME + 0.5f); + } + + // Set the music callback for handling when music finishes + Mix_HookMusicFinished(MusicPlayer::musicFinishedCallback); + + // Set music volume + Mix_VolumeMusic(volume_); + + // Get loop mode from config + bool configLoop; + if (config.getProperty("musicPlayer.loop", configLoop)) + { + loopMode_ = configLoop; + } + + // Get shuffle mode from config + bool configShuffle; + if (config.getProperty("musicPlayer.shuffle", configShuffle)) + { + shuffleMode_ = configShuffle; + } + + int configFadeMs; + if (config.getProperty("musicPlayer.fadeMs", configFadeMs)) + { + fadeMs_ = std::max(0, configFadeMs); + } + + // --- New Code: Get user-defined volume delay --- + int configVolumeDelay; + if (config.getProperty("musicPlayer.volumeDelay", configVolumeDelay)) + { + // Clamp to range 0 - 50 milliseconds. + volumeChangeIntervalMs_ = std::max(0, std::min(50, configVolumeDelay)); + } + // -------------------------------------------------- + + // First check if an M3U playlist is specified + std::string m3uPlaylist; + if (config.getProperty("musicPlayer.m3uplaylist", m3uPlaylist)) + { + // If the path is relative, resolve it against RetroFE's path + if (!fs::path(m3uPlaylist).is_absolute()) + { + m3uPlaylist = Utils::combinePath(Configuration::absolutePath, m3uPlaylist); + } + + if (loadM3UPlaylist(m3uPlaylist)) + { + LOG_INFO("MusicPlayer", "Initialized with M3U playlist: " + m3uPlaylist); + } + else + { + LOG_WARNING("MusicPlayer", "Failed to load M3U playlist: " + m3uPlaylist + ". Falling back to folder loading."); + // Fall back to folder loading if playlist loading fails + loadMusicFolderFromConfig(); + } + } + else + { + // No M3U playlist specified, use folder loading + loadMusicFolderFromConfig(); + } + + LOG_INFO("MusicPlayer", "Initialized with volume: " + std::to_string(volume_) + + ", loop: " + std::to_string(loopMode_) + + ", shuffle: " + std::to_string(shuffleMode_) + + ", fade: " + std::to_string(fadeMs_) + "ms" + + ", tracks found: " + std::to_string(musicFiles_.size())); + + return true; +} + +bool MusicPlayer::registerVisualizerCallback() +{ + if (hasVisualizer_) { + return true; // Already registered + } + + // Register our post-mix callback + Mix_SetPostMix(MusicPlayer::postMixCallback, this); + hasVisualizer_ = true; + + // Get format info from currently open audio device + int frequency; + Uint16 format; + int channels; + if (Mix_QuerySpec(&frequency, &format, &channels) == 1) { + audioChannels_ = channels; + + // Determine sample size based on format + if (format == AUDIO_U8 || format == AUDIO_S8) { + sampleSize_ = 1; // 8-bit samples + } + else if (format == AUDIO_U16LSB || format == AUDIO_S16LSB || + format == AUDIO_U16MSB || format == AUDIO_S16MSB) { + sampleSize_ = 2; // 16-bit samples + } + else { + sampleSize_ = 4; // Assume 32-bit for other formats + } + + // Resize audio levels array based on channels + audioLevels_.resize(audioChannels_, 0.0f); + } + + LOG_INFO("MusicPlayer", "Visualizer registered with " + std::to_string(audioChannels_) + + " channels and " + std::to_string(sampleSize_ * 8) + "-bit samples"); + + return true; +} + +void MusicPlayer::unregisterVisualizerCallback() +{ + if (!hasVisualizer_) { + return; // Not registered + } + + // Unregister the callback + Mix_SetPostMix(nullptr, nullptr); + hasVisualizer_ = false; + + // Reset audio levels + std::fill(audioLevels_.begin(), audioLevels_.end(), 0.0f); + + LOG_INFO("MusicPlayer", "Visualizer unregistered"); +} + +void MusicPlayer::postMixCallback(void* udata, Uint8* stream, int len) +{ + // This is a static callback, so we need to get the instance + if (udata) { + MusicPlayer* player = static_cast(udata); + player->processAudioData(stream, len); + } +} + +void MusicPlayer::processAudioData(Uint8* stream, int len) +{ + if (!hasVisualizer_ || !stream || len <= 0) { + return; + } + + // Reset audio levels + std::fill(audioLevels_.begin(), audioLevels_.end(), 0.0f); + + // Number of samples per channel + int samplesPerChannel = len / (sampleSize_ * audioChannels_); + if (samplesPerChannel <= 0) { + return; + } + + // Process each channel + for (int channel = 0; channel < audioChannels_; ++channel) { + float sum = 0.0f; + + // Process samples for this channel + for (int i = 0; i < samplesPerChannel; ++i) { + // Calculate position in the stream for this sample and channel + int samplePos = (i * audioChannels_ + channel) * sampleSize_; + + // Make sure we're within bounds + if (samplePos + sampleSize_ > len) { + break; + } + + // Get sample value based on format + float sampleValue = 0.0f; + + if (sampleSize_ == 1) { + // 8-bit sample (0-255, center at 128) + Uint8 val = stream[samplePos]; + sampleValue = (static_cast(val) - 128.0f) / 128.0f; + } + else if (sampleSize_ == 2) { + // 16-bit sample (-32768 to 32767) + Sint16 val = *reinterpret_cast(stream + samplePos); + sampleValue = static_cast(val) / 32768.0f; + } + else if (sampleSize_ == 4) { + // 32-bit sample (float -1.0 to 1.0) + float val = *reinterpret_cast(stream + samplePos); + sampleValue = val; + } + + // Accumulate absolute value for RMS calculation + sum += sampleValue * sampleValue; + } + + // Calculate RMS (Root Mean Square) value for this channel + float rms = std::sqrt(sum / samplesPerChannel); + + // Store normalized level (0.0 - 1.0) + audioLevels_[channel] = std::min(1.0f, rms); + } +} + +// Helper method to extract the folder loading logic +void MusicPlayer::loadMusicFolderFromConfig() +{ + std::string musicFolder; + if (config_ && config_->getProperty("musicPlayer.folder", musicFolder)) + { + loadMusicFolder(musicFolder); + } + else + { + // Default to a music directory in RetroFE's path + loadMusicFolder(Utils::combinePath(Configuration::absolutePath, "music")); + } +} + +bool MusicPlayer::loadMusicFolder(const std::string& folderPath) +{ + // Clear existing music files + musicFiles_.clear(); + musicNames_.clear(); + trackMetadata_.clear(); + + LOG_INFO("MusicPlayer", "Loading music from folder: " + folderPath); + + try + { + if (!fs::exists(folderPath)) + { + LOG_WARNING("MusicPlayer", "Music folder doesn't exist: " + folderPath); + return false; + } + + std::vector> musicEntries; + + for (const auto& entry : fs::directory_iterator(folderPath)) + { + if (entry.is_regular_file()) + { + std::string ext = entry.path().extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + if (ext == ".mp3" || ext == ".ogg" || ext == ".wav" || ext == ".flac" || ext == ".mod") + { + std::string filePath = entry.path().string(); + std::string fileName = entry.path().filename().string(); + + TrackMetadata metadata; + readTrackMetadata(filePath, metadata); + + musicEntries.push_back(std::make_tuple(filePath, fileName, metadata)); + } + } + } + + // Sort entries - can sort by metadata fields if needed + std::sort(musicEntries.begin(), musicEntries.end(), + [](const auto& a, const auto& b) { + return std::get<1>(a) < std::get<1>(b); + }); + + // Unpack sorted entries + for (const auto& entry : musicEntries) + { + musicFiles_.push_back(std::get<0>(entry)); + musicNames_.push_back(std::get<1>(entry)); + trackMetadata_.push_back(std::get<2>(entry)); + } + + LOG_INFO("MusicPlayer", "Found " + std::to_string(musicFiles_.size()) + " music files"); + } + catch (const std::exception& e) + { + LOG_ERROR("MusicPlayer", "Error scanning music directory: " + std::string(e.what())); + return false; + } + + return !musicFiles_.empty(); +} + +bool MusicPlayer::loadM3UPlaylist(const std::string& playlistPath) +{ + // Clear existing music files + musicFiles_.clear(); + musicNames_.clear(); + trackMetadata_.clear(); + + LOG_INFO("MusicPlayer", "Loading music from M3U playlist: " + playlistPath); + + if (!parseM3UFile(playlistPath)) + { + LOG_ERROR("MusicPlayer", "Failed to parse M3U playlist: " + playlistPath); + return false; + } + + LOG_INFO("MusicPlayer", "Found " + std::to_string(musicFiles_.size()) + " music files in playlist"); + return !musicFiles_.empty(); +} + +bool MusicPlayer::parseM3UFile(const std::string& playlistPath) +{ + try + { + if (!fs::exists(playlistPath)) + { + LOG_WARNING("MusicPlayer", "M3U playlist file doesn't exist: " + playlistPath); + return false; + } + + std::ifstream playlistFile(playlistPath); + if (!playlistFile.is_open()) + { + LOG_ERROR("MusicPlayer", "Failed to open M3U playlist: " + playlistPath); + return false; + } + + // Get the directory of the playlist for resolving relative paths + fs::path playlistDir = fs::path(playlistPath).parent_path(); + std::string line; + std::vector> musicEntries; + + while (std::getline(playlistFile, line)) + { + // Skip empty lines and comments (lines starting with #) + if (line.empty() || line[0] == '#') + { + // Some M3U files use #EXTINF for track info, but we'll ignore that for now + continue; + } + + // Process the file path + fs::path trackPath = line; + + // If the path is relative, resolve it against the playlist directory + if (!trackPath.is_absolute()) + { + trackPath = playlistDir / trackPath; + } + + // Convert to string and normalize + std::string filePath = trackPath.string(); + + // Check if the file exists and is a valid audio file + if (fs::exists(filePath) && isValidAudioFile(filePath)) + { + std::string fileName = trackPath.filename().string(); + + TrackMetadata metadata; + readTrackMetadata(filePath, metadata); + + musicEntries.push_back(std::make_tuple(filePath, fileName, metadata)); + } + else + { + LOG_WARNING("MusicPlayer", "Skipping invalid or non-existent track in playlist: " + filePath); + } + } + + // Sort entries (same as in loadMusicFolder) + std::sort(musicEntries.begin(), musicEntries.end(), + [](const auto& a, const auto& b) { + return std::get<1>(a) < std::get<1>(b); + }); + + // Unpack sorted entries + for (const auto& entry : musicEntries) + { + musicFiles_.push_back(std::get<0>(entry)); + musicNames_.push_back(std::get<1>(entry)); + trackMetadata_.push_back(std::get<2>(entry)); + } + + return true; + } + catch (const std::exception& e) + { + LOG_ERROR("MusicPlayer", "Error parsing M3U playlist: " + std::string(e.what())); + return false; + } +} + +bool MusicPlayer::isValidAudioFile(const std::string& filePath) const +{ + std::string ext = fs::path(filePath).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + return (ext == ".mp3" || ext == ".ogg" || ext == ".wav" || ext == ".flac" || ext == ".mod"); +} + +void MusicPlayer::loadTrack(int index) +{ + // Free any currently playing music + if (currentMusic_) + { + Mix_FreeMusic(currentMusic_); + currentMusic_ = nullptr; + } + + if (index < 0 || index >= static_cast(musicFiles_.size())) + { + LOG_ERROR("MusicPlayer", "Invalid track index: " + std::to_string(index)); + currentIndex_ = -1; + return; + } + + // Load the specified track + currentMusic_ = Mix_LoadMUS(musicFiles_[index].c_str()); + if (!currentMusic_) + { + LOG_ERROR("MusicPlayer", "Failed to load music file: " + musicFiles_[index] + ", Error: " + Mix_GetError()); + currentIndex_ = -1; + return; + } + + currentIndex_ = index; + LOG_INFO("MusicPlayer", "Loaded track: " + musicNames_[index]); +} + +bool MusicPlayer::readTrackMetadata(const std::string& filePath, TrackMetadata& metadata) const +{ + // Default to filename without extension as title + std::string fileName = fs::path(filePath).filename().string(); + size_t lastDot = fileName.find_last_of('.'); + if (lastDot != std::string::npos) { + metadata.title = fileName.substr(0, lastDot); + } + else { + metadata.title = fileName; + } + + bool metadataFound = false; + + // Use SDL_mixer to get metadata when available + Mix_Music* music = Mix_LoadMUS(filePath.c_str()); + if (music) { + // Get basic metadata + const char* title = Mix_GetMusicTitle(music); + const char* artist = Mix_GetMusicArtistTag(music); + const char* album = Mix_GetMusicAlbumTag(music); + + if (title && strlen(title) > 0) { + metadata.title = title; + metadataFound = true; + } + + if (artist && strlen(artist) > 0) { + metadata.artist = artist; + metadataFound = true; + } + + if (album && strlen(album) > 0) { + metadata.album = album; + metadataFound = true; + } + + // Try to get additional tag information + // Note: Not all of these functions may be available depending on your SDL_mixer version + // Add conditionals if needed +#if SDL_MIXER_MAJOR_VERSION > 2 || (SDL_MIXER_MAJOR_VERSION == 2 && SDL_MIXER_MINOR_VERSION >= 6) + // SDL_mixer 2.6.0 or newer has more tag functions + const char* copyright = Mix_GetMusicCopyrightTag(music); + if (copyright && strlen(copyright) > 0) { + metadata.comment = copyright; + metadataFound = true; + } +#endif + + Mix_FreeMusic(music); + } + + // If we didn't find any metadata, try to parse the filename for artist - title format + if (!metadataFound && metadata.artist.empty()) { + // Check for common patterns like "Artist - Title" or "Artist_-_Title" + std::string name = metadata.title; + size_t dashPos = name.find(" - "); + if (dashPos != std::string::npos) { + metadata.artist = name.substr(0, dashPos); + metadata.title = name.substr(dashPos + 3); + } + else if ((dashPos = name.find("_-_")) != std::string::npos) { + metadata.artist = name.substr(0, dashPos); + std::replace(metadata.artist.begin(), metadata.artist.end(), '_', ' '); + metadata.title = name.substr(dashPos + 3); + std::replace(metadata.title.begin(), metadata.title.end(), '_', ' '); + } + } + + return true; +} + +const MusicPlayer::TrackMetadata& MusicPlayer::getCurrentTrackMetadata() const +{ + static TrackMetadata emptyMetadata; + + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_]; + } + return emptyMetadata; +} + +const MusicPlayer::TrackMetadata& MusicPlayer::getTrackMetadata(int index) const +{ + static TrackMetadata emptyMetadata; + + if (index >= 0 && index < static_cast(trackMetadata_.size())) { + return trackMetadata_[index]; + } + return emptyMetadata; +} + +size_t MusicPlayer::getTrackMetadataCount() const +{ + return trackMetadata_.size(); +} + +std::string MusicPlayer::getCurrentTitle() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].title; + } + return ""; +} + +std::string MusicPlayer::getCurrentArtist() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].artist; + } + return ""; +} + +std::string MusicPlayer::getCurrentAlbum() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].album; + } + return ""; +} + +std::string MusicPlayer::getCurrentYear() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].year; + } + return ""; +} + +std::string MusicPlayer::getCurrentGenre() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].genre; + } + return ""; +} + +std::string MusicPlayer::getCurrentComment() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].comment; + } + return ""; +} + +int MusicPlayer::getCurrentTrackNumber() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(trackMetadata_.size())) { + return trackMetadata_[currentIndex_].trackNumber; + } + return 0; +} + +std::string MusicPlayer::getFormattedTrackInfo(int index) const +{ + if (index == -1) { + index = currentIndex_; + } + + if (index < 0 || index >= static_cast(trackMetadata_.size())) { + return ""; + } + + const auto& meta = trackMetadata_[index]; + std::string info = meta.title; + + if (!meta.artist.empty()) { + info += " - " + meta.artist; + } + +// if (!meta.album.empty()) { + // info += " (" + meta.album; + // if (!meta.year.empty()) { + // info += ", " + meta.year; + // } + // info += ")"; + //} + + return info; +} + +std::string MusicPlayer::getTrackArtist(int index) const +{ + if (index == -1) { + index = currentIndex_; + } + + if (index < 0 || index >= static_cast(trackMetadata_.size())) { + return ""; + } + + return trackMetadata_[index].artist; +} + +std::string MusicPlayer::getTrackAlbum(int index) const +{ + if (index == -1) { + index = currentIndex_; + } + + if (index < 0 || index >= static_cast(trackMetadata_.size())) { + return ""; + } + + return trackMetadata_[index].album; +} + +bool MusicPlayer::playMusic(int index, int customFadeMs) +{ + // Use default fade if -1 is passed + int useFadeMs = (customFadeMs < 0) ? fadeMs_ : customFadeMs; + + // Validate index + if (index == -1) + { + // Use current or choose default as in your original code + if (currentIndex_ >= 0) + { + index = currentIndex_; + } + else if (shuffleMode_ && !musicFiles_.empty()) + { + if (shuffledIndices_.empty()) + { + setShuffle(true); + } + index = shuffledIndices_[currentShufflePos_]; + } + else if (!musicFiles_.empty()) + { + index = 0; + } + else + { + LOG_WARNING("MusicPlayer", "No music tracks available to play"); + return false; + } + } + + // Check that the index is valid. + if (index < 0 || index >= static_cast(musicFiles_.size())) + { + LOG_ERROR("MusicPlayer", "Invalid track index: " + std::to_string(index)); + return false; + } + + // Clear any pending pause state + isPendingPause_ = false; + + // If music is already playing or fading, fade it out first + if (Mix_PlayingMusic() || Mix_FadingMusic() != MIX_NO_FADING) + { + if (useFadeMs > 0) + { + // Set up for pending track change after fade out + isPendingTrackChange_ = true; + pendingTrackIndex_ = index; + + // Fade out current music + if (Mix_FadeOutMusic(useFadeMs) == 0) + { + LOG_WARNING("MusicPlayer", "Failed to fade out music, stopping immediately"); + Mix_HaltMusic(); + } + else + { + LOG_INFO("MusicPlayer", "Fading out current track before changing to new track"); + return true; // Return true, the actual track change will happen in the callback + } + } + else + { + // No fade, stop immediately + Mix_HaltMusic(); + } + } + + // No fade or music was halted immediately, so load and play the new track + loadTrack(index); + + if (!currentMusic_) + { + isPendingTrackChange_ = false; + return false; + } + + // If shuffle mode is enabled, update the current shuffle position + if (shuffleMode_) + { + auto it = std::find(shuffledIndices_.begin(), shuffledIndices_.end(), index); + if (it != shuffledIndices_.end()) + { + currentShufflePos_ = static_cast(std::distance(shuffledIndices_.begin(), it)); + } + else + { + // If for some reason the track isn't in the current shuffle order, regenerate it. + setShuffle(true); + } + } + + // Play the music with fade-in if specified + int result; + if (useFadeMs > 0) + { + result = Mix_FadeInMusic(currentMusic_, loopMode_ ? -1 : 1, useFadeMs); + LOG_INFO("MusicPlayer", "Fading in track: " + musicNames_[index] + " over " + std::to_string(useFadeMs) + "ms"); + } + else + { + result = Mix_PlayMusic(currentMusic_, loopMode_ ? -1 : 1); + LOG_INFO("MusicPlayer", "Playing track: " + musicNames_[index]); + } + + if (result == -1) + { + LOG_ERROR("MusicPlayer", "Failed to play music: " + std::string(Mix_GetError())); + return false; + } + + setPlaybackState(PlaybackState::PLAYING); + LOG_INFO("MusicPlayer", "Now playing track: " + getFormattedTrackInfo(index)); + isPendingTrackChange_ = false; + + if (!hasStartedPlaying_) + { + hasStartedPlaying_ = true; + } + + return true; +} + +double MusicPlayer::saveCurrentMusicPosition() +{ + if (currentMusic_) + { + // Get the current position in the music in seconds + // If your SDL_mixer version doesn't support this, you'll need to track time manually +#if SDL_MIXER_MAJOR_VERSION > 2 || (SDL_MIXER_MAJOR_VERSION == 2 && SDL_MIXER_MINOR_VERSION >= 6) + return Mix_GetMusicPosition(currentMusic_); +#else +// For older SDL_mixer versions, we can't get the position + return 0.0; +#endif + } + return 0.0; +} + +bool MusicPlayer::pauseMusic(int customFadeMs) +{ + if (!isPlaying() || isPaused() || !Mix_FadingMusic() == MIX_NO_FADING) + { + return false; + } + + // Use default fade if -1 is passed + int useFadeMs = (customFadeMs < 0) ? fadeMs_ : customFadeMs; + + // Save current position before pausing (for possible resume with fade) + pausedMusicPosition_ = saveCurrentMusicPosition(); + + if (useFadeMs > 0) + { + // Set flags to indicate this is a pause operation + isPendingPause_ = true; + isPendingTrackChange_ = false; + pendingTrackIndex_ = -1; + + // Fade out and then pause + if (Mix_FadeOutMusic(useFadeMs) == 0) + { + // Failed to fade out, pause immediately + LOG_WARNING("MusicPlayer", "Failed to fade out before pause, pausing immediately"); + Mix_PauseMusic(); + isPendingPause_ = false; + } + else + { + LOG_INFO("MusicPlayer", "Fading out music before pausing over " + std::to_string(useFadeMs) + "ms"); + // The actual pause will be handled in the musicFinishedCallback + } + } + else + { + // No fade, pause immediately + Mix_PauseMusic(); + LOG_INFO("MusicPlayer", "Music paused"); + } + setPlaybackState(PlaybackState::PAUSED); + return true; +} + +bool MusicPlayer::resumeMusic(int customFadeMs) +{ + if (!Mix_FadingMusic() == MIX_NO_FADING) + return false; + + // Use default fade if -1 is passed + int useFadeMs = (customFadeMs < 0) ? fadeMs_ : customFadeMs; + + // If we're in a paused state after fade-out, we need to load the track and start it + if (isPendingPause_) + { + isPendingPause_ = false; + + // If we have a saved position and the track is still valid + if (pausedMusicPosition_ > 0.0 && currentIndex_ >= 0 && currentIndex_ < static_cast(musicFiles_.size())) + { + // Load the track + loadTrack(currentIndex_); + + if (!currentMusic_) + { + LOG_ERROR("MusicPlayer", "Failed to reload track for resume"); + return false; + } + + // Calculate the adjusted position - add the fade duration in seconds + // This ensures we don't repeat music that was playing during the fade-out + double adjustedPosition = pausedMusicPosition_; + + // Only add the fade time if it was a non-zero fade and if we're not at the beginning + if (fadeMs_ > 0 && pausedMusicPosition_ > 0.0) + { + // Convert fadeMs from milliseconds to seconds and add + adjustedPosition += useFadeMs / 1000.0; + + // Get the music length if possible to avoid going past the end +#if SDL_MIXER_MAJOR_VERSION > 2 || (SDL_MIXER_MAJOR_VERSION == 2 && SDL_MIXER_MINOR_VERSION >= 6) + double musicLength = Mix_MusicDuration(currentMusic_); + // If we have a valid duration and our adjusted position exceeds it + if (musicLength > 0 && adjustedPosition >= musicLength) + { + // If looping is on, wrap around + if (loopMode_) + { + adjustedPosition = std::fmod(adjustedPosition, musicLength); + } + // Otherwise cap at just before the end + else + { + LOG_INFO("MusicPlayer", "Adjusted position would exceed track length, playing next track instead"); + return nextTrack(useFadeMs); + } + } +#endif + } + + // Start playback from the adjusted position with fade-in +#if SDL_MIXER_MAJOR_VERSION > 2 || (SDL_MIXER_MAJOR_VERSION == 2 && SDL_MIXER_MINOR_VERSION >= 6) + if (Mix_FadeInMusicPos(currentMusic_, loopMode_ ? -1 : 1, useFadeMs, adjustedPosition) == -1) + { + LOG_ERROR("MusicPlayer", "Failed to resume music with fade: " + std::string(Mix_GetError())); + return false; + } +#else + if (Mix_FadeInMusic(currentMusic, loopMode ? -1 : 1, useFadeMs) == -1) + { + LOG_ERROR("MusicPlayer", "Failed to resume music with fade: " + std::string(Mix_GetError())); + return false; + } +#endif + + LOG_INFO("MusicPlayer", "Resuming track: " + musicNames_[currentIndex_] + " from adjusted position " + + std::to_string(adjustedPosition) + " (original: " + std::to_string(pausedMusicPosition_) + + ") with " + std::to_string(useFadeMs) + "ms fade"); + setPlaybackState(PlaybackState::PLAYING); + return true; + } + else if (currentIndex_ >= 0 && currentIndex_ < static_cast(musicFiles_.size())) + { + // Just restart the track from the beginning + return playMusic(currentIndex_, useFadeMs); + } + else + { + LOG_ERROR("MusicPlayer", "No valid track to resume"); + return false; + } + } + else if (isPaused()) + { + // Regular pause (not after fade-out), just resume + Mix_ResumeMusic(); + LOG_INFO("MusicPlayer", "Music resumed"); + setPlaybackState(PlaybackState::PLAYING); + return true; + } + + return false; // Nothing to resume +} + +bool MusicPlayer::stopMusic(int customFadeMs) +{ + if (!Mix_PlayingMusic() && !Mix_PausedMusic() && !isPendingPause_) + { + return false; + } + + // Clear any pending pause state + isPendingPause_ = false; + isPendingTrackChange_ = false; + pendingTrackIndex_ = -1; + + // Use default fade if -1 is passed + int useFadeMs = (customFadeMs < 0) ? fadeMs_ : customFadeMs; + + if (useFadeMs > 0 && !isShuttingDown_) + { + // Fade out music + if (Mix_FadeOutMusic(useFadeMs) == 0) + { + // Failed to fade out, stop immediately + LOG_WARNING("MusicPlayer", "Failed to fade out music, stopping immediately"); + Mix_HaltMusic(); + } + else + { + LOG_INFO("MusicPlayer", "Fading out music over " + std::to_string(useFadeMs) + "ms"); + } + } + else + { + // Stop immediately + Mix_HaltMusic(); + LOG_INFO("MusicPlayer", "Music stopped immediately"); + } + + // Reset saved position + pausedMusicPosition_ = 0.0; + + return true; +} + +bool MusicPlayer::nextTrack(int customFadeMs) +{ + if (musicFiles_.empty() || !Mix_FadingMusic() == MIX_NO_FADING) + { + return false; + } + + int nextIndex; + + if (shuffleMode_) + { + // In shuffle mode, move to the next track in the shuffled order + currentShufflePos_ = (currentShufflePos_ + 1) % shuffledIndices_.size(); + nextIndex = shuffledIndices_[currentShufflePos_]; + } + else + { + // In sequential mode, move to the next track in the list + nextIndex = (currentIndex_ + 1) % musicFiles_.size(); + } + setPlaybackState(PlaybackState::NEXT); + return playMusic(nextIndex, customFadeMs); +} + +int MusicPlayer::getNextTrackIndex() +{ + if (shuffleMode_) + { + // In shuffle mode, step forward in the shuffled order. + if (shuffledIndices_.empty()) + return -1; // Safety check + + if (currentShufflePos_ < static_cast(shuffledIndices_.size()) - 1) + { + currentShufflePos_++; + } + else + { + // Option: Loop back to the start (or alternatively, reshuffle). + currentShufflePos_ = 0; + } + return shuffledIndices_[currentShufflePos_]; + } + else + { + // Sequential playback when shuffle is off. + return (currentIndex_ + 1) % musicFiles_.size(); + } +} + +bool MusicPlayer::previousTrack(int customFadeMs) +{ + if (musicFiles_.empty() || !Mix_FadingMusic() == MIX_NO_FADING) + { + return false; + } + + int prevIndex; + + if (shuffleMode_) + { + // In shuffle mode, move to the previous track in the shuffled order + currentShufflePos_ = (currentShufflePos_ - 1 + static_cast(shuffledIndices_.size())) % static_cast(shuffledIndices_.size()); + prevIndex = shuffledIndices_[currentShufflePos_]; + } + else + { + // In sequential mode, move to the previous track in the list + prevIndex = (currentIndex_ - 1 + static_cast(musicFiles_.size())) % static_cast(musicFiles_.size()); + } + setPlaybackState(PlaybackState::PREVIOUS); + return playMusic(prevIndex, customFadeMs); +} + +bool MusicPlayer::isPlaying() const +{ + return Mix_PlayingMusic() == 1 && !Mix_PausedMusic(); +} + +bool MusicPlayer::isPaused() const +{ + return Mix_PausedMusic() == 1 || isPendingPause_; +} + +void MusicPlayer::changeVolume(bool increase) { + Uint64 now = SDL_GetTicks64(); + if (now - lastVolumeChangeTime_ < volumeChangeIntervalMs_) { + // Not enough time has passed since the last change + return; + } + lastVolumeChangeTime_ = now; + + int currentVolume = getLogicalVolume(); + int newVolume; + if (increase) { + newVolume = std::min(128, currentVolume + 1); + } + else { + newVolume = std::max(0, currentVolume - 1); + } + + setLogicalVolume(newVolume); + setButtonPressed(true); // Trigger volume bar update +} + +void MusicPlayer::setVolume(int newVolume) +{ + if (Mix_FadingMusic() != MIX_NO_FADING) + return; + + // Ensure volume is within SDL_Mixer's range (0-128) + volume_ = std::max(0, std::min(MIX_MAX_VOLUME, newVolume)); + Mix_VolumeMusic(volume_); + + // Save to config if available + if (config_) + { + config_->setProperty("musicPlayer.volume", volume_); + } + + LOG_INFO("MusicPlayer", "Volume set to " + std::to_string(volume_)); +} + +void MusicPlayer::setLogicalVolume(int v) { + logicalVolume_ = std::clamp(v, 0, 128); + if (logicalVolume_ == 0) { + Mix_VolumeMusic(0); + return; + } + float normalized = static_cast(logicalVolume_) / 128.0f; + float dB = normalized * 40.0f - 40.0f; + float gain = std::pow(10.0f, dB / 20.0f); + int finalVolume = static_cast(gain * 128.0f + 0.5f); + Mix_VolumeMusic(finalVolume); +} + + +int MusicPlayer::getLogicalVolume() { + return logicalVolume_; +} + + +int MusicPlayer::getVolume() const +{ + return Mix_VolumeMusic(-1); +} + +void MusicPlayer::fadeToVolume(int targetPercent) +{ + // Clamp target percentage between 0 and 100. + targetPercent = std::max(0, std::min(100, targetPercent)); + // Convert percentage to Mix_VolumeMusic range. + int targetVolume = static_cast((targetPercent / 100.0f) * MIX_MAX_VOLUME + 0.5f); + + // Save the current volume (in the 0-128 range) for later restoration. + previousVolume_ = getVolume(); + + // Determine the number of steps for a smooth fade. + const int steps = 50; + int sleepDuration = (fadeMs_ > 0) ? (fadeMs_ / steps) : 0; + + // Launch a detached thread to perform the fade. + std::thread([this, targetVolume, steps, sleepDuration]() { + int startVolume = getVolume(); + for (int i = 0; i <= steps; ++i) + { + // Linear interpolation between startVolume and targetVolume. + float t = static_cast(i) / steps; + int newVolume = static_cast(startVolume + t * (targetVolume - startVolume)); + Mix_VolumeMusic(newVolume); + if (sleepDuration > 0) + { + std::this_thread::sleep_for(std::chrono::milliseconds(sleepDuration)); + } + } + }).detach(); +} + +void MusicPlayer::fadeBackToPreviousVolume() +{ + int targetVolume = previousVolume_; + const int steps = 50; + int sleepDuration = (fadeMs_ > 0) ? (fadeMs_ / steps) : 0; + + std::thread([this, targetVolume, steps, sleepDuration]() { + int startVolume = getVolume(); + for (int i = 0; i <= steps; ++i) + { + float t = static_cast(i) / steps; + int newVolume = static_cast(startVolume + t * (targetVolume - startVolume)); + Mix_VolumeMusic(newVolume); + if (sleepDuration > 0) + { + std::this_thread::sleep_for(std::chrono::milliseconds(sleepDuration)); + } + } + }).detach(); +} + +std::string MusicPlayer::getCurrentTrackName() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(musicNames_.size())) + { + return musicNames_[currentIndex_]; + } + return ""; +} + +std::string MusicPlayer::getCurrentTrackNameWithoutExtension() const +{ + // First get the full filename with extension + std::string fullName; + if (currentIndex_ >= 0 && currentIndex_ < static_cast(musicNames_.size())) + { + fullName = musicNames_[currentIndex_]; + } + else + { + return ""; + } + + // Find the last occurrence of a dot to identify the extension + size_t lastDotPos = fullName.find_last_of('.'); + + // If no dot is found, return the full filename + if (lastDotPos == std::string::npos) + { + return fullName; + } + + // Return everything before the last dot + return fullName.substr(0, lastDotPos); +} + +std::string MusicPlayer::getCurrentTrackPath() const +{ + if (currentIndex_ >= 0 && currentIndex_ < static_cast(musicFiles_.size())) + { + return musicFiles_[currentIndex_]; + } + return ""; +} + +int MusicPlayer::getCurrentTrackIndex() const +{ + return currentIndex_; +} + +int MusicPlayer::getTrackCount() const +{ + return static_cast(musicFiles_.size()); +} + +void MusicPlayer::setLoop(bool loop) +{ + loopMode_ = loop; + + // If music is currently playing, adjust the loop setting + if (isPlaying() && currentMusic_) + { + Mix_HaltMusic(); + Mix_PlayMusic(currentMusic_, loopMode_ ? -1 : 1); + } + + // Save to config if available + if (config_) + { + config_->setProperty("musicPlayer.loop", loopMode_); + } + + LOG_INFO("MusicPlayer", "Loop mode " + std::string(loopMode_ ? "enabled" : "disabled")); +} + +bool MusicPlayer::getLoop() const +{ + return loopMode_; +} + +bool MusicPlayer::shuffle() +{ + if (musicFiles_.empty()) + { + return false; + } + + // Get a random track and play it + std::uniform_int_distribution dist(0, musicFiles_.size() - 1); + auto randomIndex = static_cast(dist(rng_)); + return playMusic(randomIndex); +} + +bool MusicPlayer::setShuffle(bool shuffle) +{ + shuffleMode_ = shuffle; + + if (shuffleMode_) + { + // Build a shuffled order for all tracks. + shuffledIndices_.clear(); + for (int i = 0; i < static_cast(musicFiles_.size()); i++) { + shuffledIndices_.push_back(i); + } + std::shuffle(shuffledIndices_.begin(), shuffledIndices_.end(), rng_); + + // If a track is currently playing, update currentShufflePos to its position in the shuffled list. + if (currentIndex_ >= 0) + { + auto it = std::find(shuffledIndices_.begin(), shuffledIndices_.end(), currentIndex_); + if (it != shuffledIndices_.end()) + currentShufflePos_ = static_cast(std::distance(shuffledIndices_.begin(), it)); + else + currentShufflePos_ = 0; + } + else + { + currentShufflePos_ = 0; + } + } + else + { + // When shuffle is off, clear the shuffle order. + shuffledIndices_.clear(); + currentShufflePos_ = -1; + } + + // Save to config if available. + if (config_) + { + config_->setProperty("musicPlayer.shuffle", shuffleMode_); + } + + LOG_INFO("MusicPlayer", "Shuffle mode " + std::string(shuffleMode_ ? "enabled" : "disabled")); + return true; +} + +bool MusicPlayer::getShuffle() const +{ + return shuffleMode_; +} + +void MusicPlayer::musicFinishedCallback() +{ + // This is a static callback, so we need to get the instance + if (instance_) + { + instance_->onMusicFinished(); + } +} + +void MusicPlayer::onMusicFinished() +{ + // Don't proceed if shutting down + if (isShuttingDown_) + { + return; + } + + // Check if this is a pause operation + if (isPendingPause_) + { + // This was a fade-to-pause operation + Mix_PauseMusic(); // Ensure paused state is set + LOG_INFO("MusicPlayer", "Music paused after fade-out"); + return; // Don't continue to next track + } + + // Check if we're waiting to change tracks after a fade + if (isPendingTrackChange_ && pendingTrackIndex_ >= 0) + { + int indexToPlay = pendingTrackIndex_; + isPendingTrackChange_ = false; + pendingTrackIndex_ = -1; + + LOG_INFO("MusicPlayer", "Playing next track after fade: " + std::to_string(indexToPlay)); + playMusic(indexToPlay, fadeMs_); + return; + } + + // Normal track finished playing + LOG_INFO("MusicPlayer", "Track finished playing: " + getCurrentTrackName()); + + if (!loopMode_) // In loop mode SDL_mixer handles looping internally + { + // Play the next track + nextTrack(); + } +} + +void MusicPlayer::setFadeDuration(int ms) +{ + fadeMs_ = std::max(0, ms); + + // Save to config if available + if (config_) + { + config_->setProperty("musicPlayer.fadeMs", fadeMs_); + } +} + +int MusicPlayer::getFadeDuration() const +{ + return fadeMs_; +} + +void MusicPlayer::resetShutdownFlag() +{ + isShuttingDown_ = false; +} + +void MusicPlayer::shutdown() +{ + LOG_INFO("MusicPlayer", "Shutting down music player"); + + // Set flag first to prevent callbacks + isShuttingDown_ = true; + + if(hasVisualizer_) + unregisterVisualizerCallback(); + + // Stop any playing music + if (Mix_PlayingMusic() || Mix_PausedMusic()) + { + Mix_HaltMusic(); + } + + // Free resources + if (currentMusic_) + { + Mix_FreeMusic(currentMusic_); + currentMusic_ = nullptr; + } + + // Clear playlists + musicFiles_.clear(); + musicNames_.clear(); + + currentIndex_ = -1; + LOG_INFO("MusicPlayer", "Music player shutdown complete"); +} + +bool MusicPlayer::hasTrackChanged() +{ + std::string currentTrackPath = getCurrentTrackPath(); + bool changed = !currentTrackPath.empty() && (currentTrackPath != lastCheckedTrackPath_); + + // Update last checked track + if (changed) { + lastCheckedTrackPath_ = currentTrackPath; + } + + return changed; +} + +bool MusicPlayer::isPlayingNewTrack() +{ + // Only report change if music is actually playing + return isPlaying() && hasTrackChanged(); +} + +static bool extractAlbumArtFromFile(const std::string& filePath, std::vector& albumArtData) { + try { + // Clear the output vector first + albumArtData.clear(); + + std::ifstream file(filePath, std::ios::binary); + if (!file.is_open()) { + LOG_ERROR("MusicPlayer", "Failed to open file: " + filePath); + return false; + } + + // Get file size for validation + file.seekg(0, std::ios::end); + std::streamsize fileSize = file.tellg(); + file.seekg(0, std::ios::beg); + + // Ensure file is large enough for ID3 header + if (fileSize < 10) { + LOG_INFO("MusicPlayer", "File too small to contain ID3 tags: " + filePath); + return false; + } + + // Read the ID3v2 header (10 bytes) + std::vector header; + try { + header.resize(10); + file.read(reinterpret_cast(header.data()), header.size()); + } + catch (const std::bad_alloc& e) { + LOG_ERROR("MusicPlayer", "Memory allocation failed for ID3 header: " + std::string(e.what())); + return false; + } + + if (file.gcount() < static_cast(header.size()) || + std::memcmp(header.data(), "ID3", 3) != 0) { + // Not an ID3v2 file + return false; + } + + // Get ID3 version + unsigned int majorVersion = static_cast(std::to_integer(header[3])); + LOG_INFO("MusicPlayer", "ID3v2." + std::to_string(majorVersion) + " tag found"); + + // Get the tag size (bytes 6-9 are synchsafe integers) + int tagSize = 0; + for (int i = 0; i < 4; ++i) { + tagSize = (tagSize << 7) | (std::to_integer(header[6 + i]) & 0x7F); + } + + // Sanity check on tag size + if (tagSize <= 0 || tagSize > 100000000) { // 100MB limit + LOG_WARNING("MusicPlayer", "Invalid tag size: " + std::to_string(tagSize) + " bytes"); + return false; + } + + // Make sure tag doesn't claim to be larger than the file + if (tagSize > fileSize - 10) { + LOG_WARNING("MusicPlayer", "Tag size exceeds file size: " + + std::to_string(tagSize) + " > " + std::to_string(static_cast(fileSize - 10))); + return false; + } + + int tagEnd = 10 + tagSize; // End position of the tag + LOG_INFO("MusicPlayer", "Tag size: " + std::to_string(tagSize) + " bytes"); + + // Loop through frames until we reach the end of the tag. + while (file.tellg() < tagEnd && !file.eof()) { + // Check if we have enough bytes left for a frame header + if (tagEnd - file.tellg() < 10) { + LOG_INFO("MusicPlayer", "Not enough data for frame header"); + break; + } + + std::vector frameHeader; + try { + frameHeader.resize(10); + file.read(reinterpret_cast(frameHeader.data()), frameHeader.size()); + } + catch (const std::bad_alloc& e) { + LOG_ERROR("MusicPlayer", "Memory allocation failed for frame header: " + std::string(e.what())); + return false; + } + + if (file.gcount() < static_cast(frameHeader.size())) + break; + + // Frame ID is in the first 4 bytes. + char frameID[5] = { 0 }; + for (int i = 0; i < 4; i++) { + char c = static_cast(std::to_integer(frameHeader[i])); + // Valid frame IDs only contain A-Z and 0-9 + if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + frameID[i] = c; + } + else { + // Invalid frame ID character + LOG_WARNING("MusicPlayer", "Invalid frame ID character: " + + std::string(1, c) + " (" + std::to_string(static_cast(c)) + ")"); + frameID[0] = 0; // Mark as invalid + break; + } + } + + // If invalid frame ID, we might have reached padding or corrupt data + if (frameID[0] == 0) { + LOG_INFO("MusicPlayer", "Invalid frame ID, skipping remainder of tag"); + break; + } + + // Get frame size (handle different versions correctly) + int frameSize; + if (majorVersion >= 4) { + // ID3v2.4 uses synchsafe integers + frameSize = 0; + for (int i = 0; i < 4; ++i) { + frameSize = (frameSize << 7) | (std::to_integer(frameHeader[4 + i]) & 0x7F); + } + } + else { + // ID3v2.3 uses regular integers + frameSize = (std::to_integer(frameHeader[4]) << 24) | + (std::to_integer(frameHeader[5]) << 16) | + (std::to_integer(frameHeader[6]) << 8) | + (std::to_integer(frameHeader[7])); + } + + // Validate frame size + if (frameSize <= 0 || frameSize > 10000000) { // 10MB limit per frame + LOG_WARNING("MusicPlayer", "Invalid frame size: " + std::to_string(frameSize)); + break; + } + + // Check if frame size exceeds remaining tag data + if (frameSize > tagEnd - file.tellg()) { + LOG_WARNING("MusicPlayer", "Frame size exceeds remaining tag data: " + + std::to_string(frameSize) + " > " + std::to_string(static_cast(tagEnd - file.tellg()))); + break; + } + + LOG_INFO("MusicPlayer", "Found frame: " + std::string(frameID) + + ", size: " + std::to_string(frameSize) + " bytes"); + + if (std::strcmp(frameID, "APIC") == 0) { + // Read the entire frame data. + std::vector frameData; + try { + frameData.resize(frameSize); + file.read(reinterpret_cast(frameData.data()), frameSize); + } + catch (const std::bad_alloc& e) { + LOG_ERROR("MusicPlayer", "Memory allocation failed for APIC frame data: " + std::string(e.what())); + return false; + } + + if (file.gcount() < frameSize) + break; + + size_t offset = 0; + + // Ensure we don't read past the frame data + if (offset >= frameData.size()) { + LOG_WARNING("MusicPlayer", "Premature end of APIC frame data"); + break; + } + + // Skip text encoding (1 byte) + int textEncoding = std::to_integer(frameData[offset]); + offset += 1; + LOG_INFO("MusicPlayer", "Text encoding: " + std::to_string(textEncoding)); + + // Skip MIME type (null-terminated string) + std::string mimeType; + while (offset < frameData.size() && std::to_integer(frameData[offset]) != 0) { + mimeType += static_cast(std::to_integer(frameData[offset])); + offset++; + // Sanity check on MIME type length + if (mimeType.length() > 100) { + LOG_WARNING("MusicPlayer", "MIME type too long, probably corrupt data"); + return false; + } + } + + // Check if we reached end of data before null terminator + if (offset >= frameData.size()) { + LOG_WARNING("MusicPlayer", "MIME type not null-terminated"); + break; + } + + offset++; // Skip the null terminator + LOG_INFO("MusicPlayer", "MIME type: " + mimeType); + + // The next byte is the picture type. + if (offset >= frameData.size()) { + LOG_WARNING("MusicPlayer", "Premature end of APIC frame data after MIME type"); + break; + } + + int pictureType = std::to_integer(frameData[offset]); + offset++; // Move past picture type + LOG_INFO("MusicPlayer", "Picture type: " + std::to_string(pictureType)); + + // We want either front cover (3) or any picture if desperate + if (pictureType != 0x03 && pictureType != 0x00) { + // Skip if not front cover or other picture + continue; + } + + // Skip description (null-terminated string) + // Handle encoding properly + if (textEncoding == 0 || textEncoding == 3) { // ISO-8859-1 or UTF-8 + // Set a reasonable limit for description scanning to prevent infinite loops + size_t scanLimit = std::min(frameData.size() - offset, static_cast(1000)); + size_t scanned = 0; + + while (offset < frameData.size() && std::to_integer(frameData[offset]) != 0) { + offset++; + scanned++; + if (scanned >= scanLimit) { + LOG_WARNING("MusicPlayer", "Description too long or not null-terminated"); + return false; + } + } + + // Ensure we didn't reach end of data before null terminator + if (offset >= frameData.size()) { + LOG_WARNING("MusicPlayer", "Description not null-terminated"); + break; + } + + offset++; // Skip the null terminator + } + else { // UTF-16/UTF-16BE with BOM + // Set a reasonable limit for description scanning + size_t scanLimit = std::min(frameData.size() - offset, static_cast(2000)); + size_t scanned = 0; + + while (offset + 1 < frameData.size() && + !(std::to_integer(frameData[offset]) == 0 && + std::to_integer(frameData[offset + 1]) == 0)) { + offset += 2; + scanned += 2; + if (scanned >= scanLimit) { + LOG_WARNING("MusicPlayer", "UTF-16 description too long or not null-terminated"); + return false; + } + } + + // Ensure we didn't reach end of data before double null terminator + if (offset + 1 >= frameData.size()) { + LOG_WARNING("MusicPlayer", "UTF-16 description not properly null-terminated"); + break; + } + + offset += 2; // Skip the double null terminator + } + + if (offset < frameData.size()) { + // Calculate remaining bytes for image data + size_t imageDataSize = frameData.size() - offset; + + // Check we have enough data for a meaningful image + if (imageDataSize < 100) { // Arbitrary minimum size for a valid image + LOG_WARNING("MusicPlayer", "Image data too small: " + std::to_string(imageDataSize) + " bytes"); + return false; + } + + // Log how much image data we're extracting + LOG_INFO("MusicPlayer", "Extracting " + std::to_string(imageDataSize) + " bytes of image data"); + + try { + // Convert std::byte to unsigned char for albumArtData + albumArtData.resize(imageDataSize); + for (size_t i = 0; i < albumArtData.size(); ++i) { + albumArtData[i] = std::to_integer(frameData[offset + i]); + } + } + catch (const std::bad_alloc& e) { + LOG_ERROR("MusicPlayer", "Memory allocation failed for album art data: " + std::string(e.what())); + return false; + } + + // Validate the image data starts with proper headers + if (albumArtData.size() >= 4) { + // Check if it's a valid JPG/PNG + if ((albumArtData[0] == 0xFF && albumArtData[1] == 0xD8) || // JPEG + (albumArtData[0] == 0x89 && albumArtData[1] == 0x50 && albumArtData[2] == 0x4E && albumArtData[3] == 0x47)) { // PNG + LOG_INFO("MusicPlayer", "Valid image header detected"); + return true; + } + else { + // Format the hex values using stringstream + std::stringstream ss; + ss << std::hex << std::uppercase << std::setfill('0') + << std::setw(2) << static_cast(albumArtData[0]) << " " + << std::setw(2) << static_cast(albumArtData[1]) << " " + << std::setw(2) << static_cast(albumArtData[2]) << " " + << std::setw(2) << static_cast(albumArtData[3]); + + LOG_WARNING("MusicPlayer", "Warning: Invalid image header: " + ss.str()); + // Continue anyway, IMG_Load might still handle it + return true; + } + } + else { + LOG_WARNING("MusicPlayer", "Image data too small: " + std::to_string(albumArtData.size()) + " bytes"); + return false; + } + } + else { + LOG_WARNING("MusicPlayer", "Invalid APIC frame structure"); + return false; + } + } + else { + // Skip this frame's data if not APIC. + file.seekg(frameSize, std::ios::cur); + + // Check if seek operation succeeded + if (file.fail()) { + LOG_WARNING("MusicPlayer", "Failed to seek past frame data"); + break; + } + } + } + + LOG_INFO("MusicPlayer", "No suitable album art found"); + return false; + } + catch (const std::exception& e) { + LOG_ERROR("MusicPlayer", "Exception extracting album art: " + std::string(e.what())); + return false; + } + catch (...) { + LOG_ERROR("MusicPlayer", "Unknown exception extracting album art"); + return false; + } +} + +bool MusicPlayer::getAlbumArt(int trackIndex, std::vector& albumArtData) { + try { + // Clear the output vector first + albumArtData.clear(); + + // Validate track index + if (trackIndex < 0 || trackIndex >= static_cast(musicFiles_.size())) { + LOG_ERROR("MusicPlayer", "Invalid track index for album art: " + std::to_string(trackIndex)); + return false; + } + + // Get the file path of the requested track + std::string filePath = musicFiles_[trackIndex]; + + // Check if file exists + if (!std::filesystem::exists(filePath)) { + LOG_ERROR("MusicPlayer", "Track file does not exist: " + filePath); + return false; + } + + // Extract album art data from the file + bool result = extractAlbumArtFromFile(filePath, albumArtData); + + if (!result || albumArtData.empty()) { + LOG_INFO("MusicPlayer", "No album art found in track: " + musicNames_[trackIndex]); + return false; + } + + LOG_INFO("MusicPlayer", "Extracted album art from track: " + musicNames_[trackIndex]); + return true; + } + catch (const std::exception& e) { + LOG_ERROR("MusicPlayer", "Exception getting album art: " + std::string(e.what())); + return false; + } + catch (...) { + LOG_ERROR("MusicPlayer", "Unknown exception getting album art"); + return false; + } +} + +double MusicPlayer::getCurrent() +{ + if (!currentMusic_) { + return -1.0; + } + + return Mix_GetMusicPosition(currentMusic_); +} + +double MusicPlayer::getDuration() +{ + if (!currentMusic_) { + return -1.0; + } + + return Mix_MusicDuration(currentMusic_); +} + +std::pair MusicPlayer::getCurrentAndDurationSec() { + if (!currentMusic_) return { -1, -1 }; + return { + static_cast(Mix_GetMusicPosition(currentMusic_)), + static_cast(Mix_MusicDuration(currentMusic_)) + }; +} + +bool MusicPlayer::isFading() const +{ + return Mix_FadingMusic() != MIX_NO_FADING; +} + +bool MusicPlayer::hasStartedPlaying() const +{ + return hasStartedPlaying_; +} + +void MusicPlayer::setButtonPressed(bool buttonPressed) { + buttonPressed_ = buttonPressed; +} + +bool MusicPlayer::getButtonPressed() { + return buttonPressed_; +} \ No newline at end of file diff --git a/RetroFE/Source/Sound/MusicPlayer.h b/RetroFE/Source/Sound/MusicPlayer.h new file mode 100644 index 000000000..b77900450 --- /dev/null +++ b/RetroFE/Source/Sound/MusicPlayer.h @@ -0,0 +1,208 @@ +/* This file is part of RetroFE. + * + * RetroFE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * RetroFE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with RetroFE. If not, see . + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#if __has_include() + #include +#elif __has_include() + #include +#else + #error "Cannot find SDL_mixer header" +#endif +#if __has_include() + #include +#elif __has_include() + #include +#else + #error "Cannot find SDL_image header" +#endif +#include "../Database/Configuration.h" + +class MusicPlayer +{ +public: + // Singleton & Basic Setup + static MusicPlayer* getInstance(); + bool hasStartedPlaying() const; // Returns true once the first track begins playing + + // Track Metadata Structure + struct TrackMetadata + { + std::string title; + std::string artist; + std::string album; + std::string year; + std::string genre; + std::string comment; + int trackNumber; + + TrackMetadata() : trackNumber(0) {} + }; + + + // Enum for track change direction + enum class PlaybackState { + NONE, + PLAYING, + PAUSED, + NEXT, + PREVIOUS + }; + + void setPlaybackState(PlaybackState state) { playbackState_ = state; } + PlaybackState getPlaybackState() const { return playbackState_; } + + // Initialization & Shutdown + bool initialize(Configuration& config); + void shutdown(); + + // Playlist & Folder Loading + bool loadM3UPlaylist(const std::string& playlistPath); + void loadMusicFolderFromConfig(); + bool loadMusicFolder(const std::string& folderPath); + + // Playback Control + bool playMusic(int index = -1, int customFadeMs = -1); // -1 means use current or random track + bool pauseMusic(int customFadeMs = -1); + bool resumeMusic(int customFadeMs = -1); + bool stopMusic(int customFadeMs = -1); + bool nextTrack(int customFadeMs = -1); + bool previousTrack(int customFadeMs = -1); + bool isPlaying() const; + bool isPaused() const; + void changeVolume(bool increase); + double saveCurrentMusicPosition(); + double getCurrent(); // Current playback position (sec) + double getDuration(); // Duration of current track (sec) + std::pair getCurrentAndDurationSec(); + bool getButtonPressed(); + void setButtonPressed(bool buttonPressed); + + // Volume & Loop Settings + void setVolume(int volume); // 0-128 (SDL_Mixer range) + void setLogicalVolume(int v); + int getLogicalVolume(); + int getVolume() const; + void fadeToVolume(int targetPercent); + void fadeBackToPreviousVolume(); + void setLoop(bool loop); + bool getLoop() const; + + // Shuffle Controls + bool shuffle(); + bool setShuffle(bool shuffle); + bool getShuffle() const; + + // Track Navigation & Identification + int getCurrentTrackIndex() const; + int getTrackCount() const; + std::string getCurrentTrackName() const; + std::string getCurrentTrackNameWithoutExtension() const; + std::string getCurrentTrackPath() const; + std::string getFormattedTrackInfo(int index = -1) const; + std::string getTrackArtist(int index = -1) const; + std::string getTrackAlbum(int index = -1) const; + + // Detailed Metadata Access + const TrackMetadata& getCurrentTrackMetadata() const; + const TrackMetadata& getTrackMetadata(int index) const; + size_t getTrackMetadataCount() const; + std::string getCurrentTitle() const; + std::string getCurrentArtist() const; + std::string getCurrentAlbum() const; + std::string getCurrentYear() const; + std::string getCurrentGenre() const; + std::string getCurrentComment() const; + int getCurrentTrackNumber() const; + + // Track Change State + bool isFading() const; + bool hasTrackChanged(); + bool isPlayingNewTrack(); + + // Album Art Extraction + bool getAlbumArt(int trackIndex, std::vector& albumArtData); + + // Audio Visualization & Processing + static void postMixCallback(void* udata, Uint8* stream, int len); + void processAudioData(Uint8* stream, int len); + const std::vector& getAudioLevels() const { return audioLevels_; } + int getAudioChannels() const { return audioChannels_; } + bool registerVisualizerCallback(); + void unregisterVisualizerCallback(); + bool hasVisualizer() const { return hasVisualizer_; } + +private: + // Constructors / Destructors + MusicPlayer(); + ~MusicPlayer(); + + PlaybackState playbackState_; + + // Private Helper Functions + void loadTrack(int index); + bool readTrackMetadata(const std::string& filePath, TrackMetadata& metadata) const; + bool parseM3UFile(const std::string& playlistPath); + bool isValidAudioFile(const std::string& filePath) const; + void setFadeDuration(int ms); + int getFadeDuration() const; + void resetShutdownFlag(); + int getNextTrackIndex(); + static void musicFinishedCallback(); + void onMusicFinished(); + + // Singleton Instance + static MusicPlayer* instance_; + + // Configuration & Playback State + Configuration* config_; + Mix_Music* currentMusic_; + std::vector musicFiles_; + std::vector musicNames_; + std::vector trackMetadata_; + std::vector shuffledIndices_; + int currentShufflePos_; + int currentIndex_; + int volume_; + int logicalVolume_; + bool loopMode_; + bool shuffleMode_; + bool isShuttingDown_; + std::mt19937 rng_; + bool isPendingPause_; + double pausedMusicPosition_; + bool isPendingTrackChange_; + int pendingTrackIndex_; + int fadeMs_; + int previousVolume_; + bool buttonPressed_; + std::string lastCheckedTrackPath_; + bool hasStartedPlaying_; + Uint64 lastVolumeChangeTime_; + Uint64 volumeChangeIntervalMs_; + + // Audio Visualization Members + std::vector audioLevels_; + int audioChannels_; + bool hasVisualizer_; + int sampleSize_; // 1, 2, or 4 bytes per sample +}; \ No newline at end of file diff --git a/RetroFE/Source/Video/GStreamerVideo.cpp b/RetroFE/Source/Video/GStreamerVideo.cpp index e244fb897..02348d189 100644 --- a/RetroFE/Source/Video/GStreamerVideo.cpp +++ b/RetroFE/Source/Video/GStreamerVideo.cpp @@ -39,11 +39,15 @@ #include #include #include - +#include +#include bool GStreamerVideo::initialized_ = false; bool GStreamerVideo::pluginsInitialized_ = false; +std::vector GStreamerVideo::activeVideos_; +std::mutex GStreamerVideo::activeVideosMutex_; + typedef enum { GST_PLAY_FLAG_VIDEO = (1 << 0), GST_PLAY_FLAG_AUDIO = (1 << 1), @@ -148,34 +152,6 @@ void GStreamerVideo::messageHandler(float dt) { if (!playbin_ || !isPlaying_.load(std::memory_order_relaxed)) return; - // Accumulate time since last message processing - static float timeAccumulator = 0.0f; - - // Default message checking interval: 50ms (20Hz) - constexpr float DEFAULT_CHECK_INTERVAL = 0.050f; - - // Shorter interval during transitions or paused state: ~16ms (60Hz) - constexpr float CRITICAL_CHECK_INTERVAL = 0.016f; - - // Determine which interval to use based on playback state - float currentInterval = DEFAULT_CHECK_INTERVAL; - - // Use faster checking during paused state or when there's an error - // These are critical states where we want more responsive message handling - if (hasError_.load(std::memory_order_relaxed)) { - currentInterval = CRITICAL_CHECK_INTERVAL; - } - - // Accumulate the time - timeAccumulator += dt; - - // Skip if not enough time has passed - if (timeAccumulator < currentInterval) - return; - - // Reset accumulator (don't just zero it - subtract the interval to maintain precision) - timeAccumulator -= currentInterval; - // Get the bus and process messages GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(playbin_)); if (!bus) @@ -248,7 +224,7 @@ void GStreamerVideo::initializePlugins() { pluginsInitialized_ = true; #if defined(WIN32) - enablePlugin("directsoundsink"); + //enablePlugin("directsoundsink"); disablePlugin("mfdeviceprovider"); disablePlugin("nvh264dec"); disablePlugin("nvh265dec"); @@ -411,11 +387,13 @@ bool GStreamerVideo::stop() { perspective_gva_ = nullptr; } - + { + std::lock_guard lock(activeVideosMutex_); + activeVideos_.erase(std::remove(activeVideos_.begin(), activeVideos_.end(), this), activeVideos_.end()); + } return true; } - bool GStreamerVideo::unload() { if (!playbin_) { return false; @@ -423,80 +401,78 @@ bool GStreamerVideo::unload() { isPlaying_.store(false, std::memory_order_release); - // Set pipeline to GST_STATE_READY (instead of GST_STATE_NULL) so we can reuse it later - GstStateChangeReturn ret = gst_element_set_state(playbin_, GST_STATE_READY); - if (ret == GST_STATE_CHANGE_FAILURE) { - LOG_ERROR("GStreamerVideo", "Failed to set pipeline to READY during unload."); - return false; + // 1. Check current and pending state + GstState curState, pendingState; + GstStateChangeReturn getStateRet = gst_element_get_state(playbin_, &curState, &pendingState, 0); + + bool needsPause = true; + + if (getStateRet != GST_STATE_CHANGE_FAILURE) { + if (curState == GST_STATE_PAUSED || pendingState == GST_STATE_PAUSED) { + needsPause = false; + } + } + + // 2. Gracefully pause if necessary + if (needsPause) { + gst_element_set_state(playbin_, GST_STATE_PAUSED); + // Now block briefly (not forever) to allow pause to complete + gst_element_get_state(playbin_, nullptr, nullptr, 2 * GST_SECOND); + } + + // 3. Drain any remaining samples from appsink + while (GstSample* sample = gst_app_sink_try_pull_sample(GST_APP_SINK(videoSink_), 0)) { + gst_sample_unref(sample); } - // Optionally wait for the state change to complete + // 4. Move pipeline to READY + GstStateChangeReturn ret = gst_element_set_state(playbin_, GST_STATE_READY); GstState newState; ret = gst_element_get_state(playbin_, &newState, nullptr, GST_SECOND); if (ret == GST_STATE_CHANGE_FAILURE || newState != GST_STATE_READY) { - LOG_ERROR("GStreamerVideo", "Pipeline did not reach READY state during unload."); + LOG_ERROR("GStreamerVideo", "Pipeline failed to reach READY state during unload."); + hasError_.store(true, std::memory_order_release); + return false; } + // 5. Clean up bus GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(playbin_)); - - // Process all pending messages (non-blocking) - GstMessage* msg; - while ((msg = gst_bus_pop(bus))) { - switch (GST_MESSAGE_TYPE(msg)) { - case GST_MESSAGE_ERROR: { - GError* err; - gchar* debug_info; - gst_message_parse_error(msg, &err, &debug_info); - - // Set error flag and log the error - hasError_.store(true, std::memory_order_release); - LOG_ERROR("GStreamerVideo", "Error received from element " + - std::string(GST_OBJECT_NAME(msg->src)) + ": " + - std::string(err->message)); - if (debug_info) { - LOG_DEBUG("GStreamerVideo", "Debug info: " + std::string(debug_info)); - } - - g_clear_error(&err); - g_free(debug_info); - break; - } - default: - break; + if (bus) { + GstMessage* msg; + while ((msg = gst_bus_pop(bus))) { + gst_message_unref(msg); } - gst_message_unref(msg); + gst_bus_set_flushing(bus, TRUE); + gst_object_unref(bus); } - gst_object_unref(bus); - // Reset flags used for timing, volume, etc. + // 6. Reset everything else (same as before) paused_ = false; + currentFile_.clear(); + playCount_ = 0; + numLoops_ = 0; currentVolume_ = 0.0f; lastSetVolume_ = -1.0f; lastSetMuteState_ = false; - volume_ = 0.0f; // reset to default - playCount_ = 0; - numLoops_ = 0; + volume_ = 0.0f; - if (videoInfo_) { - gst_video_info_free(videoInfo_); - videoInfo_ = nullptr; - } textureWidth_.store(width_.load(std::memory_order_acquire), std::memory_order_release); textureHeight_.store(height_.load(std::memory_order_acquire), std::memory_order_release); width_.store(0, std::memory_order_release); height_.store(0, std::memory_order_release); + SDL_LockMutex(SDL::getMutex()); - texture_ = alphaTexture_; // Switch to blank texture + texture_ = alphaTexture_; // fallback to blank textureValid_.store(false, std::memory_order_release); SDL_UnlockMutex(SDL::getMutex()); - LOG_DEBUG("GStreamerVideo", "Pipeline unloaded, now in READY state."); + LOG_DEBUG("GStreamerVideo", "Pipeline and class fully unloaded, ready for new play()."); return true; } // Main function to compute perspective transform from 4 arbitrary points -inline std::array computePerspectiveMatrixFromCorners( +static inline std::array computePerspectiveMatrixFromCorners( int width, int height, const std::array& pts) @@ -570,9 +546,6 @@ inline std::array computePerspectiveMatrixFromCorners( return H; } - - - bool GStreamerVideo::createPipelineIfNeeded() { if (playbin_) { return true; @@ -669,10 +642,14 @@ bool GStreamerVideo::createPipelineIfNeeded() { g_object_set(playbin_, "video-sink", videoSink_, nullptr); } + { + std::lock_guard lock(activeVideosMutex_); + activeVideos_.push_back(this); + } + return true; } - bool GStreamerVideo::play(const std::string& file) { playCount_ = 0; if (!initialized_) { @@ -904,7 +881,7 @@ void GStreamerVideo::volumeUpdate() { currentVolume_ += 0.005; // Determine mute state - bool shouldMute = (currentVolume_ < 0.1); + bool shouldMute = (currentVolume_ < 0.1) || Configuration::MuteVideo; // Update volume only if it has changed and is not muted if (!shouldMute && currentVolume_ != lastSetVolume_) @@ -924,7 +901,6 @@ void GStreamerVideo::volumeUpdate() { } } - int GStreamerVideo::getHeight() { return height_.load(std::memory_order_relaxed); } @@ -945,26 +921,7 @@ void GStreamerVideo::draw() { // Try to pull a sample from the appsink (GStreamer operation - no mutex needed) GstSample* sample = gst_app_sink_try_pull_sample(GST_APP_SINK(videoSink_), 0); - // If no sample is available, check for EOS condition if (!sample) { - // Only check state if we're still playing (reusing cached value) - if (isPlaying) { - GstState state; - gst_element_get_state(GST_ELEMENT(playbin_), &state, nullptr, 0); - - // Check for end of stream when in PLAYING state - if (state == GST_STATE_PLAYING && gst_app_sink_is_eos(GST_APP_SINK(videoSink_))) { - if (getCurrent() > GST_SECOND) { - playCount_++; - if (!numLoops_ || numLoops_ > playCount_) { - restart(); - } - else { - stop(); - } - } - } - } return; } @@ -1005,55 +962,136 @@ void GStreamerVideo::draw() { } } - // We now know texture is valid from above checks - // Update the texture if it's the video texture (using cached state) - if (texture_ == videoTexture_) { - int updateResult = -1; + // Refresh after possible recreate + textureValid = textureValid_.load(std::memory_order_acquire); - if (sdlFormat_ == SDL_PIXELFORMAT_NV12) { - updateResult = SDL_UpdateNVTexture(texture_, nullptr, - static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, 0)), - GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 0), - static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, 1)), - GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 1)); - } - else if (sdlFormat_ == SDL_PIXELFORMAT_IYUV) { - updateResult = SDL_UpdateYUVTexture(texture_, nullptr, - static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, 0)), - GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 0), - static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, 1)), - GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 1), - static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, 2)), - GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 2)); - } - else if (sdlFormat_ == SDL_PIXELFORMAT_ABGR8888) { - // For RGBA, there is only one plane (plane 0) - updateResult = SDL_UpdateTexture(texture_, nullptr, - static_cast(GST_VIDEO_FRAME_PLANE_DATA(&frame, 0)), - GST_VIDEO_FRAME_PLANE_STRIDE(&frame, 0)); - } - else { - // Unsupported format - should not happen due to format checking in createPipelineIfNeeded() + if (textureValid && texture_ == videoTexture_) { + bool success = false; + + switch (sdlFormat_) { + case SDL_PIXELFORMAT_IYUV: + success = updateTextureFromFrameIYUV(texture_, &frame); + break; + case SDL_PIXELFORMAT_NV12: + success = updateTextureFromFrameNV12(texture_, &frame); + break; + case SDL_PIXELFORMAT_ABGR8888: + success = updateTextureFromFrameRGBA(texture_, &frame); + break; + default: LOG_ERROR("GStreamerVideo", "Unsupported pixel format in draw()"); - updateResult = -1; + break; } - // Check for texture update errors - if (updateResult != 0) { - LOG_ERROR("GStreamerVideo", "Texture update failed: " + std::string(SDL_GetError())); - // Mark texture as invalid so we'll try to recreate it next frame + if (!success) { textureValid_.store(false, std::memory_order_release); } } - // We're done with SDL operations, unlock the mutex SDL_UnlockMutex(SDL::getMutex()); - // Clean up GStreamer resources (no mutex needed) + // Unmap and unref GStreamer objects gst_video_frame_unmap(&frame); gst_sample_unref(sample); } +bool GStreamerVideo::updateTextureFromFrameIYUV(SDL_Texture* texture, GstVideoFrame* frame) { + void* pixels = nullptr; + int pitch = 0; + if (SDL_LockTexture(texture, nullptr, &pixels, &pitch) != 0) + return false; + + uint8_t* dst = static_cast(pixels); + + const int width = GST_VIDEO_FRAME_COMP_WIDTH(frame, 0); + const int height = GST_VIDEO_FRAME_COMP_HEIGHT(frame, 0); + + const uint8_t* srcY = static_cast(GST_VIDEO_FRAME_PLANE_DATA(frame, 0)); + const uint8_t* srcU = static_cast(GST_VIDEO_FRAME_PLANE_DATA(frame, 1)); + const uint8_t* srcV = static_cast(GST_VIDEO_FRAME_PLANE_DATA(frame, 2)); + + const int strideY = GST_VIDEO_FRAME_PLANE_STRIDE(frame, 0); + const int strideU = GST_VIDEO_FRAME_PLANE_STRIDE(frame, 1); + const int strideV = GST_VIDEO_FRAME_PLANE_STRIDE(frame, 2); + + uint8_t* dstU = dst + height * pitch; + uint8_t* dstV = dstU + (height / 2) * (pitch / 2); + + // Copy Y plane + for (int y = 0; y < height; ++y) { + SDL_memcpy(dst + y * pitch, srcY + y * strideY, width); + } + + // Copy U plane + for (int y = 0; y < height / 2; ++y) { + SDL_memcpy(dstU + y * (pitch / 2), srcU + y * strideU, width / 2); + } + + // Copy V plane + for (int y = 0; y < height / 2; ++y) { + SDL_memcpy(dstV + y * (pitch / 2), srcV + y * strideV, width / 2); + } + + SDL_UnlockTexture(texture); + return true; +} + +bool GStreamerVideo::updateTextureFromFrameNV12(SDL_Texture* texture, GstVideoFrame* frame) { + void* pixels = nullptr; + int pitch = 0; + if (SDL_LockTexture(texture, nullptr, &pixels, &pitch) != 0) + return false; + + uint8_t* dst = static_cast(pixels); + + const int width = GST_VIDEO_FRAME_COMP_WIDTH(frame, 0); + const int height = GST_VIDEO_FRAME_COMP_HEIGHT(frame, 0); + + const uint8_t* srcY = static_cast(GST_VIDEO_FRAME_PLANE_DATA(frame, 0)); + const uint8_t* srcUV = static_cast(GST_VIDEO_FRAME_PLANE_DATA(frame, 1)); + + const int strideY = GST_VIDEO_FRAME_PLANE_STRIDE(frame, 0); + const int strideUV = GST_VIDEO_FRAME_PLANE_STRIDE(frame, 1); + + uint8_t* dstUV = dst + height * pitch; + + // --- Copy Y plane --- + for (int y = 0; y < height; ++y) { + SDL_memcpy(dst + y * pitch, srcY + y * strideY, width); + } + + // --- Copy UV plane --- + for (int y = 0; y < height / 2; ++y) { + SDL_memcpy(dstUV + y * pitch, srcUV + y * strideUV, width); + } + + SDL_UnlockTexture(texture); + return true; +} + +bool GStreamerVideo::updateTextureFromFrameRGBA(SDL_Texture* texture, GstVideoFrame* frame) { + void* pixels = nullptr; + int pitch = 0; + if (SDL_LockTexture(texture, nullptr, &pixels, &pitch) != 0) + return false; + + uint8_t* dst = static_cast(pixels); + + const int width = GST_VIDEO_FRAME_COMP_WIDTH(frame, 0); + const int height = GST_VIDEO_FRAME_COMP_HEIGHT(frame, 0); + + const uint8_t* src = static_cast(GST_VIDEO_FRAME_PLANE_DATA(frame, 0)); + const int stride = GST_VIDEO_FRAME_PLANE_STRIDE(frame, 0); + + // --- Copy RGBA plane --- + for (int y = 0; y < height; ++y) { + SDL_memcpy(dst + y * pitch, src + y * stride, width * 4); // 4 bytes per pixel + } + + SDL_UnlockTexture(texture); + return true; +} + bool GStreamerVideo::isPlaying() { return isPlaying_.load(std::memory_order_acquire); } @@ -1155,7 +1193,6 @@ void GStreamerVideo::restart() { } } - unsigned long long GStreamerVideo::getCurrent() { gint64 ret = 0; if (!gst_element_query_position(playbin_, GST_FORMAT_TIME, &ret) || !isPlaying_) @@ -1225,45 +1262,62 @@ void GStreamerVideo::setPerspectiveCorners(const int* corners) { void GStreamerVideo::customGstLogHandler(GstDebugCategory* category, GstDebugLevel level, const gchar* file, const gchar* function, gint line, - GObject* object, GstDebugMessage* message, gpointer user_data) -{ - // Extract the log message from the GStreamer message + GObject* object, GstDebugMessage* message, gpointer user_data) { std::string logMsg = gst_debug_message_get(message); + std::string componentName = (category && gst_debug_category_get_name(category)) ? gst_debug_category_get_name(category) : "Unknown"; - // Get the original GStreamer category name if available, or default to "Unknown" - std::string originalComponent = (category && gst_debug_category_get_name(category)) - ? gst_debug_category_get_name(category) - : "Unknown"; - - // Combine the original component and log message in the format "component: message" - std::string fullMessage = originalComponent + ": " + logMsg; - - // Use a fixed component name so that all GStreamer logs appear under one category std::string component = "GStreamerLog"; + std::string finalMessage = componentName + ": " + logMsg; + + // Try to associate the log with a playing file + if (object) { + if (GstObject* gstObj = GST_OBJECT(object)) { + if (GStreamerVideo* owner = findInstanceFromGstObject(gstObj)) { + if (!owner->currentFile_.empty()) { + std::string relativePath = owner->currentFile_; + const std::string& basePath = Configuration::absolutePath; + + // Remove base path if it matches + if (relativePath.find(basePath) == 0) { + relativePath = relativePath.substr(basePath.length()); + if (!relativePath.empty() && (relativePath[0] == '/' || relativePath[0] == '\\')) { + relativePath.erase(0, 1); // Trim leading separator + } + } - // Map GStreamer log levels to your Logger's macros + finalMessage = "[" + relativePath + "] " + finalMessage; + } + } + } + } + + // Map log level to your logging macros switch (level) { - case GST_LEVEL_ERROR: - LOG_ERROR(component, fullMessage); - break; - case GST_LEVEL_WARNING: - LOG_WARNING(component, fullMessage); - break; - case GST_LEVEL_FIXME: - LOG_NOTICE(component, fullMessage); - break; - case GST_LEVEL_INFO: - LOG_INFO(component, fullMessage); - break; - case GST_LEVEL_DEBUG: - case GST_LEVEL_LOG: - case GST_LEVEL_TRACE: - case GST_LEVEL_MEMDUMP: - LOG_DEBUG(component, fullMessage); - break; - default: - // Default to DEBUG if the level is unrecognized - LOG_DEBUG(component, fullMessage); - break; + case GST_LEVEL_ERROR: LOG_ERROR(component, finalMessage); break; + case GST_LEVEL_WARNING: LOG_WARNING(component, finalMessage); break; + case GST_LEVEL_FIXME: LOG_NOTICE(component, finalMessage); break; + case GST_LEVEL_INFO: LOG_INFO(component, finalMessage); break; + case GST_LEVEL_DEBUG: + case GST_LEVEL_LOG: + case GST_LEVEL_TRACE: + case GST_LEVEL_MEMDUMP: + default: LOG_DEBUG(component, finalMessage); break; } } + +GStreamerVideo* GStreamerVideo::findInstanceFromGstObject(GstObject* object) { + if (!object) + return nullptr; + + GstObject* cur = object; + while (cur) { + std::lock_guard lock(activeVideosMutex_); + for (GStreamerVideo* video : activeVideos_) { + if (video->playbin_ == GST_ELEMENT(cur)) { + return video; + } + } + cur = GST_OBJECT_PARENT(cur); + } + return nullptr; +} \ No newline at end of file diff --git a/RetroFE/Source/Video/GStreamerVideo.h b/RetroFE/Source/Video/GStreamerVideo.h index 56da225dd..ecafbf32b 100644 --- a/RetroFE/Source/Video/GStreamerVideo.h +++ b/RetroFE/Source/Video/GStreamerVideo.h @@ -89,11 +89,19 @@ class GStreamerVideo final : public IVideo { } private: + static std::vector activeVideos_; + static std::mutex activeVideosMutex_; + + static GStreamerVideo* findInstanceFromGstObject(GstObject* object); + static constexpr int ALPHA_TEXTURE_SIZE = 4; void createAlphaTexture(); static void elementSetupCallback(GstElement* playbin, GstElement* element, gpointer data); static GstPadProbeReturn padProbeCallback(GstPad* pad, GstPadProbeInfo* info, gpointer user_data); static void initializePlugins(); + bool updateTextureFromFrameIYUV(SDL_Texture* texture, GstVideoFrame* frame); + bool updateTextureFromFrameNV12(SDL_Texture* texture, GstVideoFrame* frame); + bool updateTextureFromFrameRGBA(SDL_Texture* texture, GstVideoFrame* frame); void createSdlTexture(); GstElement* playbin_{ nullptr }; // for playbin3 GstElement* videoSink_{ nullptr }; // for appsink diff --git a/RetroFE/Source/Video/VideoPool.cpp b/RetroFE/Source/Video/VideoPool.cpp index bbc99424a..8f9fb41a8 100644 --- a/RetroFE/Source/Video/VideoPool.cpp +++ b/RetroFE/Source/Video/VideoPool.cpp @@ -25,6 +25,18 @@ #include #include +namespace { + // Keep only relevant constants + constexpr int ACQUIRE_MAX_RETRIES = 5; + constexpr std::chrono::milliseconds ACQUIRE_BASE_BACKOFF{ 20 }; + constexpr std::chrono::milliseconds ACQUIRE_LOCK_TIMEOUT{ 100 }; + constexpr std::chrono::milliseconds ACQUIRE_WAIT_TIMEOUT{ 500 }; + constexpr std::chrono::milliseconds RELEASE_LOCK_TIMEOUT{ 300 }; + constexpr size_t HEALTH_CHECK_ACTIVE_THRESHOLD = 20; // Keep health check + constexpr int HEALTH_CHECK_INTERVAL = 30; // Keep health check interval +} +// --- End Constants --- + VideoPool::PoolMap VideoPool::pools_; std::shared_mutex VideoPool::mapMutex_; @@ -32,311 +44,407 @@ VideoPool::PoolInfo* VideoPool::getPoolInfo(int monitor, int listId) { // Try read-only access first { std::shared_lock readLock(mapMutex_); - if (pools_.count(monitor) && pools_[monitor].count(listId)) { - return &pools_[monitor][listId]; + auto monitorIt = pools_.find(monitor); + if (monitorIt != pools_.end()) { + auto listIt = monitorIt->second.find(listId); + if (listIt != monitorIt->second.end()) { + return &listIt->second; + } } - } + } // readLock released here // Need to create new pool info, acquire write lock std::unique_lock writeLock(mapMutex_); - return &pools_[monitor][listId]; + // Check again after acquiring write lock (double-checked locking pattern) + auto monitorIt = pools_.find(monitor); + if (monitorIt != pools_.end()) { + auto listIt = monitorIt->second.find(listId); + if (listIt != monitorIt->second.end()) { + return &listIt->second; // Another thread created it + } + // Monitor exists, but listId doesn't + LOG_DEBUG("VideoPool", "Creating new pool entry for Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + return &monitorIt->second[listId]; // Create listId entry + } + // Neither monitor nor listId exists + LOG_DEBUG("VideoPool", "Creating new pool entry for Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + return &pools_[monitor][listId]; // Create monitor and listId entries } std::unique_ptr VideoPool::acquireVideo(int monitor, int listId, bool softOverlay) { if (listId == -1) { - return std::make_unique(monitor); + LOG_DEBUG("VideoPool", "Creating non-pooled instance (listId = -1). Monitor: " + std::to_string(monitor)); + auto instance = std::make_unique(monitor); + instance->setSoftOverlay(softOverlay); // Set properties if needed directly + return instance; // Return as IVideo } - // Periodically check health and trim excess instances (every 30th call) + // Periodic health check static std::atomic callCounter{ 0 }; - if (++callCounter % 30 == 0) { + if (callCounter.fetch_add(1, std::memory_order_relaxed) % HEALTH_CHECK_INTERVAL == (HEALTH_CHECK_INTERVAL - 1)) { if (!checkPoolHealth(monitor, listId)) { - // If health check fails, schedule cleanup + // Health check failed, cleanup resets the pool. cleanup(monitor, listId); - } - else { - // If health is good, just trim excess instances - trimExcessInstances(monitor, listId); + // Fall through to potentially create a new instance in a fresh pool } } PoolInfo* poolInfo = getPoolInfo(monitor, listId); - // Add backoff mechanism for lock acquisition - const int MAX_RETRIES = 5; - int retries = 0; - while (!poolInfo->poolMutex.try_lock_for(std::chrono::milliseconds(100))) { - if (++retries >= MAX_RETRIES) { - LOG_WARNING("VideoPool", "Lock timeout in acquireVideo. Creating fallback instance. Monitor: " + + // Acquire lock for the specific pool with backoff + std::unique_lock poolLock; + for (int retries = 0; ; ++retries) { + if (poolInfo->poolMutex.try_lock_for(ACQUIRE_LOCK_TIMEOUT)) { + poolLock = std::unique_lock(poolInfo->poolMutex, std::adopt_lock); + break; + } + if (retries >= ACQUIRE_MAX_RETRIES) { + LOG_WARNING("VideoPool", "Lock timeout in acquireVideo. Creating temporary fallback instance. Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - return std::make_unique(monitor); + // Create a temporary instance, don't modify pool state otherwise. + // It will be destroyed when the caller's unique_ptr goes out of scope. + auto fallbackInstance = std::make_unique(monitor); + fallbackInstance->setSoftOverlay(softOverlay); + return fallbackInstance; // Return as IVideo } - std::this_thread::sleep_for(std::chrono::milliseconds(20 * retries)); // Exponential backoff + std::this_thread::sleep_for(ACQUIRE_BASE_BACKOFF * (1 << retries)); } - std::unique_lock poolLock(poolInfo->poolMutex, std::adopt_lock); + // --- Lock Acquired --- - // If not initialized yet, create new instances freely - if (!poolInfo->poolInitialized.load()) { - poolInfo->currentActive.fetch_add(1); - LOG_DEBUG("VideoPool", "Creating initial instance. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - return std::make_unique(monitor); - } + std::unique_ptr vid = nullptr; + bool createdNew = false; - // If we haven't created our +1 extra instance yet, do it now - if (!poolInfo->hasExtraInstance.load()) { - poolInfo->hasExtraInstance.store(true); - poolInfo->currentActive.fetch_add(1); - LOG_DEBUG("VideoPool", "Creating +1 extra instance. Monitor: " + + // 1. Check if available in the pool + if (!poolInfo->instances.empty()) { + vid = std::move(poolInfo->instances.front()); + poolInfo->instances.pop_front(); + poolInfo->currentActive.fetch_add(1, std::memory_order_relaxed); + LOG_DEBUG("VideoPool", "Reusing instance from pool. Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - return std::make_unique(monitor); } + else { + // 2. Pool empty. Determine if we need to create or wait. + size_t currentActive = poolInfo->currentActive.load(std::memory_order_relaxed); + size_t currentPooled = poolInfo->instances.size(); // Will be 0 here + size_t currentTotal = currentActive + currentPooled; - auto waitResult = poolInfo->waitCondition.wait_for(poolLock, - std::chrono::milliseconds(500), - [poolInfo]() { return !poolInfo->instances.empty(); }); - - if (!waitResult) { - // Timed out waiting for an instance - LOG_WARNING("VideoPool", "Timed out waiting for video instance. Creating new instance. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - poolInfo->currentActive.fetch_add(1); - return std::make_unique(monitor); - } + size_t targetTotalInstances; + bool isLatched = poolInfo->initialCountLatched.load(std::memory_order_relaxed); - std::unique_ptr vid = std::move(poolInfo->instances.front()); - poolInfo->instances.pop_front(); - vid->setSoftOverlay(softOverlay); - poolInfo->currentActive.fetch_add(1); + if (isLatched) { + // Target is fixed after latching + targetTotalInstances = poolInfo->requiredInstanceCount.load(std::memory_order_relaxed) + 1; + } + else { + // Before latching, target grows with observed max (minimum 1 means target is at least 2) + // We only create *new* ones before latching, up to the observed peak + 1 + size_t observedMax = poolInfo->observedMaxActive.load(std::memory_order_relaxed); + targetTotalInstances = std::max(observedMax, currentTotal) + 1; // Ensure target includes current request + // Use currentTotal in max ensures we try to create if observedMax is stale/low + } - // After incrementing currentActive, update observedMaxActive if needed - size_t newActiveCount = poolInfo->currentActive.load(); - size_t currentMax = poolInfo->observedMaxActive.load(); - if (newActiveCount > currentMax) { - poolInfo->observedMaxActive.store(newActiveCount); + if (currentTotal < targetTotalInstances) { + // Need to create a new instance (either pre-latch growth or post-latch replenishment) + poolInfo->currentActive.fetch_add(1, std::memory_order_relaxed); // Increment before creating + createdNew = true; + LOG_DEBUG("VideoPool", "Creating new instance (pool empty, below target). Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId) + + ", ActiveCountAfter: " + std::to_string(currentActive + 1) + + ", TargetTotal: " + std::to_string(targetTotalInstances) + + ", Latched: " + (isLatched ? "Yes" : "No")); + // Create the instance *after* unlocking + } + else { + // Pool is empty, but we have reached the target total. Wait for one to be released. + LOG_DEBUG("VideoPool", "Pool empty, target reached. Waiting for instance. Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId) + + ", TargetTotal: " + std::to_string(targetTotalInstances)); + + if (poolInfo->waitCondition.wait_for(poolLock, ACQUIRE_WAIT_TIMEOUT, + [poolInfo]() { return !poolInfo->instances.empty(); })) + { + // Got an instance after waiting + vid = std::move(poolInfo->instances.front()); + poolInfo->instances.pop_front(); + poolInfo->currentActive.fetch_add(1, std::memory_order_relaxed); + LOG_DEBUG("VideoPool", "Reusing instance from pool after wait. Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + } + else { + // Timed out waiting. Create a temporary fallback. + LOG_WARNING("VideoPool", "Timed out waiting for video instance. Creating temporary fallback. Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + // Unlock before creating fallback + poolLock.unlock(); + auto fallbackInstance = std::make_unique(monitor); + fallbackInstance->setSoftOverlay(softOverlay); + return fallbackInstance; // Return as IVideo + } + } } - LOG_DEBUG("VideoPool", "Reusing instance from pool. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - return std::unique_ptr(std::move(vid)); -} + // Update observedMaxActive *before* latching occurs + if (!poolInfo->initialCountLatched.load(std::memory_order_relaxed)) { + size_t currentActiveAfterUpdate = poolInfo->currentActive.load(std::memory_order_relaxed); + size_t currentMax = poolInfo->observedMaxActive.load(std::memory_order_relaxed); + // Update observedMaxActive if the new active count is higher + while (currentActiveAfterUpdate > currentMax) { + if (poolInfo->observedMaxActive.compare_exchange_weak(currentMax, currentActiveAfterUpdate, std::memory_order_relaxed)) break; + // Reload currentMax if CAS failed due to contention + currentMax = poolInfo->observedMaxActive.load(std::memory_order_relaxed); + } + } -void VideoPool::releaseVideo(std::unique_ptr vid, int monitor, int listId) { - if (!vid || listId == -1) return; + // Unlock mutex before creating the video instance or setting properties + poolLock.unlock(); - // Check if the instance encountered an error. - if (vid->hasError()) { - LOG_DEBUG("VideoPool", "Faulty video instance detected during release. Destroying instance. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - destroyVideo(std::move(vid), monitor, listId); - return; + if (createdNew) { + vid = std::make_unique(monitor); } - try { - vid->unload(); + // Set properties on the acquired/created instance + if (vid) { + vid->setSoftOverlay(softOverlay); } - catch (const std::exception& e) { - LOG_ERROR("VideoPool", "Exception during video unload: " + std::string(e.what()) + - ". Destroying instance."); - destroyVideo(std::move(vid), monitor, listId); - return; + else if (!createdNew) { + // This case should ideally not happen if logic is correct (either reused, created new, or returned fallback) + LOG_ERROR("VideoPool", "Internal error: Failed to acquire or create video instance unexpectedly."); + return nullptr; } - catch (...) { - LOG_ERROR("VideoPool", "Unknown exception during video unload. Destroying instance."); - destroyVideo(std::move(vid), monitor, listId); + + + return std::unique_ptr(std::move(vid)); +} + +void VideoPool::releaseVideo(std::unique_ptr vid, int monitor, int listId) { + // Check if it's a non-pooled instance (listId == -1) or null + // Note: Fallback instances created due to timeouts also won't have pool info + // associated implicitly, they just get destroyed by unique_ptr. + // We only handle instances that were originally acquired *with* a valid listId. + if (!vid || listId == -1) { + // Let unique_ptr handle destruction of non-pooled or fallback instances return; } PoolInfo* poolInfo = getPoolInfo(monitor, listId); + bool isFaulty = false; - if (!poolInfo->poolMutex.try_lock_for(std::chrono::milliseconds(300))) { - LOG_WARNING("VideoPool", "Lock timeout in releaseVideo. Destroying instance. Monitor: " + + // Check for errors before attempting unload + if (vid->hasError()) { + LOG_WARNING("VideoPool", "Faulty video instance detected during release. Discarding. Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - destroyVideo(std::move(vid), monitor, listId); - return; + isFaulty = true; + } + else { + // Try to unload cleanly + try { + vid->unload(); + } + catch (const std::exception& e) { + LOG_ERROR("VideoPool", "Exception during video unload: " + std::string(e.what()) + + ". Discarding instance. Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + isFaulty = true; + } + catch (...) { + LOG_ERROR("VideoPool", "Unknown exception during video unload. Discarding instance. Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + isFaulty = true; + } } - std::unique_lock poolLock(poolInfo->poolMutex, std::adopt_lock); - // Decrement the active count under lock so that the instance can be accounted for. - poolInfo->currentActive.fetch_sub(1); + // Lock the pool + std::unique_lock poolLock; + if (!poolInfo->poolMutex.try_lock_for(RELEASE_LOCK_TIMEOUT)) { + LOG_WARNING("VideoPool", "Lock timeout in releaseVideo. Discarding instance. Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + return; // Let unique_ptr destroy vid + } + poolLock = std::unique_lock(poolInfo->poolMutex, std::adopt_lock); - // On the first release, initialize the pool and set initial observed max - if (!poolInfo->poolInitialized.load()) { - poolInfo->poolInitialized.store(true); - poolInfo->instances.push_back(std::move(vid)); + // --- Lock Acquired --- - // Initial observed max should be at least 1 - size_t currentActive = poolInfo->currentActive.load(); - poolInfo->observedMaxActive.store(std::max(currentActive, size_t(1))); + // Decrement active count regardless of fault status + poolInfo->currentActive.fetch_sub(1, std::memory_order_relaxed); + size_t activeCountAfterDecrement = poolInfo->currentActive.load(std::memory_order_relaxed); // Read decremented value - LOG_DEBUG("VideoPool", "First release detected for Monitor: " + + if (isFaulty) { + // Faulty instance: just discard. Replacement happens on demand in acquireVideo. + LOG_DEBUG("VideoPool", "Discarded faulty instance. Active count decremented. Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - poolInfo->waitCondition.notify_one(); - return; + // Let unique_ptr destroy vid when it goes out of scope after unlock } + else { + // Healthy instance: return to pool and potentially latch/create buffer. + + bool countWasLatched = poolInfo->initialCountLatched.load(std::memory_order_acquire); + + // Latch the required count on the *first* successful release + if (!countWasLatched) { + size_t peakCount = poolInfo->observedMaxActive.load(std::memory_order_relaxed); + size_t requiredCount = std::max(peakCount, size_t(1)); + poolInfo->requiredInstanceCount.store(requiredCount, std::memory_order_relaxed); + poolInfo->initialCountLatched.store(true, std::memory_order_release); + countWasLatched = true; // Mark as latched for logic below + LOG_INFO("VideoPool", "Initial instance count latched for Monitor: " + std::to_string(monitor) + + ", List ID: " + std::to_string(listId) + ". Required count: " + std::to_string(requiredCount) + + " (Pool target total: " + std::to_string(requiredCount + 1) + ")"); + } - // Return the instance to the pool. - poolInfo->instances.push_back(std::move(vid)); - poolInfo->waitCondition.notify_one(); - LOG_DEBUG("VideoPool", "Instance added to pool. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); -} - -void VideoPool::cleanup(int monitor, int listId) { - if (listId == -1) return; + // Add the healthy, unloaded instance back to the pool (becomes most recently idle) + poolInfo->instances.push_back(std::move(vid)); + size_t pooledCountAfterAdd = poolInfo->instances.size(); // Includes the one just added - std::unique_lock mapLock(mapMutex_); + LOG_DEBUG("VideoPool", "Instance returned to pool. Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - // Log the cleanup start - LOG_DEBUG("VideoPool", "Starting cleanup for Monitor: " + std::to_string(monitor) + - ", List ID: " + std::to_string(listId)); - if (pools_.count(monitor) && pools_[monitor].count(listId)) { - PoolInfo& poolInfo = pools_[monitor][listId]; - - // Log pool state before cleanup - LOG_DEBUG("VideoPool", "Pool state before cleanup - Active: " + - std::to_string(poolInfo.currentActive.load()) + - ", Instances: " + std::to_string(poolInfo.instances.size())); - - // Clear all instances - poolInfo.instances.clear(); - - // Reset pool state - poolInfo.poolInitialized.store(false); - poolInfo.hasExtraInstance.store(false); - poolInfo.currentActive.store(0); - poolInfo.observedMaxActive.store(0); // Reset observed maximum - - // Remove from maps - pools_[monitor].erase(listId); - if (pools_[monitor].empty()) { - pools_.erase(monitor); + // --- Proactive Buffer Creation Logic --- + if (countWasLatched) { + size_t currentTotal = activeCountAfterDecrement + pooledCountAfterAdd; + size_t requiredCount = poolInfo->requiredInstanceCount.load(std::memory_order_relaxed); + size_t targetTotal = requiredCount + 1; + + // If current total is less than the target (N+1), create the missing buffer instance(s) proactively. + // This usually happens right after latching when total = N. + if (currentTotal < targetTotal) { + // In theory, could be < targetTotal by more than 1 if multiple instances + // were faulty and discarded previously, but we top up to targetTotal. + size_t needed = targetTotal - currentTotal; + LOG_INFO("VideoPool", "Proactively creating " + std::to_string(needed) + + " buffer instance(s) to reach target " + std::to_string(targetTotal) + + ". Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + for (size_t i = 0; i < needed; ++i) { + // Add new idle instances to the back (they become most-recently-idle) + poolInfo->instances.push_back(std::make_unique(monitor)); + } + } } + // --- End Proactive Buffer Creation --- - LOG_DEBUG("VideoPool", "Completed cleanup for List ID: " + std::to_string(listId)); - } -} - -void VideoPool::shutdown() { - std::unique_lock mapLock(mapMutex_); + // Notify one waiting thread (if any) that an instance is available + // Do this AFTER potential buffer creation and BEFORE unlocking is safest practice. + // poolLock still held here. + poolInfo->waitCondition.notify_one(); // Notify *under lock* recommended for CVs - for (auto& [monitor, listPools] : pools_) { - for (auto& [listId, poolInfo] : listPools) { - if (!poolInfo.poolMutex.try_lock()) { - LOG_WARNING("VideoPool", "Skipping busy pool during shutdown..."); - continue; - } - std::lock_guard poolLock(poolInfo.poolMutex, std::adopt_lock); + } // End of healthy instance handling - // instances will clear automatically due to unique_ptr - poolInfo.instances.clear(); - poolInfo.currentActive.store(0); - poolInfo.poolInitialized.store(false); - poolInfo.hasExtraInstance.store(false); - } - listPools.clear(); - } - pools_.clear(); + poolLock.unlock(); // Explicitly unlock before returning - LOG_DEBUG("VideoPool", "VideoPool shutdown complete"); + // If vid was faulty, it gets destroyed here by unique_ptr going out of scope. + // If it was healthy, it was moved into the pool's deque. } -void VideoPool::destroyVideo(std::unique_ptr vid, int monitor, int listId) { - if (!vid) return; - - PoolInfo* poolInfo = getPoolInfo(monitor, listId); - // Skip if we can't get a lock - we'll fix it later - if (!poolInfo->poolMutex.try_lock_for(std::chrono::milliseconds(100))) { - // Decrement the counter even if we can't lock - poolInfo->currentActive.fetch_sub(1); - LOG_DEBUG("VideoPool", "Destroyed video instance without lock. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); - return; - } +void VideoPool::cleanup(int monitor, int listId) { + if (listId == -1) return; - std::unique_lock poolLock(poolInfo->poolMutex, std::adopt_lock); + std::unique_lock mapLock(mapMutex_); // Lock the map first - // Decrement active count - poolInfo->currentActive.fetch_sub(1); + LOG_DEBUG("VideoPool", "Starting cleanup for Monitor: " + std::to_string(monitor) + + ", List ID: " + std::to_string(listId)); - // Determine if we need to replace this instance in the pool - if (poolInfo->poolInitialized.load()) { - // Calculate target pool size (observed max + 1) - size_t targetTotal = poolInfo->observedMaxActive.load() + 1; + auto monitorIt = pools_.find(monitor); + if (monitorIt != pools_.end()) { + auto listIt = monitorIt->second.find(listId); + if (listIt != monitorIt->second.end()) { + PoolInfo& poolInfo = listIt->second; + + // Lock the specific pool before modifying + std::unique_lock poolLock; + if (!poolInfo.poolMutex.try_lock_for(RELEASE_LOCK_TIMEOUT)) { // Use a reasonable timeout + LOG_WARNING("VideoPool", "Could not lock pool during cleanup for Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId) + + ". Skipping detailed cleanup, removing from map."); + // Remove from maps even if we can't lock it + monitorIt->second.erase(listIt); + if (monitorIt->second.empty()) { + pools_.erase(monitorIt); + } + return; // Exit, mapLock releases + } + poolLock = std::unique_lock(poolInfo.poolMutex, std::adopt_lock); + // --- Pool Lock Acquired --- - // Calculate current total (active + pooled) - size_t currentActive = poolInfo->currentActive.load(); - size_t currentPooled = poolInfo->instances.size(); - size_t currentTotal = currentActive + currentPooled; + LOG_DEBUG("VideoPool", "Pool state before cleanup - Active: " + + std::to_string(poolInfo.currentActive.load()) + + ", Pooled: " + std::to_string(poolInfo.instances.size())); - // If we're now below target, create a replacement - if (currentTotal < targetTotal) { - LOG_DEBUG("VideoPool", "Creating replacement for destroyed instance. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + poolInfo.instances.clear(); // unique_ptrs handle destruction - // Add a fresh instance to the pool - poolInfo->instances.push_back(std::make_unique(monitor)); + // Reset pool state safely under lock + poolInfo.currentActive.store(0, std::memory_order_relaxed); + poolInfo.observedMaxActive.store(0, std::memory_order_relaxed); // Reset pre-latch counter too + poolInfo.initialCountLatched.store(false, std::memory_order_relaxed); // Reset latch state + poolInfo.requiredInstanceCount.store(0, std::memory_order_relaxed); // Reset required count - // Notify any waiting threads - poolInfo->waitCondition.notify_one(); - } - } + // Pool lock releases automatically - LOG_DEBUG("VideoPool", "Destroyed faulty video instance. Monitor: " + - std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + // Remove from maps (while mapLock is still held) + monitorIt->second.erase(listIt); + if (monitorIt->second.empty()) { + pools_.erase(monitorIt); + } - // The unique_ptr will be destroyed when it goes out of scope + LOG_DEBUG("VideoPool", "Completed cleanup for Monitor: " + std::to_string(monitor) + + ", List ID: " + std::to_string(listId)); + } // else: listId not found, ignore + } // else: monitor not found, ignore + // mapLock releases here } -void VideoPool::trimExcessInstances(int monitor, int listId) { - if (listId == -1) return; +void VideoPool::shutdown() { + std::unique_lock mapLock(mapMutex_); // Lock the main map - PoolInfo* poolInfo = getPoolInfo(monitor, listId); + LOG_INFO("VideoPool", "Starting VideoPool shutdown..."); // Use INFO level for shutdown - // Try to lock with timeout - if can't get lock, just skip trimming - if (!poolInfo->poolMutex.try_lock_for(std::chrono::milliseconds(100))) { - return; - } + for (auto itMon = pools_.begin(); itMon != pools_.end(); /* no increment here */) { + int monitor = itMon->first; + auto& listPools = itMon->second; + for (auto itList = listPools.begin(); itList != listPools.end(); /* no increment here */) { + int listId = itList->first; + PoolInfo& poolInfo = itList->second; - std::unique_lock poolLock(poolInfo->poolMutex, std::adopt_lock); + LOG_DEBUG("VideoPool", "Shutting down pool for Monitor: " + std::to_string(monitor) + + ", List ID: " + std::to_string(listId)); - // Don't trim if there are active users waiting - if (poolInfo->waitCondition.wait_for(poolLock, std::chrono::milliseconds(0), - []() { return true; }) == false) { - return; - } - - // Get the current active count and update the observed maximum - size_t currentActive = poolInfo->currentActive.load(); - size_t observedMax = poolInfo->observedMaxActive.load(); + // Try to lock the individual pool + if (!poolInfo.poolMutex.try_lock()) { + LOG_WARNING("VideoPool", "Skipping busy pool during shutdown: Monitor: " + + std::to_string(monitor) + ", List ID: " + std::to_string(listId) + + ". Instances may not be cleaned up immediately."); + ++itList; // Move to next listId for this monitor + continue; // Skip this pool + } + // --- Pool Lock Acquired --- + std::lock_guard poolLock(poolInfo.poolMutex, std::adopt_lock); + poolInfo.instances.clear(); // Clear instances (unique_ptrs handle destruction) + // Resetting state is optional here as the entry will be removed, but doesn't hurt + poolInfo.currentActive.store(0); + poolInfo.observedMaxActive.store(0); + poolInfo.initialCountLatched.store(false); + poolInfo.requiredInstanceCount.store(0); - if (currentActive > observedMax) { - poolInfo->observedMaxActive.store(currentActive); - observedMax = currentActive; - } + // Pool lock releases automatically - // Target size = observed maximum + 1 extra instance (for safety) - // But never go below 2 instances minimum - size_t targetSize = std::max(observedMax + 1, size_t(2)); + // Erase the current listId entry and advance the iterator + itList = listPools.erase(itList); + } // End inner loop (listIds) - // Keep the pool size reasonable - size_t currentPoolSize = poolInfo->instances.size(); + // If the inner map is now empty after erasing, erase the monitor entry + if (listPools.empty()) { + LOG_DEBUG("VideoPool", "Removing empty monitor entry during shutdown: " + std::to_string(monitor)); + itMon = pools_.erase(itMon); + } + else { + ++itMon; // Otherwise, just move to the next monitor + } + } // End outer loop (monitors) - // Only trim if we have substantially more instances than needed - // This prevents constant resizing for small fluctuations - if (currentPoolSize > targetSize + 2) { - size_t excessCount = currentPoolSize - targetSize; - LOG_DEBUG("VideoPool", "Trimming " + std::to_string(excessCount) + - " excess instances (keeping " + std::to_string(targetSize) + - ") for Monitor: " + std::to_string(monitor) + ", List ID: " + std::to_string(listId)); + // pools_.clear(); // No longer needed, erase handles removal - while (poolInfo->instances.size() > targetSize) { - poolInfo->instances.pop_back(); // Remove oldest instances first - } - } + LOG_INFO("VideoPool", "VideoPool shutdown complete"); // Use INFO level + // mapLock releases here } bool VideoPool::checkPoolHealth(int monitor, int listId) { diff --git a/RetroFE/Source/Video/VideoPool.h b/RetroFE/Source/Video/VideoPool.h index 21f4d2241..e3fcd6498 100644 --- a/RetroFE/Source/Video/VideoPool.h +++ b/RetroFE/Source/Video/VideoPool.h @@ -34,21 +34,19 @@ class VideoPool { static void releaseVideo(std::unique_ptr vid, int monitor, int listId); static void cleanup(int monitor, int listId); static void shutdown(); - static void destroyVideo(std::unique_ptr vid, int monitor, int listId); // Health check method static bool checkPoolHealth(int monitor, int listId); - // Trim excess instances, but determine target size dynamically - static void trimExcessInstances(int monitor, int listId); private: struct PoolInfo { std::deque> instances; std::atomic currentActive{0}; - std::atomic poolInitialized{false}; - std::atomic hasExtraInstance{false}; std::timed_mutex poolMutex; std::condition_variable_any waitCondition; // Add this line std::atomic observedMaxActive{ 0 }; // Track observed maximum active instances + std::atomic initialCountLatched{false}; + std::atomic requiredInstanceCount{0}; // The latched target count (excluding the +1 buffer) + PoolInfo() = default; PoolInfo(const PoolInfo&) = delete; PoolInfo& operator=(const PoolInfo&) = delete; diff --git a/RetroFE/xcode/retrofe.xcodeproj/project.pbxproj b/RetroFE/xcode/retrofe.xcodeproj/project.pbxproj index 7cddfdb1b..085c99e18 100644 --- a/RetroFE/xcode/retrofe.xcodeproj/project.pbxproj +++ b/RetroFE/xcode/retrofe.xcodeproj/project.pbxproj @@ -59,6 +59,8 @@ DF010F002B4DDDAA0062DCCA /* SDL2.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DFF91E9C2B260B5B00507957 /* SDL2.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF07B3E52B2A14CE00B732CF /* GStreamer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF07B3E42B2A14CE00B732CF /* GStreamer.framework */; }; DF07B3E62B2A14CE00B732CF /* GStreamer.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DF07B3E42B2A14CE00B732CF /* GStreamer.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DF20B2A52DAA8F7F00CE3B74 /* MusicPlayer.cpp in Sources */ = {isa = PBXBuildFile; fileRef = DF20B2A42DAA8F7F00CE3B74 /* MusicPlayer.cpp */; }; + DF20B2A82DAA8FA400CE3B74 /* MusicPlayerComponent.cpp in Sources */ = {isa = PBXBuildFile; fileRef = DF20B2A72DAA8FA400CE3B74 /* MusicPlayerComponent.cpp */; }; DF2B282E2B683F2E00A22011 /* GlobalOpts.cpp in Sources */ = {isa = PBXBuildFile; fileRef = DF2B282C2B683F2E00A22011 /* GlobalOpts.cpp */; }; DF87A17A2B152AAB00548E78 /* RetroFE.png in Resources */ = {isa = PBXBuildFile; fileRef = DF87A1792B152AA400548E78 /* RetroFE.png */; }; DFC83C8A2D57973600AA7522 /* webp.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DFC83C882D57973600AA7522 /* webp.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -184,6 +186,10 @@ DF010EF72B4DD8150062DCCA /* libSystem.B.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libSystem.B.tbd; path = ../../../../../../../Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.0.sdk/usr/lib/libSystem.B.tbd; sourceTree = SOURCE_ROOT; }; DF010EF82B4DD81C0062DCCA /* libz.1.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.1.tbd; path = ../../../../../../../Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.0.sdk/usr/lib/libz.1.tbd; sourceTree = SOURCE_ROOT; }; DF07B3E42B2A14CE00B732CF /* GStreamer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GStreamer.framework; path = ../ThirdPartyMac/GStreamer.framework; sourceTree = SOURCE_ROOT; }; + DF20B2A42DAA8F7F00CE3B74 /* MusicPlayer.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = MusicPlayer.cpp; path = Sound/MusicPlayer.cpp; sourceTree = ""; }; + DF20B2A62DAA8F8500CE3B74 /* MusicPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MusicPlayer.h; path = Sound/MusicPlayer.h; sourceTree = ""; }; + DF20B2A72DAA8FA400CE3B74 /* MusicPlayerComponent.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = MusicPlayerComponent.cpp; path = Graphics/Component/MusicPlayerComponent.cpp; sourceTree = ""; }; + DF20B2A92DAA8FAB00CE3B74 /* MusicPlayerComponent.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MusicPlayerComponent.h; path = Graphics/Component/MusicPlayerComponent.h; sourceTree = ""; }; DF2B282C2B683F2E00A22011 /* GlobalOpts.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = GlobalOpts.cpp; path = Database/GlobalOpts.cpp; sourceTree = SOURCE_ROOT; }; DF2B282D2B683F2E00A22011 /* GlobalOpts.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = GlobalOpts.h; path = Database/GlobalOpts.h; sourceTree = SOURCE_ROOT; }; DF5A44672DABDD0B005892D0 /* versioning.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = versioning.h; path = autogen/versioning.h; sourceTree = ""; }; @@ -300,6 +306,8 @@ DFC83C952D579B6100AA7522 /* Metadata.h */, A439BE1557574981A00E8313 /* MetadataDatabase.h */, E068482BE58D4293AB7C541D /* MouseButtonHandler.h */, + DF20B2A62DAA8F8500CE3B74 /* MusicPlayer.h */, + DF20B2A92DAA8FAB00CE3B74 /* MusicPlayerComponent.h */, 12BF4352BCFF41758DA92D86 /* Page.h */, 41EA3E9FBEB9441ABFA62BF1 /* PageBuilder.h */, DFC83C9B2D579D3100AA7522 /* ReloadableHiscores.h */, @@ -368,7 +376,6 @@ F1EBF96675C5443E881F32F0 /* Source Files */ = { isa = PBXGroup; children = ( - DFC83C9C2D579D7F00AA7522 /* VideoPool.cpp */, 265E7B256A954D8793198331 /* Animation.cpp */, F7BFDD750A8D4FD2BD04E3E8 /* AnimationEvents.cpp */, C10786B3822040D9BB915594 /* AttractMode.cpp */, @@ -399,6 +406,8 @@ F23BC20492D84B5F8ACB6AA0 /* MenuParser.cpp */, 77848D7AD6354A199ED9DEAF /* MetadataDatabase.cpp */, 5BE2DB1B42B5406981272867 /* MouseButtonHandler.cpp */, + DF20B2A42DAA8F7F00CE3B74 /* MusicPlayer.cpp */, + DF20B2A72DAA8FA400CE3B74 /* MusicPlayerComponent.cpp */, AD426A12CF1C43358CC9F6CE /* Page.cpp */, A55969FD734F4923ADE5A6B9 /* PageBuilder.cpp */, DFC83C992D579D2B00AA7522 /* ReloadableHiscores.cpp */, @@ -419,6 +428,7 @@ 5C2402E96D2D400793F40054 /* VideoBuilder.cpp */, F83482D74B334D73A7832224 /* VideoComponent.cpp */, A1A2A509107048D78E451632 /* VideoFactory.cpp */, + DFC83C9C2D579D7F00AA7522 /* VideoPool.cpp */, 4AE8BEDF614B479688422071 /* ViewInfo.cpp */, F916B85C7BF943D3AD1C1864 /* CollectionInfoBuilder.cpp */, ); @@ -532,6 +542,7 @@ FF9D1396B0DD42E4822D0657 /* Tween.cpp in Sources */, DFF5D9582B6F9EFA005E9600 /* ThreadPool.cpp in Sources */, 516567856F9048499ECB7208 /* TweenSet.cpp in Sources */, + DF20B2A52DAA8F7F00CE3B74 /* MusicPlayer.cpp in Sources */, 6EC24D1FB1AA4DD8A981E54A /* Component.cpp in Sources */, B6A3D783C1AF4CD798EBBDDA /* Container.cpp in Sources */, 3C1EAA65CDC04D80A105A4D3 /* Image.cpp in Sources */, @@ -550,6 +561,7 @@ FEC7E2567C954EC295AC1C94 /* FontCache.cpp in Sources */, 260456C56F8545EBA1082E33 /* Page.cpp in Sources */, A8B5CA925EC049F788150D46 /* PageBuilder.cpp in Sources */, + DF20B2A82DAA8FA400CE3B74 /* MusicPlayerComponent.cpp in Sources */, 274AF9AE37B744F58D51EB13 /* ViewInfo.cpp in Sources */, B332BB68337544B4B47814BA /* Main.cpp in Sources */, 99242A2BB8BA4919A4202B9C /* Menu.cpp in Sources */,