From a947eb1e3bf8ce8a375159850b0231bba0893ff5 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Mon, 23 Feb 2026 15:37:50 -0800 Subject: [PATCH 01/16] Add non-blocking async image loading to prevent UI stalls on NAS Introduces setImageAsync() on ImageComponent that skips the stat64-backed fileExists() check, bypassing the NAS latency that was freezing the main thread on every game selection. The path is handed directly to TextureResource with block=false so the background TextureLoader thread handles the actual file I/O. Adds mAsyncPending flag; update() polls updateTextureSize() each frame and calls resize() once the background thread finishes loading, producing a smooth fade-in rather than a hard stall. Also caps the deltaTime fed into IList's scroll accumulator to one scroll tier interval, preventing multiple cursor jumps after a long-blocking frame. Updates DetailedGameListView, GridGameListView, VideoGameListView, and VideoComponent to call setImageAsync() for all per-game media. Co-Authored-By: Claude Sonnet 4.6 --- .../views/gamelist/DetailedGameListView.cpp | 6 +- .../src/views/gamelist/GridGameListView.cpp | 6 +- .../src/views/gamelist/VideoGameListView.cpp | 8 +-- es-core/src/components/IList.h | 9 ++- es-core/src/components/ImageComponent.cpp | 68 ++++++++++++++++++- es-core/src/components/ImageComponent.h | 6 ++ es-core/src/components/VideoComponent.cpp | 15 ++++ es-core/src/components/VideoComponent.h | 2 + es-core/src/resources/TextureDataManager.cpp | 16 ++++- es-core/src/resources/TextureResource.cpp | 56 ++++++++++++--- es-core/src/resources/TextureResource.h | 8 ++- 11 files changed, 171 insertions(+), 29 deletions(-) diff --git a/es-app/src/views/gamelist/DetailedGameListView.cpp b/es-app/src/views/gamelist/DetailedGameListView.cpp index ecac95c3bd..da55f7efa8 100644 --- a/es-app/src/views/gamelist/DetailedGameListView.cpp +++ b/es-app/src/views/gamelist/DetailedGameListView.cpp @@ -221,9 +221,9 @@ void DetailedGameListView::updateInfoPanel() //mDescription.setText(""); fadingOut = true; }else{ - mThumbnail.setImage(file->getThumbnailPath()); - mMarquee.setImage(file->getMarqueePath()); - mImage.setImage(file->getImagePath()); + mThumbnail.setImageAsync(file->getThumbnailPath()); + mImage.setImageAsync(file->getImagePath()); + mMarquee.setImageAsync(file->getMarqueePath()); mDescription.setText(file->metadata.get("desc")); mDescContainer.reset(); diff --git a/es-app/src/views/gamelist/GridGameListView.cpp b/es-app/src/views/gamelist/GridGameListView.cpp index c013fa3284..10aa7b7434 100644 --- a/es-app/src/views/gamelist/GridGameListView.cpp +++ b/es-app/src/views/gamelist/GridGameListView.cpp @@ -323,9 +323,9 @@ void GridGameListView::updateInfoPanel() } mVideoPlaying = true; - mVideo->setImage(file->getThumbnailPath()); - mMarquee.setImage(file->getMarqueePath()); - mImage.setImage(file->getImagePath()); + mVideo->setImageAsync(file->getThumbnailPath()); + mMarquee.setImageAsync(file->getMarqueePath()); + mImage.setImageAsync(file->getImagePath()); mDescription.setText(file->metadata.get("desc")); mDescContainer.reset(); diff --git a/es-app/src/views/gamelist/VideoGameListView.cpp b/es-app/src/views/gamelist/VideoGameListView.cpp index e26715c774..632b2de0a0 100644 --- a/es-app/src/views/gamelist/VideoGameListView.cpp +++ b/es-app/src/views/gamelist/VideoGameListView.cpp @@ -265,10 +265,10 @@ void VideoGameListView::updateInfoPanel() } mVideoPlaying = true; - mVideo->setImage(file->getThumbnailPath()); - mThumbnail.setImage(file->getThumbnailPath()); - mMarquee.setImage(file->getMarqueePath()); - mImage.setImage(file->getImagePath()); + mVideo->setImageAsync(file->getThumbnailPath()); + mThumbnail.setImageAsync(file->getThumbnailPath()); + mMarquee.setImageAsync(file->getMarqueePath()); + mImage.setImageAsync(file->getImagePath()); mDescription.setText(file->metadata.get("desc")); mDescContainer.reset(); diff --git a/es-core/src/components/IList.h b/es-core/src/components/IList.h index 67f0ee22f9..4458ed7fc0 100644 --- a/es-core/src/components/IList.h +++ b/es-core/src/components/IList.h @@ -240,8 +240,13 @@ class IList : public GuiComponent if(mScrollVelocity == 0 || size() < 2) return; - mScrollCursorAccumulator += deltaTime; - mScrollTierAccumulator += deltaTime; + // Cap the delta time used for scrolling to prevent multiple scroll jumps + // after a long-blocking frame (e.g., slow NAS I/O) + const int maxScrollDelta = mTierList.tiers[mScrollTier].scrollDelay; + int scrollDelta = (deltaTime > maxScrollDelta) ? maxScrollDelta : deltaTime; + + mScrollCursorAccumulator += scrollDelta; + mScrollTierAccumulator += scrollDelta; // we delay scrolling until after scroll tier has updated so isScrolling() returns accurately during onCursorChanged callbacks // we don't just do scroll tier first because it would not catch the scrollDelay == tier length case diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 823ceb570d..6c083c056d 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -21,7 +21,8 @@ Vector2f ImageComponent::getSize() const ImageComponent::ImageComponent(Window* window, bool forceLoad, bool dynamic) : GuiComponent(window), mTargetIsMax(false), mTargetIsMin(false), mFlipX(false), mFlipY(false), mTargetSize(0, 0), mColorShift(0xFFFFFFFF), mColorShiftEnd(0xFFFFFFFF), mColorGradientHorizontal(true), mForceLoad(forceLoad), mDynamic(dynamic), - mFadeOpacity(0), mFading(false), mRotateByTargetSize(false), mTopLeftCrop(0.0f, 0.0f), mBottomRightCrop(1.0f, 1.0f) + mFadeOpacity(0), mFading(false), mRotateByTargetSize(false), mTopLeftCrop(0.0f, 0.0f), mBottomRightCrop(1.0f, 1.0f), + mAsyncPending(false) { updateColors(); } @@ -132,6 +133,8 @@ void ImageComponent::setDefaultImage(std::string path) void ImageComponent::setImage(std::string path, bool tile) { + mAsyncPending = false; + if(path.empty() || !ResourceManager::getInstance()->fileExists(path)) { if(mDefaultPath.empty() || !ResourceManager::getInstance()->fileExists(mDefaultPath)) @@ -158,9 +161,63 @@ void ImageComponent::setImage(const char* path, size_t length, bool tile) void ImageComponent::setImage(const std::shared_ptr& texture) { mTexture = texture; + mAsyncPending = false; resize(); } +void ImageComponent::setImageAsync(std::string path, bool tile) +{ + mAsyncPending = false; + + // Skip the fileExists() check used by setImage() — it calls stat64 which blocks + // on NAS. Instead, just hand the path to TextureResource and let the background + // thread's load() handle missing files gracefully. + if(path.empty()) + { + if(mDefaultPath.empty()) + mTexture.reset(); + else + mTexture = TextureResource::get(mDefaultPath, tile, mForceLoad, mDynamic, false); + } else { + mTexture = TextureResource::get(path, tile, mForceLoad, mDynamic, false); + } + + if(mTexture) + { + // Check if the texture is already loaded (e.g. cache hit) + if(mTexture->updateTextureSize()) + { + LOG(LogDebug) << "setImageAsync: immediate load for " << path; + resize(); + } + else + { + // Texture is loading in background - resize() will be called from update() when ready + LOG(LogDebug) << "setImageAsync: queued async for " << path; + mAsyncPending = true; + } + } + else + { + LOG(LogDebug) << "setImageAsync: no texture for " << path; + } +} + +void ImageComponent::update(int deltaTime) +{ + GuiComponent::update(deltaTime); + + if(mAsyncPending && mTexture) + { + if(mTexture->updateTextureSize()) + { + LOG(LogDebug) << "ImageComponent::update: async load complete, calling resize. size=" << mTexture->getSize().x() << "x" << mTexture->getSize().y(); + mAsyncPending = false; + resize(); + } + } +} + void ImageComponent::setResize(float width, float height) { mTargetSize = Vector2f(width, height); @@ -325,7 +382,7 @@ void ImageComponent::render(const Transform4x4f& parentTrans) Transform4x4f trans = parentTrans * getTransform(); Renderer::setMatrix(trans); - if(mTexture && mOpacity > 0) + if(mTexture && mOpacity > 0 && !mAsyncPending) { if(Settings::getInstance()->getBool("DebugImage")) { Vector2f targetSizePos = (mTargetSize - mSize) * mOrigin * -1; @@ -346,6 +403,13 @@ void ImageComponent::render(const Transform4x4f& parentTrans) mTexture.reset(); } } + else if(mTexture && mAsyncPending) + { + // Debug: log when we're skipping render due to async pending + static int skipCount = 0; + if(++skipCount % 60 == 1) // log every ~1 second at 60fps + LOG(LogDebug) << "render: skipping due to mAsyncPending, mSize=" << mSize.x() << "x" << mSize.y() << " opacity=" << (int)mOpacity; + } GuiComponent::renderChildren(trans); } diff --git a/es-core/src/components/ImageComponent.h b/es-core/src/components/ImageComponent.h index ef6ab47c42..02579f4760 100644 --- a/es-core/src/components/ImageComponent.h +++ b/es-core/src/components/ImageComponent.h @@ -23,9 +23,14 @@ class ImageComponent : public GuiComponent //Use an already existing texture. void setImage(const std::shared_ptr& texture); + //Loads the image asynchronously in a background thread. The image will fade in when ready. + void setImageAsync(std::string path, bool tile = false); + void onSizeChanged() override; void setOpacity(unsigned char opacity) override; + void update(int deltaTime) override; + // Resize the image to fit this size. If one axis is zero, scale that axis to maintain aspect ratio. // If both are non-zero, potentially break the aspect ratio. If both are zero, no resizing. // Can be set before or after an image is loaded. @@ -104,6 +109,7 @@ class ImageComponent : public GuiComponent bool mForceLoad; bool mDynamic; bool mRotateByTargetSize; + bool mAsyncPending; Vector2f mTopLeftCrop; Vector2f mBottomRightCrop; diff --git a/es-core/src/components/VideoComponent.cpp b/es-core/src/components/VideoComponent.cpp index 98ea4d882a..ec6e55fc88 100644 --- a/es-core/src/components/VideoComponent.cpp +++ b/es-core/src/components/VideoComponent.cpp @@ -135,6 +135,17 @@ void VideoComponent::setImage(std::string path) mStaticImagePath = path; } +void VideoComponent::setImageAsync(std::string path) +{ + // Check that the image has changed + if (path == mStaticImagePath) + return; + + mStaticImage.setImageAsync(path); + mFadeIn = 0.0f; + mStaticImagePath = path; +} + void VideoComponent::setDefaultVideo() { setVideo(mConfig.defaultVideoPath); @@ -267,6 +278,10 @@ void VideoComponent::update(int deltaTime) { manageState(); + // mStaticImage is not a child, so we must pump its update manually + // to let its async-load polling (mAsyncPending) resolve. + mStaticImage.update(deltaTime); + // If the video start is delayed and there is less than the fade time then set the image fade // accordingly if (mStartDelayed) diff --git a/es-core/src/components/VideoComponent.h b/es-core/src/components/VideoComponent.h index 1c574a1fae..cd2bf49a05 100644 --- a/es-core/src/components/VideoComponent.h +++ b/es-core/src/components/VideoComponent.h @@ -31,6 +31,8 @@ class VideoComponent : public GuiComponent bool setVideo(std::string path); // Loads a static image that is displayed if the video cannot be played void setImage(std::string path); + // Loads a static image asynchronously in a background thread + void setImageAsync(std::string path); // Configures the component to show the default video void setDefaultVideo(); diff --git a/es-core/src/resources/TextureDataManager.cpp b/es-core/src/resources/TextureDataManager.cpp index 8eccdbdd3d..6d69652fc4 100644 --- a/es-core/src/resources/TextureDataManager.cpp +++ b/es-core/src/resources/TextureDataManager.cpp @@ -39,6 +39,8 @@ void TextureDataManager::remove(const TextureResource* key) auto it = mTextureLookup.find(key); if (it != mTextureLookup.cend()) { + // Cancel any pending async load for this texture + mLoader->remove(*(*it).second); // Remove the list entry mTextures.erase((*it).second); // And the lookup @@ -84,7 +86,12 @@ size_t TextureDataManager::getTotalSize() { size_t total = 0; for (auto tex : mTextures) - total += tex->width() * tex->height() * 4; + { + // Only count textures whose dimensions are known — calling width()/height() + // on an unloaded texture triggers a synchronous load(). + if (tex->isLoaded()) + total += tex->width() * tex->height() * 4; + } return total; } @@ -220,12 +227,15 @@ void TextureLoader::remove(std::shared_ptr textureData) size_t TextureLoader::getQueueSize() { // Gets the amount of video memory that will be used once all textures in - // the queue are loaded + // the queue are loaded. Only count textures whose dimensions are already + // known — calling width()/height() on an unloaded texture triggers a + // synchronous load() which blocks the main thread on NAS I/O. size_t mem = 0; std::unique_lock lock(mMutex); for (auto tex : mTextureDataQ) { - mem += tex->width() * tex->height() * 4; + if (tex->isLoaded()) + mem += tex->width() * tex->height() * 4; } return mem; } diff --git a/es-core/src/resources/TextureResource.cpp b/es-core/src/resources/TextureResource.cpp index 975e6f70d4..0d914e0338 100644 --- a/es-core/src/resources/TextureResource.cpp +++ b/es-core/src/resources/TextureResource.cpp @@ -7,7 +7,7 @@ TextureDataManager TextureResource::sTextureDataManager; std::map< TextureResource::TextureKeyType, std::weak_ptr > TextureResource::sTextureMap; std::set TextureResource::sAllTextures; -TextureResource::TextureResource(const std::string& path, bool tile, bool dynamic) : mTextureData(nullptr), mSize(0.0f, 0.0f), mSourceSize(0.0f, 0.0f), mForceLoad(false) +TextureResource::TextureResource(const std::string& path, bool tile, bool dynamic, bool block) : mTextureData(nullptr), mSize(0.0f, 0.0f), mSourceSize(0.0f, 0.0f), mForceLoad(false) { // Create a texture data object for this texture if (!path.empty()) @@ -19,20 +19,24 @@ TextureResource::TextureResource(const std::string& path, bool tile, bool dynami { data = sTextureDataManager.add(this, tile); data->initFromPath(path); - // Force the texture manager to load it using a blocking load - sTextureDataManager.load(data, true); + // Load the texture - either blocking or async via the background thread + sTextureDataManager.load(data, block); } else { mTextureData = std::shared_ptr(new TextureData(tile)); data = mTextureData; data->initFromPath(path); - // Load it so we can read the width/height + // Non-dynamic textures are always loaded immediately (blocking) data->load(); } - mSize = Vector2i((int)data->width(), (int)data->height()); - mSourceSize = Vector2f(data->sourceWidth(), data->sourceHeight()); + if (block || !dynamic) + { + mSize = Vector2i((int)data->width(), (int)data->height()); + mSourceSize = Vector2f(data->sourceWidth(), data->sourceHeight()); + } + // When block=false && dynamic, sizes stay at (0,0) until updateTextureSize() is called } else { @@ -100,11 +104,16 @@ bool TextureResource::bind() } } -std::shared_ptr TextureResource::get(const std::string& path, bool tile, bool forceLoad, bool dynamic) +std::shared_ptr TextureResource::get(const std::string& path, bool tile, bool forceLoad, bool dynamic, bool block) { std::shared_ptr& rm = ResourceManager::getInstance(); - const std::string canonicalPath = Utils::FileSystem::getCanonicalPath(path); + // Avoid getCanonicalPath here — it calls stat64/lstat64 on every path component + // to resolve symlinks, which blocks the main thread for tens of ms per call on NAS. + // Use getGenericPath (pure string normalization) instead. Built-in resource paths + // starting with ":/" are passed through as-is (matching getCanonicalPath behavior). + const std::string canonicalPath = (path.size() >= 2 && path[0] == ':' && path[1] == '/') + ? path : Utils::FileSystem::getGenericPath(path); if(canonicalPath.empty()) { std::shared_ptr tex(new TextureResource("", tile, false)); @@ -122,8 +131,10 @@ std::shared_ptr TextureResource::get(const std::string& path, b // need to create it std::shared_ptr tex; - tex = std::shared_ptr(new TextureResource(key.first, tile, dynamic)); - std::shared_ptr data = sTextureDataManager.get(tex.get()); + tex = std::shared_ptr(new TextureResource(key.first, tile, dynamic, block)); + // When block=false, pass enableLoading=false to avoid re-triggering a synchronous + // load — the constructor already queued it for async loading via TextureLoader. + std::shared_ptr data = sTextureDataManager.get(tex.get(), block); // is it an SVG? if(key.first.substr(key.first.size() - 4, std::string::npos) != ".svg") @@ -164,6 +175,31 @@ Vector2f TextureResource::getSourceImageSize() const return mSourceSize; } +bool TextureResource::updateTextureSize() +{ + // If sizes are already known, nothing to do + if (mSize != Vector2i(0, 0)) + return true; + + // Get the texture data without triggering a load + std::shared_ptr data; + if (mTextureData != nullptr) + data = mTextureData; + else + data = sTextureDataManager.get(this, false); + + if (data && data->isLoaded()) + { + // The background thread has finished loading - read dimensions now + // (safe because isLoaded() guarantees data is available via mutex ordering) + mSize = Vector2i((int)data->width(), (int)data->height()); + mSourceSize = Vector2f(data->sourceWidth(), data->sourceHeight()); + return true; + } + + return false; +} + bool TextureResource::isInitialized() const { return true; diff --git a/es-core/src/resources/TextureResource.h b/es-core/src/resources/TextureResource.h index c3c7e9dd29..e54a374df9 100644 --- a/es-core/src/resources/TextureResource.h +++ b/es-core/src/resources/TextureResource.h @@ -16,7 +16,7 @@ class TextureData; class TextureResource : public IReloadable { public: - static std::shared_ptr get(const std::string& path, bool tile = false, bool forceLoad = false, bool dynamic = true); + static std::shared_ptr get(const std::string& path, bool tile = false, bool forceLoad = false, bool dynamic = true, bool block = true); void initFromPixels(const unsigned char* dataRGBA, size_t width, size_t height); virtual void initFromMemory(const char* file, size_t length); @@ -24,6 +24,10 @@ class TextureResource : public IReloadable void rasterizeAt(size_t width, size_t height); Vector2f getSourceImageSize() const; + // Check if asynchronously-loaded texture data is ready and update cached sizes. + // Returns true if sizes are available (texture loaded), false if still pending. + bool updateTextureSize(); + virtual ~TextureResource(); bool isInitialized() const; @@ -36,7 +40,7 @@ class TextureResource : public IReloadable static size_t getTotalTextureSize(); // returns the number of bytes that would be used if all textures were in memory protected: - TextureResource(const std::string& path, bool tile, bool dynamic); + TextureResource(const std::string& path, bool tile, bool dynamic, bool block = true); virtual bool unload(); virtual void reload(); From 2f118ba9200d973d43dd6c79c529b3d0b87aa6f7 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Mon, 23 Feb 2026 22:01:53 -0800 Subject: [PATCH 02/16] Make VLC media parsing async to eliminate busy-wait stall on video load Replaces the synchronous busy-wait loop (while (libvlc_media_get_parsed_status() == 0) ;) with a non-blocking libvlc_media_parse_with_options call and a per-frame poll via a new handleParsing() method called from render(). Adds mMediaParsing flag to VideoVlcComponent to guard against starting a new video while the previous media is still being parsed, and to defer startVideo() until parsing completes. Eliminates the main-thread stall that caused audio/video to lag on first load. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/VideoComponent.cpp | 13 +- es-core/src/components/VideoVlcComponent.cpp | 161 +++++++++++-------- es-core/src/components/VideoVlcComponent.h | 7 + 3 files changed, 107 insertions(+), 74 deletions(-) diff --git a/es-core/src/components/VideoComponent.cpp b/es-core/src/components/VideoComponent.cpp index ec6e55fc88..bcd739dcaf 100644 --- a/es-core/src/components/VideoComponent.cpp +++ b/es-core/src/components/VideoComponent.cpp @@ -239,8 +239,6 @@ void VideoComponent::handleStartDelay() } // Completed mStartDelayed = false; - // Clear the playing flag so startVideo works - mIsPlaying = false; startVideo(); } } @@ -251,8 +249,8 @@ void VideoComponent::handleLooping() void VideoComponent::startVideoWithDelay() { - // If not playing then either start the video or initiate the delay - if (!mIsPlaying) + // If not playing and not already preparing to play, start the process + if (!mIsPlaying && mPlayingVideoPath.empty()) { // Set the video that we are going to be playing so we don't attempt to restart it mPlayingVideoPath = mVideoPath; @@ -270,7 +268,6 @@ void VideoComponent::startVideoWithDelay() mFadeIn = 0.0f; mStartTime = SDL_GetTicks() + mConfig.startDelay; } - mIsPlaying = true; } } @@ -313,8 +310,8 @@ void VideoComponent::manageState() // is not active and the component is visible bool show = mShowing && !mScreensaverActive && !mDisable && mVisible; - // See if we're already playing - if (mIsPlaying) + // See if we're already playing (or mid-parse preparing to play) + if (mIsPlaying || mPlayingVideoPath.length() > 0) { // If we are not on display then stop the video from playing if (!show) @@ -332,7 +329,7 @@ void VideoComponent::manageState() } } // Need to recheck variable rather than 'else' because it may be modified above - if (!mIsPlaying) + if (!mIsPlaying && mPlayingVideoPath.empty()) { // If we are on display then see if we should start the video if (show && !mVideoPath.empty()) diff --git a/es-core/src/components/VideoVlcComponent.cpp b/es-core/src/components/VideoVlcComponent.cpp index 4ef7500d05..33e2c9a364 100644 --- a/es-core/src/components/VideoVlcComponent.cpp +++ b/es-core/src/components/VideoVlcComponent.cpp @@ -40,7 +40,8 @@ static void display(void* /*data*/, void* /*id*/) { VideoVlcComponent::VideoVlcComponent(Window* window, std::string subtitles) : VideoComponent(window), - mMediaPlayer(nullptr) + mMediaPlayer(nullptr), + mMediaParsing(false) { memset(&mContext, 0, sizeof(mContext)); @@ -137,6 +138,9 @@ void VideoVlcComponent::render(const Transform4x4f& parentTrans) if (!isVisible()) return; + // Poll for async media parsing completion each frame + handleParsing(); + VideoComponent::render(parentTrans); Transform4x4f trans = parentTrans * getTransform(); GuiComponent::renderChildren(trans); @@ -233,7 +237,7 @@ void VideoVlcComponent::handleLooping() void VideoVlcComponent::startVideo() { - if (!mIsPlaying) { + if (!mIsPlaying && !mMediaParsing) { mVideoWidth = 0; mVideoHeight = 0; @@ -252,76 +256,92 @@ void VideoVlcComponent::startVideo() mMedia = libvlc_media_new_path(mVLC, path.c_str()); if (mMedia) { - unsigned track_count; - // Get the media metadata so we can find the aspect ratio + // Start async parse — we will poll for completion in handleParsing() libvlc_media_parse_with_options(mMedia, libvlc_media_fetch_local, -1); - while (libvlc_media_get_parsed_status(mMedia) == 0) - ; - libvlc_media_track_t** tracks; - track_count = libvlc_media_tracks_get(mMedia, &tracks); - for (unsigned track = 0; track < track_count; ++track) - { - if (tracks[track]->i_type == libvlc_track_video) - { - mVideoWidth = tracks[track]->video->i_width; - mVideoHeight = tracks[track]->video->i_height; - break; - } - } - libvlc_media_tracks_release(tracks, track_count); + mMediaParsing = true; + } + } + } +} + +void VideoVlcComponent::handleParsing() +{ + if (!mMediaParsing || !mMedia) + return; + + // Poll — not yet parsed, come back next frame + if (libvlc_media_get_parsed_status(mMedia) == 0) + return; + + mMediaParsing = false; + onMediaParsed(); +} + +void VideoVlcComponent::onMediaParsed() +{ + unsigned track_count; + libvlc_media_track_t** tracks; + track_count = libvlc_media_tracks_get(mMedia, &tracks); + for (unsigned track = 0; track < track_count; ++track) + { + if (tracks[track]->i_type == libvlc_track_video) + { + mVideoWidth = tracks[track]->video->i_width; + mVideoHeight = tracks[track]->video->i_height; + break; + } + } + libvlc_media_tracks_release(tracks, track_count); - // Make sure we found a valid video track - if ((mVideoWidth > 0) && (mVideoHeight > 0)) + // Make sure we found a valid video track + if ((mVideoWidth > 0) && (mVideoHeight > 0)) + { + if (mScreensaverMode) + { + std::string resolution = Settings::getInstance()->getString("VlcScreenSaverResolution"); + if(resolution != "original") { + float scale = 1; + if (resolution == "low") + // 25% of screen resolution + scale = 0.25; + if (resolution == "medium") + // 50% of screen resolution + scale = 0.5; + if (resolution == "high") + // 75% of screen resolution + scale = 0.75; + + Vector2f resizeScale((Renderer::getScreenWidth() / (float)mVideoWidth) * scale, (Renderer::getScreenHeight() / (float)mVideoHeight) * scale); + + if(resizeScale.x() < resizeScale.y()) { - if (mScreensaverMode) - { - std::string resolution = Settings::getInstance()->getString("VlcScreenSaverResolution"); - if(resolution != "original") { - float scale = 1; - if (resolution == "low") - // 25% of screen resolution - scale = 0.25; - if (resolution == "medium") - // 50% of screen resolution - scale = 0.5; - if (resolution == "high") - // 75% of screen resolution - scale = 0.75; - - Vector2f resizeScale((Renderer::getScreenWidth() / (float)mVideoWidth) * scale, (Renderer::getScreenHeight() / (float)mVideoHeight) * scale); - - if(resizeScale.x() < resizeScale.y()) - { - mVideoWidth = (unsigned int) (mVideoWidth * resizeScale.x()); - mVideoHeight = (unsigned int) (mVideoHeight * resizeScale.x()); - }else{ - mVideoWidth = (unsigned int) (mVideoWidth * resizeScale.y()); - mVideoHeight = (unsigned int) (mVideoHeight * resizeScale.y()); - } - } - } - else - { - remove(getTitlePath().c_str()); - } - PowerSaver::pause(); - setupContext(); - - // Setup the media player - mMediaPlayer = libvlc_media_player_new_from_media(mMedia); - - setMuteMode(); - - libvlc_media_player_play(mMediaPlayer); - libvlc_video_set_callbacks(mMediaPlayer, lock, unlock, display, (void*)&mContext); - libvlc_video_set_format(mMediaPlayer, "RGBA", (int)mVideoWidth, (int)mVideoHeight, (int)mVideoWidth * 4); - - // Update the playing state - mIsPlaying = true; - mFadeIn = 0.0f; + mVideoWidth = (unsigned int) (mVideoWidth * resizeScale.x()); + mVideoHeight = (unsigned int) (mVideoHeight * resizeScale.x()); + }else{ + mVideoWidth = (unsigned int) (mVideoWidth * resizeScale.y()); + mVideoHeight = (unsigned int) (mVideoHeight * resizeScale.y()); } } } + else + { + remove(getTitlePath().c_str()); + } + PowerSaver::pause(); + setupContext(); + + // Setup the media player + mMediaPlayer = libvlc_media_player_new_from_media(mMedia); + + setMuteMode(); + + libvlc_media_player_play(mMediaPlayer); + libvlc_video_set_callbacks(mMediaPlayer, lock, unlock, display, (void*)&mContext); + libvlc_video_set_format(mMediaPlayer, "RGBA", (int)mVideoWidth, (int)mVideoHeight, (int)mVideoWidth * 4); + + // Update the playing state + mIsPlaying = true; + mFadeIn = 0.0f; } } @@ -329,6 +349,15 @@ void VideoVlcComponent::stopVideo() { mIsPlaying = false; mStartDelayed = false; + mPlayingVideoPath = ""; + // If we were mid-parse with no player yet, cancel the parse and release the media + if (mMediaParsing && mMedia) + { + libvlc_media_parse_stop(mMedia); + libvlc_media_release(mMedia); + mMedia = nullptr; + } + mMediaParsing = false; // Release the media player so it stops calling back to us if (mMediaPlayer) { diff --git a/es-core/src/components/VideoVlcComponent.h b/es-core/src/components/VideoVlcComponent.h index b3974faf3a..af902e29ed 100644 --- a/es-core/src/components/VideoVlcComponent.h +++ b/es-core/src/components/VideoVlcComponent.h @@ -62,12 +62,19 @@ class VideoVlcComponent : public VideoComponent void setupContext(); void freeContext(); + // Called each frame to check if async media parsing has completed; + // once done, extracts track info and starts playback. + void handleParsing(); + // Second half of startVideo — runs after parsing finishes. + void onMediaParsed(); + private: static libvlc_instance_t* mVLC; libvlc_media_t* mMedia; libvlc_media_player_t* mMediaPlayer; VideoContext mContext; std::shared_ptr mTexture; + bool mMediaParsing; }; #endif // ES_CORE_COMPONENTS_VIDEO_VLC_COMPONENT_H From 349250712bf4ca5604adedc03aad628dc9ddb4a2 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Tue, 24 Feb 2026 00:50:00 -0800 Subject: [PATCH 03/16] Write gamelist.xml asynchronously to avoid blocking UI on NAS I/O Serializes the pugixml document to a std::string on the main thread (fast, in-memory), then dispatches the actual file write to a background thread via std::async. Pending futures are stored in a global vector; completed ones are pruned on each subsequent write call. Adds waitForGamelistWrites() which blocks until all in-flight writes complete. Called from main() before SystemData::deleteSystems() to guarantee no data is lost on clean shutdown. Co-Authored-By: Claude Sonnet 4.6 --- es-app/src/Gamelist.cpp | 62 ++++++++++++++++++++++++++++++++++++----- es-app/src/Gamelist.h | 4 +++ es-app/src/main.cpp | 2 ++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index 51a6d9e2ba..931f7e7777 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -1,6 +1,11 @@ #include "Gamelist.h" +#include #include +#include +#include +#include +#include #include "utils/FileSystemUtil.h" #include "FileData.h" @@ -9,6 +14,11 @@ #include "Settings.h" #include "SystemData.h" #include +#include + +// Async gamelist write infrastructure +static std::mutex sGamelistWriteMutex; +static std::vector> sGamelistPendingWrites; FileData* findOrCreateFile(SystemData* system, const std::string& path, FileType type) { @@ -292,6 +302,7 @@ void updateGamelist(SystemData* system) if(!pathNode) { LOG(LogError) << "<" << tag << "> node contains no child!"; + fileNode = nextNode; continue; } @@ -325,22 +336,59 @@ void updateGamelist(SystemData* system) // now write the file if (numUpdated > 0) { - const auto startTs = std::chrono::system_clock::now(); - //make sure the folders leading up to this path exist (or the write will fail) std::string xmlWritePath(system->getGamelistPath(true)); Utils::FileSystem::createDirectory(Utils::FileSystem::getParent(xmlWritePath)); LOG(LogInfo) << "Added/Updated " << numUpdated << " entities in '" << xmlReadPath << "'"; - if (!doc.save_file(xmlWritePath.c_str())) { - LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << system->getName() << ")!"; - } + // Serialize the XML document to a string on the main thread, + // then write the string to file on a background thread to + // avoid blocking the UI on slow NAS I/O. + std::stringstream ss; + doc.save(ss); + std::string xmlContent = ss.str(); + std::string sysName = system->getName(); + + { + // Clean up finished futures + std::lock_guard lock(sGamelistWriteMutex); + sGamelistPendingWrites.erase( + std::remove_if(sGamelistPendingWrites.begin(), sGamelistPendingWrites.end(), + [](std::future& f) { + return f.wait_for(std::chrono::seconds(0)) == std::future_status::ready; + }), + sGamelistPendingWrites.end()); + + sGamelistPendingWrites.push_back(std::async(std::launch::async, + [xmlContent, xmlWritePath, sysName]() { + const auto startTs = std::chrono::system_clock::now(); + + std::ofstream outFile(xmlWritePath, std::ios::out | std::ios::trunc); + if (outFile.is_open()) { + outFile << xmlContent; + outFile.close(); + } else { + LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << sysName << ")!"; + } - const auto endTs = std::chrono::system_clock::now(); - LOG(LogInfo) << "Saved gamelist.xml for system \"" << system->getName() << "\" in " << std::chrono::duration_cast(endTs - startTs).count() << " ms"; + const auto endTs = std::chrono::system_clock::now(); + LOG(LogInfo) << "Saved gamelist.xml for system \"" << sysName << "\" in " << std::chrono::duration_cast(endTs - startTs).count() << " ms"; + })); + } } }else{ LOG(LogError) << "Found no root folder for system \"" << system->getName() << "\"!"; } } + +void waitForGamelistWrites() +{ + std::lock_guard lock(sGamelistWriteMutex); + for (auto& f : sGamelistPendingWrites) + { + if (f.valid()) + f.wait(); + } + sGamelistPendingWrites.clear(); +} diff --git a/es-app/src/Gamelist.h b/es-app/src/Gamelist.h index d9502a196d..377fe4b1b7 100644 --- a/es-app/src/Gamelist.h +++ b/es-app/src/Gamelist.h @@ -10,4 +10,8 @@ void parseGamelist(SystemData* system); // Writes currently loaded metadata for a SystemData to gamelist.xml. void updateGamelist(SystemData* system); +// Blocks until all pending async gamelist writes have completed. +// Must be called before process exit to avoid data loss. +void waitForGamelistWrites(); + #endif // ES_APP_GAME_LIST_H diff --git a/es-app/src/main.cpp b/es-app/src/main.cpp index eb0b92cacf..4447ed6220 100644 --- a/es-app/src/main.cpp +++ b/es-app/src/main.cpp @@ -8,6 +8,7 @@ #include "views/ViewController.h" #include "CollectionSystemManager.h" #include "EmulationStation.h" +#include "Gamelist.h" #include "InputManager.h" #include "Log.h" #include "MameNames.h" @@ -484,6 +485,7 @@ int main(int argc, char* argv[]) MameNames::deinit(); CollectionSystemManager::deinit(); + waitForGamelistWrites(); SystemData::deleteSystems(); // call this ONLY when linking with FreeImage as a static library From 4872ae022635edd14ce643dc89d65727d4d4187d Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Thu, 26 Feb 2026 15:46:45 -0800 Subject: [PATCH 04/16] Add AsyncFileIO setting to gate async image loads and gamelist writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an "AsyncFileIO" boolean setting (default off) accessible in the Other Settings menu as "ASYNC FILE IO", mirroring the pattern used by "PARSE GAMELISTS ONLY". When off (default): image loads call the synchronous setImage() path and gamelist writes use doc.save_file() on the main thread — original blocking behaviour preserved. When on: ImageComponent::setImageAsync() and VideoComponent::setImageAsync() queue texture loads on the background TextureLoader thread; gamelist writes serialize XML on the main thread then dispatch the file write via std::async to avoid blocking the UI on slow NAS I/O. The toggle is handled entirely inside setImageAsync() and updateGamelist() so no call sites in the game list views needed to change. Co-Authored-By: Claude Sonnet 4.6 --- es-app/src/Gamelist.cpp | 73 +++++++++++++---------- es-app/src/guis/GuiMenu.cpp | 5 ++ es-core/src/Settings.cpp | 1 + es-core/src/components/ImageComponent.cpp | 6 ++ es-core/src/components/VideoComponent.cpp | 7 ++- 5 files changed, 60 insertions(+), 32 deletions(-) diff --git a/es-app/src/Gamelist.cpp b/es-app/src/Gamelist.cpp index 931f7e7777..bf4d89d7e4 100644 --- a/es-app/src/Gamelist.cpp +++ b/es-app/src/Gamelist.cpp @@ -342,39 +342,50 @@ void updateGamelist(SystemData* system) LOG(LogInfo) << "Added/Updated " << numUpdated << " entities in '" << xmlReadPath << "'"; - // Serialize the XML document to a string on the main thread, - // then write the string to file on a background thread to - // avoid blocking the UI on slow NAS I/O. - std::stringstream ss; - doc.save(ss); - std::string xmlContent = ss.str(); - std::string sysName = system->getName(); - + if (!Settings::getInstance()->getBool("AsyncFileIO")) { - // Clean up finished futures - std::lock_guard lock(sGamelistWriteMutex); - sGamelistPendingWrites.erase( - std::remove_if(sGamelistPendingWrites.begin(), sGamelistPendingWrites.end(), - [](std::future& f) { - return f.wait_for(std::chrono::seconds(0)) == std::future_status::ready; - }), - sGamelistPendingWrites.end()); - - sGamelistPendingWrites.push_back(std::async(std::launch::async, - [xmlContent, xmlWritePath, sysName]() { - const auto startTs = std::chrono::system_clock::now(); - - std::ofstream outFile(xmlWritePath, std::ios::out | std::ios::trunc); - if (outFile.is_open()) { - outFile << xmlContent; - outFile.close(); - } else { - LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << sysName << ")!"; - } + const auto startTs = std::chrono::system_clock::now(); + if (!doc.save_file(xmlWritePath.c_str())) + LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << system->getName() << ")!"; + const auto endTs = std::chrono::system_clock::now(); + LOG(LogInfo) << "Saved gamelist.xml for system \"" << system->getName() << "\" in " << std::chrono::duration_cast(endTs - startTs).count() << " ms"; + } + else + { + // Serialize the XML document to a string on the main thread, + // then write the string to file on a background thread to + // avoid blocking the UI on slow NAS I/O. + std::stringstream ss; + doc.save(ss); + std::string xmlContent = ss.str(); + std::string sysName = system->getName(); - const auto endTs = std::chrono::system_clock::now(); - LOG(LogInfo) << "Saved gamelist.xml for system \"" << sysName << "\" in " << std::chrono::duration_cast(endTs - startTs).count() << " ms"; - })); + { + // Clean up finished futures + std::lock_guard lock(sGamelistWriteMutex); + sGamelistPendingWrites.erase( + std::remove_if(sGamelistPendingWrites.begin(), sGamelistPendingWrites.end(), + [](std::future& f) { + return f.wait_for(std::chrono::seconds(0)) == std::future_status::ready; + }), + sGamelistPendingWrites.end()); + + sGamelistPendingWrites.push_back(std::async(std::launch::async, + [xmlContent, xmlWritePath, sysName]() { + const auto startTs = std::chrono::system_clock::now(); + + std::ofstream outFile(xmlWritePath, std::ios::out | std::ios::trunc); + if (outFile.is_open()) { + outFile << xmlContent; + outFile.close(); + } else { + LOG(LogError) << "Error saving gamelist.xml to \"" << xmlWritePath << "\" (for system " << sysName << ")!"; + } + + const auto endTs = std::chrono::system_clock::now(); + LOG(LogInfo) << "Saved gamelist.xml for system \"" << sysName << "\" in " << std::chrono::duration_cast(endTs - startTs).count() << " ms"; + })); + } } } }else{ diff --git a/es-app/src/guis/GuiMenu.cpp b/es-app/src/guis/GuiMenu.cpp index b3150db4a5..442f18462c 100644 --- a/es-app/src/guis/GuiMenu.cpp +++ b/es-app/src/guis/GuiMenu.cpp @@ -451,6 +451,11 @@ void GuiMenu::openOtherSettings() s->addWithLabel("PARSE GAMESLISTS ONLY", parse_gamelists); s->addSaveFunc([parse_gamelists] { Settings::getInstance()->setBool("ParseGamelistOnly", parse_gamelists->getState()); }); + auto async_file_io = std::make_shared(mWindow); + async_file_io->setState(Settings::getInstance()->getBool("AsyncFileIO")); + s->addWithLabel("ASYNC FILE IO", async_file_io); + s->addSaveFunc([async_file_io] { Settings::getInstance()->setBool("AsyncFileIO", async_file_io->getState()); }); + auto local_art = std::make_shared(mWindow); local_art->setState(Settings::getInstance()->getBool("LocalArt")); s->addWithLabel("SEARCH FOR LOCAL ART", local_art); diff --git a/es-core/src/Settings.cpp b/es-core/src/Settings.cpp index aeb1057e33..436c95a7e7 100644 --- a/es-core/src/Settings.cpp +++ b/es-core/src/Settings.cpp @@ -58,6 +58,7 @@ void Settings::setDefaults() mBoolMap["BackgroundJoystickInput"] = false; mBoolMap["ParseGamelistOnly"] = false; + mBoolMap["AsyncFileIO"] = false; mBoolMap["ShowHiddenFiles"] = false; mBoolMap["DrawFramerate"] = false; mBoolMap["ShowExit"] = true; diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 6c083c056d..146a1754fc 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -167,6 +167,12 @@ void ImageComponent::setImage(const std::shared_ptr& texture) void ImageComponent::setImageAsync(std::string path, bool tile) { + if (!Settings::getInstance()->getBool("AsyncFileIO")) + { + setImage(path, tile); + return; + } + mAsyncPending = false; // Skip the fileExists() check used by setImage() — it calls stat64 which blocks diff --git a/es-core/src/components/VideoComponent.cpp b/es-core/src/components/VideoComponent.cpp index bcd739dcaf..5a3064ef99 100644 --- a/es-core/src/components/VideoComponent.cpp +++ b/es-core/src/components/VideoComponent.cpp @@ -1,6 +1,7 @@ #include "components/VideoComponent.h" #include "resources/ResourceManager.h" +#include "Settings.h" #include "utils/FileSystemUtil.h" #include "PowerSaver.h" #include "ThemeData.h" @@ -141,7 +142,11 @@ void VideoComponent::setImageAsync(std::string path) if (path == mStaticImagePath) return; - mStaticImage.setImageAsync(path); + if (!Settings::getInstance()->getBool("AsyncFileIO")) + mStaticImage.setImage(path); + else + mStaticImage.setImageAsync(path); + mFadeIn = 0.0f; mStaticImagePath = path; } From 2a4a41da837f2e293f5c45523ba1609f6dbf6044 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 14:35:54 -0800 Subject: [PATCH 05/16] Move blocking VLC stop/cleanup to a persistent background worker thread stopVideo() was freezing the UI because libvlc_media_parse_stop and libvlc_media_player_stop block on internal VLC mutexes. The initial fix of spawning a std::thread per call also blocked due to pthread_create mutex contention during stack allocation. This replaces per-call thread creation with a single persistent worker thread and task queue, so stopVideo() returns immediately after pushing a cleanup lambda. The VideoContext is now heap-allocated so ownership can be safely transferred to the background worker without lifetime issues with VLC callbacks. Co-Authored-By: Claude Opus 4.6 --- es-core/src/components/VideoVlcComponent.cpp | 105 +++++++++++++++---- es-core/src/components/VideoVlcComponent.h | 17 ++- 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/es-core/src/components/VideoVlcComponent.cpp b/es-core/src/components/VideoVlcComponent.cpp index 33e2c9a364..3e1d6bbc1f 100644 --- a/es-core/src/components/VideoVlcComponent.cpp +++ b/es-core/src/components/VideoVlcComponent.cpp @@ -17,6 +17,44 @@ typedef SSIZE_T ssize_t; libvlc_instance_t* VideoVlcComponent::mVLC = NULL; +// Persistent worker thread statics for non-blocking VLC cleanup +std::thread VideoVlcComponent::sCleanupThread; +std::mutex VideoVlcComponent::sCleanupMutex; +std::condition_variable VideoVlcComponent::sCleanupCond; +std::deque> VideoVlcComponent::sCleanupQueue; +bool VideoVlcComponent::sCleanupRunning = false; + +void VideoVlcComponent::cleanupWorker() +{ + while (true) + { + std::function task; + { + std::unique_lock lock(sCleanupMutex); + sCleanupCond.wait(lock, [] { return !sCleanupQueue.empty(); }); + task = std::move(sCleanupQueue.front()); + sCleanupQueue.pop_front(); + } + task(); + } +} + +void VideoVlcComponent::postCleanupTask(std::function task) +{ + { + std::lock_guard lock(sCleanupMutex); + // Lazily start the persistent worker thread on first use + if (!sCleanupRunning) + { + sCleanupRunning = true; + sCleanupThread = std::thread(cleanupWorker); + sCleanupThread.detach(); + } + sCleanupQueue.push_back(std::move(task)); + } + sCleanupCond.notify_one(); +} + // VLC prepares to render a video frame. static void *lock(void *data, void **p_pixels) { struct VideoContext *c = (struct VideoContext *)data; @@ -43,7 +81,7 @@ VideoVlcComponent::VideoVlcComponent(Window* window, std::string subtitles) : mMediaPlayer(nullptr), mMediaParsing(false) { - memset(&mContext, 0, sizeof(mContext)); + mContext = nullptr; // Get an empty texture for rendering the video mTexture = TextureResource::get(""); @@ -146,7 +184,7 @@ void VideoVlcComponent::render(const Transform4x4f& parentTrans) GuiComponent::renderChildren(trans); Renderer::setMatrix(trans); - if (mIsPlaying && mContext.valid) + if (mIsPlaying && mContext && mContext->valid) { const unsigned int fadeIn = (unsigned int)(Math::clamp(0.0f, mFadeIn, 1.0f) * 255.0f); const unsigned int color = Renderer::convertColor((fadeIn << 24) | (fadeIn << 16) | (fadeIn << 8) | 255); @@ -162,7 +200,7 @@ void VideoVlcComponent::render(const Transform4x4f& parentTrans) vertices[i].pos.round(); // Build a texture for the video frame - mTexture->initFromPixels((unsigned char*)mContext.surface->pixels, mContext.surface->w, mContext.surface->h); + mTexture->initFromPixels((unsigned char*)mContext->surface->pixels, mContext->surface->w, mContext->surface->h); mTexture->bind(); // Render it @@ -176,23 +214,25 @@ void VideoVlcComponent::render(const Transform4x4f& parentTrans) void VideoVlcComponent::setupContext() { - if (!mContext.valid) + if (!mContext) { // Create an RGBA surface to render the video into - mContext.surface = SDL_CreateRGBSurface(SDL_SWSURFACE, (int)mVideoWidth, (int)mVideoHeight, 32, 0xff000000, 0x00ff0000, 0x0000ff00, 0x000000ff); - mContext.mutex = SDL_CreateMutex(); - mContext.valid = true; + mContext = new VideoContext(); + mContext->surface = SDL_CreateRGBSurface(SDL_SWSURFACE, (int)mVideoWidth, (int)mVideoHeight, 32, 0xff000000, 0x00ff0000, 0x0000ff00, 0x000000ff); + mContext->mutex = SDL_CreateMutex(); + mContext->valid = true; resize(); } } void VideoVlcComponent::freeContext() { - if (mContext.valid) + if (mContext) { - SDL_FreeSurface(mContext.surface); - SDL_DestroyMutex(mContext.mutex); - mContext.valid = false; + SDL_FreeSurface(mContext->surface); + SDL_DestroyMutex(mContext->mutex); + delete mContext; + mContext = nullptr; } } @@ -336,7 +376,7 @@ void VideoVlcComponent::onMediaParsed() setMuteMode(); libvlc_media_player_play(mMediaPlayer); - libvlc_video_set_callbacks(mMediaPlayer, lock, unlock, display, (void*)&mContext); + libvlc_video_set_callbacks(mMediaPlayer, lock, unlock, display, (void*)mContext); libvlc_video_set_format(mMediaPlayer, "RGBA", (int)mVideoWidth, (int)mVideoHeight, (int)mVideoWidth * 4); // Update the playing state @@ -350,23 +390,44 @@ void VideoVlcComponent::stopVideo() mIsPlaying = false; mStartDelayed = false; mPlayingVideoPath = ""; - // If we were mid-parse with no player yet, cancel the parse and release the media + // If we were mid-parse with no player yet, cancel the parse on a background + // thread so the blocking libvlc_media_parse_stop call doesn't stall the UI. if (mMediaParsing && mMedia) { - libvlc_media_parse_stop(mMedia); - libvlc_media_release(mMedia); + libvlc_media_t* media = mMedia; mMedia = nullptr; + mMediaParsing = false; + + postCleanupTask([media]() { + libvlc_media_parse_stop(media); + libvlc_media_release(media); + }); + return; } mMediaParsing = false; - // Release the media player so it stops calling back to us + // Release the media player on a background thread so the blocking + // libvlc_media_player_stop call doesn't freeze the UI. if (mMediaPlayer) { - libvlc_media_player_stop(mMediaPlayer); - libvlc_media_player_release(mMediaPlayer); - libvlc_media_release(mMedia); - mMediaPlayer = NULL; - freeContext(); - PowerSaver::resume(); + libvlc_media_player_t* player = mMediaPlayer; + libvlc_media_t* media = mMedia; + VideoContext* context = mContext; + + mMediaPlayer = nullptr; + mMedia = nullptr; + mContext = nullptr; + + postCleanupTask([player, media, context]() { + libvlc_media_player_stop(player); + libvlc_media_player_release(player); + libvlc_media_release(media); + if (context) { + SDL_FreeSurface(context->surface); + SDL_DestroyMutex(context->mutex); + delete context; + } + PowerSaver::resume(); + }); } } diff --git a/es-core/src/components/VideoVlcComponent.h b/es-core/src/components/VideoVlcComponent.h index af902e29ed..16a5ba253e 100644 --- a/es-core/src/components/VideoVlcComponent.h +++ b/es-core/src/components/VideoVlcComponent.h @@ -3,6 +3,11 @@ #define ES_CORE_COMPONENTS_VIDEO_VLC_COMPONENT_H #include "VideoComponent.h" +#include +#include +#include +#include +#include struct SDL_mutex; struct SDL_Surface; @@ -68,11 +73,21 @@ class VideoVlcComponent : public VideoComponent // Second half of startVideo — runs after parsing finishes. void onMediaParsed(); + // Post a cleanup task to the shared background worker thread. + static void postCleanupTask(std::function task); + + static void cleanupWorker(); + private: static libvlc_instance_t* mVLC; + static std::thread sCleanupThread; + static std::mutex sCleanupMutex; + static std::condition_variable sCleanupCond; + static std::deque> sCleanupQueue; + static bool sCleanupRunning; libvlc_media_t* mMedia; libvlc_media_player_t* mMediaPlayer; - VideoContext mContext; + VideoContext* mContext; std::shared_ptr mTexture; bool mMediaParsing; }; From 5cb2d0023e4eb97ac403637cb9c8d9e93e0990a8 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 23:13:38 -0800 Subject: [PATCH 06/16] Remove blocking fileExists() checks on NAS-backed paths VideoComponent::setVideo() and ImageComponent::setImage() were calling ResourceManager::fileExists() (fstatat64) on the main thread for every cursor move, freezing the UI while a NAS drive spins up. Remove the guards and let VLC / TextureData::load() handle missing files gracefully. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.cpp | 6 ++++-- es-core/src/components/VideoComponent.cpp | 11 +++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 146a1754fc..3956609aad 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -135,9 +135,11 @@ void ImageComponent::setImage(std::string path, bool tile) { mAsyncPending = false; - if(path.empty() || !ResourceManager::getInstance()->fileExists(path)) + // Skip fileExists() — it calls stat64 which blocks on NAS paths. + // TextureData::load() handles missing files gracefully (returns empty texture). + if(path.empty()) { - if(mDefaultPath.empty() || !ResourceManager::getInstance()->fileExists(mDefaultPath)) + if(mDefaultPath.empty()) mTexture.reset(); else mTexture = TextureResource::get(mDefaultPath, tile, mForceLoad, mDynamic); diff --git a/es-core/src/components/VideoComponent.cpp b/es-core/src/components/VideoComponent.cpp index 5a3064ef99..67f34f49eb 100644 --- a/es-core/src/components/VideoComponent.cpp +++ b/es-core/src/components/VideoComponent.cpp @@ -115,14 +115,9 @@ bool VideoComponent::setVideo(std::string path) // Store the path mVideoPath = fullPath; - // If the file exists then set the new video - if (!fullPath.empty() && ResourceManager::getInstance()->fileExists(fullPath)) - { - // Return true to show that we are going to attempt to play a video - return true; - } - // Return false to show that no video will be displayed - return false; + // Return true if there's a path to attempt; missing files are handled + // gracefully by VLC, avoiding a blocking stat() call on NAS paths. + return !fullPath.empty(); } void VideoComponent::setImage(std::string path) From 349abed3a62aa3dbaa33f96e0fa48d081b9d75d8 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Fri, 27 Feb 2026 23:36:29 -0800 Subject: [PATCH 07/16] Fix FileSystem::exists() deadlock on NAS paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background texture-loader thread would acquire the exists() mutex then block on stat64() for a slow NAS path. Meanwhile the main thread (e.g. Scripting::fireEvent on every cursor move) tried to acquire the same mutex and froze the UI until the NAS responded. Fix by doing stat64 outside the mutex: check the cache under lock, release before the syscall, then reacquire to store the result. Cache is preserved so return visits to the same game avoid re-stat'ing on NAS paths. The benign race (two threads both missing cache for the same path) is acceptable — both will stat and store the same result. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/utils/FileSystemUtil.cpp | 39 ++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/es-core/src/utils/FileSystemUtil.cpp b/es-core/src/utils/FileSystemUtil.cpp index 94be3a58bd..21088786f2 100644 --- a/es-core/src/utils/FileSystemUtil.cpp +++ b/es-core/src/utils/FileSystemUtil.cpp @@ -605,13 +605,13 @@ namespace Utils if(!exists(path)) return true; + // try to remove file bool removed = (unlink(path.c_str()) == 0); - - // if removed, let's remove it from the index if (removed) + { + const std::unique_lock lock(mutex); pathExistsIndex[_path] = false; - - // try to remove file + } return removed; } // removeFile @@ -629,6 +629,7 @@ namespace Utils // try to create directory if(mkdir(path.c_str(), 0755) == 0) { + const std::unique_lock lock(mutex); pathExistsIndex[_path] = true; return true; } @@ -642,9 +643,11 @@ namespace Utils // try to create directory again now that the parent should exist bool created = (mkdir(path.c_str(), 0755) == 0); - if(created) + if (created) + { + const std::unique_lock lock(mutex); pathExistsIndex[_path] = true; - + } return created; } // createDirectory @@ -653,17 +656,27 @@ namespace Utils bool exists(const std::string& _path) { - const std::unique_lock lock(mutex); + // Fast path: return cached result without doing any I/O. + { + const std::unique_lock lock(mutex); + auto it = pathExistsIndex.find(_path); + if (it != pathExistsIndex.cend()) + return it->second; + } + + // Slow path: stat outside the lock so a blocking NAS call on the + // background texture-loader thread doesn't stall the main thread + // when it tries to acquire the same mutex (e.g. Scripting::fireEvent). + const std::string path = getGenericPath(_path); + struct stat64 info; + bool result = (stat64(path.c_str(), &info) == 0); - if(pathExistsIndex.find(_path) == pathExistsIndex.cend()) { - const std::string path = getGenericPath(_path); - struct stat64 info; - // check if stat64 succeeded - pathExistsIndex[_path] = (stat64(path.c_str(), &info) == 0); + const std::unique_lock lock(mutex); + pathExistsIndex[_path] = result; } - return pathExistsIndex.at(_path); + return result; } // exists From a74c71e573057eef0a41ed32c739efa320aa5eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 19:08:43 +0000 Subject: [PATCH 08/16] Fix infinite retry loop, VRAM exhaustion, and thread shutdown for missing textures Four related bugs in the async texture loading system: 1. Missing-file retry loop: TextureData had no "load failed" state, so after load() returned false (file not found), isLoaded() kept returning false. TextureDataManager::get() would re-queue the texture every frame it was rendered, causing log spam and spinning the background thread. Fix: add mLoadFailed flag set on any load() failure; check it in TextureDataManager::get() and TextureLoader::load() before re-queuing. 2. VRAM exhaustion: the retry loop moved the never-loadable texture to the front of the LRU list every frame and triggered the eviction path to "make room", continuously evicting real textures without gaining anything. Resolved by fix #1 stopping the re-queue cycle. 3. mAsyncPending stuck forever: updateTextureSize() never returned true for failed textures since isLoaded() stays false after failure. ImageComponent polled updateTextureSize() every frame indefinitely and the render path logged a debug skip every ~1 second per missing image. Fix: return true from updateTextureSize() when hasLoadFailed() is set so callers treat it as terminal (mSize stays 0,0; image renders blank). 4. Thread not dying on exit: TextureLoader::~TextureLoader() cleared the queue and set mExit without holding mMutex, racing with the background thread's condition_variable wait (which holds the mutex while sleeping). The clear and mExit assignment are now done inside a scoped lock before notify_one(). Also adds hasLoadFailed() guards to TextureDataManager::load() and TextureResource::reload() to close secondary paths that bypassed the check. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/resources/TextureData.cpp | 19 ++++++++++++- es-core/src/resources/TextureData.h | 8 ++++++ es-core/src/resources/TextureDataManager.cpp | 29 ++++++++++++-------- es-core/src/resources/TextureResource.cpp | 8 +++++- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/es-core/src/resources/TextureData.cpp b/es-core/src/resources/TextureData.cpp index 33382b6d8d..8497b09087 100644 --- a/es-core/src/resources/TextureData.cpp +++ b/es-core/src/resources/TextureData.cpp @@ -13,7 +13,7 @@ #define DPI 96 TextureData::TextureData(bool tile) : mTile(tile), mTextureID(0), mDataRGBA(nullptr), mScalable(false), - mWidth(0), mHeight(0), mSourceWidth(0.0f), mSourceHeight(0.0f) + mWidth(0), mHeight(0), mSourceWidth(0.0f), mSourceHeight(0.0f), mLoadFailed(false) { } @@ -136,6 +136,11 @@ bool TextureData::load() else retval = initImageFromMemory((const unsigned char*)data.ptr.get(), data.length); } + if (!retval) + { + std::unique_lock lock(mMutex); + mLoadFailed = true; + } return retval; } @@ -147,6 +152,18 @@ bool TextureData::isLoaded() return false; } +bool TextureData::hasLoadFailed() +{ + std::unique_lock lock(mMutex); + return mLoadFailed; +} + +bool TextureData::isLoadedOrFailed() +{ + std::unique_lock lock(mMutex); + return mDataRGBA || (mTextureID != 0) || mLoadFailed; +} + bool TextureData::uploadAndBind() { // See if it's already been uploaded diff --git a/es-core/src/resources/TextureData.h b/es-core/src/resources/TextureData.h index 44dc92623c..efbd023c80 100644 --- a/es-core/src/resources/TextureData.h +++ b/es-core/src/resources/TextureData.h @@ -25,6 +25,13 @@ class TextureData bool load(); bool isLoaded(); + // Returns true if a previous load() attempt failed (e.g. file not found). + // The texture will not be re-queued for loading while this is set. + bool hasLoadFailed(); + // Returns true if loading is complete — either successfully loaded or + // permanently failed. Single mutex acquisition; use in preference to + // !isLoaded() && !hasLoadFailed() or isLoaded() || hasLoadFailed(). + bool isLoadedOrFailed(); // Upload the texture to VRAM if necessary and bind. Returns true if bound ok or // false if either not loaded @@ -59,6 +66,7 @@ class TextureData float mSourceHeight; bool mScalable; bool mReloadable; + bool mLoadFailed; }; #endif // ES_CORE_RESOURCES_TEXTURE_DATA_H diff --git a/es-core/src/resources/TextureDataManager.cpp b/es-core/src/resources/TextureDataManager.cpp index 6d69652fc4..c72ff8ea7f 100644 --- a/es-core/src/resources/TextureDataManager.cpp +++ b/es-core/src/resources/TextureDataManager.cpp @@ -64,8 +64,10 @@ std::shared_ptr TextureDataManager::get(const TextureResource* key, // Store it back in the lookup mTextureLookup[key] = mTextures.cbegin(); - // Make sure it's loaded or queued for loading - if (enableLoading && !tex->isLoaded()) + // Make sure it's loaded or queued for loading. + // Skip textures whose load previously failed (e.g. file not found) so + // we don't keep re-queuing them every frame they are rendered. + if (enableLoading && !tex->isLoadedOrFailed()) load(tex); } return tex; @@ -110,8 +112,8 @@ size_t TextureDataManager::getQueueSize() void TextureDataManager::load(std::shared_ptr tex, bool block) { - // See if it's already loaded - if (tex->isLoaded()) + // See if it's already loaded or has permanently failed + if (tex->isLoadedOrFailed()) return; // Not loaded. Make sure there is room size_t max_texture = (size_t)Settings::getInstance()->getInt("MaxVRAM") * 1024 * 1024; @@ -146,12 +148,15 @@ TextureLoader::TextureLoader() : mExit(false) TextureLoader::~TextureLoader() { - // Just abort any waiting texture - mTextureDataQ.clear(); - mTextureDataLookup.clear(); - - // Exit the thread - mExit = true; + // Clear the queue and signal exit atomically under the mutex so there is no + // race with the background thread's condition_variable wait (which holds the + // mutex while sleeping). + { + std::unique_lock lock(mMutex); + mTextureDataQ.clear(); + mTextureDataLookup.clear(); + mExit = true; + } mEvent.notify_one(); mThread->join(); delete mThread; @@ -193,8 +198,8 @@ void TextureLoader::threadProc() void TextureLoader::load(std::shared_ptr textureData) { - // Make sure it's not already loaded - if (!textureData->isLoaded()) + // Make sure it's not already loaded and hasn't permanently failed + if (!textureData->isLoadedOrFailed()) { std::unique_lock lock(mMutex); // Remove it from the queue if it is already there diff --git a/es-core/src/resources/TextureResource.cpp b/es-core/src/resources/TextureResource.cpp index 0d914e0338..fc9259eead 100644 --- a/es-core/src/resources/TextureResource.cpp +++ b/es-core/src/resources/TextureResource.cpp @@ -197,6 +197,12 @@ bool TextureResource::updateTextureSize() return true; } + // Load permanently failed (e.g. file not found). Treat as "done" so callers + // (e.g. ImageComponent) don't keep polling every frame waiting for a result + // that will never come. mSize stays (0,0) — the image simply won't display. + if (data && data->hasLoadFailed()) + return true; + return false; } @@ -259,7 +265,7 @@ void TextureResource::reload() { // For dynamically loaded textures the texture manager will load them on demand. // For manually loaded textures we have to reload them here - if (mTextureData && !mTextureData->isLoaded()) + if (mTextureData && !mTextureData->isLoadedOrFailed()) mTextureData->load(); // Uncomment this 2 lines in future release in order to reload texture VRAM exactly as it was before From f9395e74a2a222afe15f526f8ac311ea57c518b1 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 28 Feb 2026 17:35:05 -0800 Subject: [PATCH 09/16] Log texture path on async load completion in ImageComponent Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.cpp | 3 ++- es-core/src/components/ImageComponent.h | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 3956609aad..327996a5b7 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -176,6 +176,7 @@ void ImageComponent::setImageAsync(std::string path, bool tile) } mAsyncPending = false; + mTexturePath = path; // Skip the fileExists() check used by setImage() — it calls stat64 which blocks // on NAS. Instead, just hand the path to TextureResource and let the background @@ -219,7 +220,7 @@ void ImageComponent::update(int deltaTime) { if(mTexture->updateTextureSize()) { - LOG(LogDebug) << "ImageComponent::update: async load complete, calling resize. size=" << mTexture->getSize().x() << "x" << mTexture->getSize().y(); + LOG(LogDebug) << "ImageComponent::update: async load complete for " << mTexturePath << " size=" << mTexture->getSize().x() << "x" << mTexture->getSize().y(); mAsyncPending = false; resize(); } diff --git a/es-core/src/components/ImageComponent.h b/es-core/src/components/ImageComponent.h index 02579f4760..8ac2ba98af 100644 --- a/es-core/src/components/ImageComponent.h +++ b/es-core/src/components/ImageComponent.h @@ -102,6 +102,7 @@ class ImageComponent : public GuiComponent bool mColorGradientHorizontal; std::string mDefaultPath; + std::string mTexturePath; std::shared_ptr mTexture; unsigned char mFadeOpacity; From 7e55fa73fcfe66ce1c5b31761ddb477526bb9f41 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 28 Feb 2026 17:41:06 -0800 Subject: [PATCH 10/16] Log texture path in async-pending render skip message Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 327996a5b7..53832d7491 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -417,7 +417,7 @@ void ImageComponent::render(const Transform4x4f& parentTrans) // Debug: log when we're skipping render due to async pending static int skipCount = 0; if(++skipCount % 60 == 1) // log every ~1 second at 60fps - LOG(LogDebug) << "render: skipping due to mAsyncPending, mSize=" << mSize.x() << "x" << mSize.y() << " opacity=" << (int)mOpacity; + LOG(LogDebug) << "render: skipping due to mAsyncPending for " << mTexturePath << " mSize=" << mSize.x() << "x" << mSize.y() << " opacity=" << (int)mOpacity; } GuiComponent::renderChildren(trans); From d28cd596cdcc5b6f2917c8ef616e579f5872b2bc Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 28 Feb 2026 18:06:08 -0800 Subject: [PATCH 11/16] Clear mAsyncPending in render when load has failed When a game list view is rendered in the background (e.g. during the system carousel transition), update() is never called for its ImageComponents, so mAsyncPending would stay true indefinitely for failed loads even after mLoadFailed was set by the background thread. Add TextureResource::hasLoadFailed() and check it in the render skip-path to clear mAsyncPending without the overhead of updateTextureSize() or an unnecessary resize() call. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.cpp | 16 ++++++++++++---- es-core/src/resources/TextureResource.cpp | 10 ++++++++++ es-core/src/resources/TextureResource.h | 3 +++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 53832d7491..76d906501e 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -414,10 +414,18 @@ void ImageComponent::render(const Transform4x4f& parentTrans) } else if(mTexture && mAsyncPending) { - // Debug: log when we're skipping render due to async pending - static int skipCount = 0; - if(++skipCount % 60 == 1) // log every ~1 second at 60fps - LOG(LogDebug) << "render: skipping due to mAsyncPending for " << mTexturePath << " mSize=" << mSize.x() << "x" << mSize.y() << " opacity=" << (int)mOpacity; + // Clear mAsyncPending for failed loads even when update() isn't being called + // (e.g. game list rendered in background during system carousel transition). + if(mTexture->hasLoadFailed()) + { + mAsyncPending = false; + } + else + { + static int skipCount = 0; + if(++skipCount % 60 == 1) // log every ~1 second at 60fps + LOG(LogDebug) << "render: skipping due to mAsyncPending for " << mTexturePath << " mSize=" << mSize.x() << "x" << mSize.y() << " opacity=" << (int)mOpacity; + } } GuiComponent::renderChildren(trans); diff --git a/es-core/src/resources/TextureResource.cpp b/es-core/src/resources/TextureResource.cpp index fc9259eead..4abad52fd3 100644 --- a/es-core/src/resources/TextureResource.cpp +++ b/es-core/src/resources/TextureResource.cpp @@ -175,6 +175,16 @@ Vector2f TextureResource::getSourceImageSize() const return mSourceSize; } +bool TextureResource::hasLoadFailed() const +{ + std::shared_ptr data; + if (mTextureData != nullptr) + data = mTextureData; + else + data = sTextureDataManager.get(this, false); + return data && data->hasLoadFailed(); +} + bool TextureResource::updateTextureSize() { // If sizes are already known, nothing to do diff --git a/es-core/src/resources/TextureResource.h b/es-core/src/resources/TextureResource.h index e54a374df9..2cbee96815 100644 --- a/es-core/src/resources/TextureResource.h +++ b/es-core/src/resources/TextureResource.h @@ -28,6 +28,9 @@ class TextureResource : public IReloadable // Returns true if sizes are available (texture loaded), false if still pending. bool updateTextureSize(); + // Returns true if a previous load attempt permanently failed (e.g. file not found). + bool hasLoadFailed() const; + virtual ~TextureResource(); bool isInitialized() const; From 91b5953177d6d770c5d7026fe1053c8971c9203f Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 28 Feb 2026 18:11:21 -0800 Subject: [PATCH 12/16] Replace isLoaded/hasLoadFailed/isLoadedOrFailed with LoadStatus enum Two bools allowed impossible states and required callers to check both with two mutex acquisitions. Replace with a single LoadStatus enum (LOADING / LOADED / FAILED) returned under one lock, and use a switch in updateTextureSize() to make each state's intent explicit. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/resources/TextureData.cpp | 20 +++-------- es-core/src/resources/TextureData.h | 17 ++++----- es-core/src/resources/TextureDataManager.cpp | 10 +++--- es-core/src/resources/TextureResource.cpp | 37 +++++++++++--------- 4 files changed, 40 insertions(+), 44 deletions(-) diff --git a/es-core/src/resources/TextureData.cpp b/es-core/src/resources/TextureData.cpp index 8497b09087..8b8de9d18c 100644 --- a/es-core/src/resources/TextureData.cpp +++ b/es-core/src/resources/TextureData.cpp @@ -144,24 +144,14 @@ bool TextureData::load() return retval; } -bool TextureData::isLoaded() +TextureData::LoadStatus TextureData::loadStatus() { std::unique_lock lock(mMutex); if (mDataRGBA || (mTextureID != 0)) - return true; - return false; -} - -bool TextureData::hasLoadFailed() -{ - std::unique_lock lock(mMutex); - return mLoadFailed; -} - -bool TextureData::isLoadedOrFailed() -{ - std::unique_lock lock(mMutex); - return mDataRGBA || (mTextureID != 0) || mLoadFailed; + return LoadStatus::LOADED; + if (mLoadFailed) + return LoadStatus::FAILED; + return LoadStatus::LOADING; } bool TextureData::uploadAndBind() diff --git a/es-core/src/resources/TextureData.h b/es-core/src/resources/TextureData.h index efbd023c80..912bbb0f4b 100644 --- a/es-core/src/resources/TextureData.h +++ b/es-core/src/resources/TextureData.h @@ -21,17 +21,18 @@ class TextureData bool initImageFromMemory(const unsigned char* fileData, size_t length); bool initFromRGBA(const unsigned char* dataRGBA, size_t width, size_t height); + enum class LoadStatus + { + LOADING, // not yet loaded or in progress + LOADED, // pixel data is in RAM or VRAM + FAILED // load() was attempted and permanently failed (e.g. file not found) + }; + // Read the data into memory if necessary bool load(); - bool isLoaded(); - // Returns true if a previous load() attempt failed (e.g. file not found). - // The texture will not be re-queued for loading while this is set. - bool hasLoadFailed(); - // Returns true if loading is complete — either successfully loaded or - // permanently failed. Single mutex acquisition; use in preference to - // !isLoaded() && !hasLoadFailed() or isLoaded() || hasLoadFailed(). - bool isLoadedOrFailed(); + // Returns the current load state under a single mutex acquisition. + LoadStatus loadStatus(); // Upload the texture to VRAM if necessary and bind. Returns true if bound ok or // false if either not loaded diff --git a/es-core/src/resources/TextureDataManager.cpp b/es-core/src/resources/TextureDataManager.cpp index c72ff8ea7f..b75c10de44 100644 --- a/es-core/src/resources/TextureDataManager.cpp +++ b/es-core/src/resources/TextureDataManager.cpp @@ -67,7 +67,7 @@ std::shared_ptr TextureDataManager::get(const TextureResource* key, // Make sure it's loaded or queued for loading. // Skip textures whose load previously failed (e.g. file not found) so // we don't keep re-queuing them every frame they are rendered. - if (enableLoading && !tex->isLoadedOrFailed()) + if (enableLoading && tex->loadStatus() == TextureData::LoadStatus::LOADING) load(tex); } return tex; @@ -91,7 +91,7 @@ size_t TextureDataManager::getTotalSize() { // Only count textures whose dimensions are known — calling width()/height() // on an unloaded texture triggers a synchronous load(). - if (tex->isLoaded()) + if (tex->loadStatus() == TextureData::LoadStatus::LOADED) total += tex->width() * tex->height() * 4; } return total; @@ -113,7 +113,7 @@ size_t TextureDataManager::getQueueSize() void TextureDataManager::load(std::shared_ptr tex, bool block) { // See if it's already loaded or has permanently failed - if (tex->isLoadedOrFailed()) + if (tex->loadStatus() != TextureData::LoadStatus::LOADING) return; // Not loaded. Make sure there is room size_t max_texture = (size_t)Settings::getInstance()->getInt("MaxVRAM") * 1024 * 1024; @@ -199,7 +199,7 @@ void TextureLoader::threadProc() void TextureLoader::load(std::shared_ptr textureData) { // Make sure it's not already loaded and hasn't permanently failed - if (!textureData->isLoadedOrFailed()) + if (textureData->loadStatus() == TextureData::LoadStatus::LOADING) { std::unique_lock lock(mMutex); // Remove it from the queue if it is already there @@ -239,7 +239,7 @@ size_t TextureLoader::getQueueSize() std::unique_lock lock(mMutex); for (auto tex : mTextureDataQ) { - if (tex->isLoaded()) + if (tex->loadStatus() == TextureData::LoadStatus::LOADED) mem += tex->width() * tex->height() * 4; } return mem; diff --git a/es-core/src/resources/TextureResource.cpp b/es-core/src/resources/TextureResource.cpp index 4abad52fd3..b543145743 100644 --- a/es-core/src/resources/TextureResource.cpp +++ b/es-core/src/resources/TextureResource.cpp @@ -182,7 +182,7 @@ bool TextureResource::hasLoadFailed() const data = mTextureData; else data = sTextureDataManager.get(this, false); - return data && data->hasLoadFailed(); + return data && data->loadStatus() == TextureData::LoadStatus::FAILED; } bool TextureResource::updateTextureSize() @@ -198,22 +198,27 @@ bool TextureResource::updateTextureSize() else data = sTextureDataManager.get(this, false); - if (data && data->isLoaded()) + if (!data) + return false; + + switch (data->loadStatus()) { - // The background thread has finished loading - read dimensions now - // (safe because isLoaded() guarantees data is available via mutex ordering) - mSize = Vector2i((int)data->width(), (int)data->height()); - mSourceSize = Vector2f(data->sourceWidth(), data->sourceHeight()); - return true; - } + case TextureData::LoadStatus::LOADED: + // The background thread has finished — read dimensions now + // (safe because LOADED guarantees data is available via mutex ordering) + mSize = Vector2i((int)data->width(), (int)data->height()); + mSourceSize = Vector2f(data->sourceWidth(), data->sourceHeight()); + return true; - // Load permanently failed (e.g. file not found). Treat as "done" so callers - // (e.g. ImageComponent) don't keep polling every frame waiting for a result - // that will never come. mSize stays (0,0) — the image simply won't display. - if (data && data->hasLoadFailed()) - return true; + case TextureData::LoadStatus::FAILED: + // Permanently failed (e.g. file not found). Treat as done so callers + // don't keep polling. mSize stays (0,0) — image simply won't display. + return true; - return false; + case TextureData::LoadStatus::LOADING: + default: + return false; + } } bool TextureResource::isInitialized() const @@ -260,7 +265,7 @@ bool TextureResource::unload() else data = mTextureData; - if (data != nullptr && data->isLoaded()) + if (data != nullptr && data->loadStatus() == TextureData::LoadStatus::LOADED) { data->releaseVRAM(); data->releaseRAM(); @@ -275,7 +280,7 @@ void TextureResource::reload() { // For dynamically loaded textures the texture manager will load them on demand. // For manually loaded textures we have to reload them here - if (mTextureData && !mTextureData->isLoadedOrFailed()) + if (mTextureData && mTextureData->loadStatus() == TextureData::LoadStatus::LOADING) mTextureData->load(); // Uncomment this 2 lines in future release in order to reload texture VRAM exactly as it was before From 333c990e57897f4e6b169d748f782f693d3883d9 Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 28 Feb 2026 19:40:16 -0800 Subject: [PATCH 13/16] Fix mAsyncPending stuck for background game list views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background views receive render() but not update(), so the updateTextureSize() poll in update() never runs for them. Check updateTextureSize() in the render skip block instead — handles both LOADED and FAILED states without needing update(). Remove now-unused hasLoadFailed() from TextureResource. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.cpp | 4 ++-- es-core/src/resources/TextureResource.cpp | 10 ---------- es-core/src/resources/TextureResource.h | 3 --- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 76d906501e..83491bb8ee 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -414,9 +414,9 @@ void ImageComponent::render(const Transform4x4f& parentTrans) } else if(mTexture && mAsyncPending) { - // Clear mAsyncPending for failed loads even when update() isn't being called + // Resolve mAsyncPending for failed loads even when update() isn't being called // (e.g. game list rendered in background during system carousel transition). - if(mTexture->hasLoadFailed()) + if(mTexture->updateTextureSize()) { mAsyncPending = false; } diff --git a/es-core/src/resources/TextureResource.cpp b/es-core/src/resources/TextureResource.cpp index b543145743..aac6b237b4 100644 --- a/es-core/src/resources/TextureResource.cpp +++ b/es-core/src/resources/TextureResource.cpp @@ -175,16 +175,6 @@ Vector2f TextureResource::getSourceImageSize() const return mSourceSize; } -bool TextureResource::hasLoadFailed() const -{ - std::shared_ptr data; - if (mTextureData != nullptr) - data = mTextureData; - else - data = sTextureDataManager.get(this, false); - return data && data->loadStatus() == TextureData::LoadStatus::FAILED; -} - bool TextureResource::updateTextureSize() { // If sizes are already known, nothing to do diff --git a/es-core/src/resources/TextureResource.h b/es-core/src/resources/TextureResource.h index 2cbee96815..e54a374df9 100644 --- a/es-core/src/resources/TextureResource.h +++ b/es-core/src/resources/TextureResource.h @@ -28,9 +28,6 @@ class TextureResource : public IReloadable // Returns true if sizes are available (texture loaded), false if still pending. bool updateTextureSize(); - // Returns true if a previous load attempt permanently failed (e.g. file not found). - bool hasLoadFailed() const; - virtual ~TextureResource(); bool isInitialized() const; From 8857b50cb965b5c38605a559b274e1bd1d71359d Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Sat, 28 Feb 2026 22:47:12 -0800 Subject: [PATCH 14/16] Pause video start delay while snapshot image is still loading When the NAS is slow to respond, the snapshot image may not finish loading before the configured video start delay expires, causing the image to never display and the video to start immediately after a blank screen. Keep pushing mStartTime forward in handleStartDelay() while mStaticImage.isAsyncPending() so the full delay runs only after the snapshot is actually visible to the user. Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.h | 1 + es-core/src/components/VideoComponent.cpp | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/es-core/src/components/ImageComponent.h b/es-core/src/components/ImageComponent.h index 8ac2ba98af..4a8b5b3679 100644 --- a/es-core/src/components/ImageComponent.h +++ b/es-core/src/components/ImageComponent.h @@ -82,6 +82,7 @@ class ImageComponent : public GuiComponent virtual std::vector getHelpPrompts() override; std::shared_ptr getTexture() { return mTexture; }; + bool isAsyncPending() const { return mAsyncPending; }; private: Vector2f mTargetSize; diff --git a/es-core/src/components/VideoComponent.cpp b/es-core/src/components/VideoComponent.cpp index 67f34f49eb..b5a35ff72e 100644 --- a/es-core/src/components/VideoComponent.cpp +++ b/es-core/src/components/VideoComponent.cpp @@ -232,6 +232,13 @@ void VideoComponent::handleStartDelay() // Only play if any delay has timed out if (mStartDelayed) { + // If the snapshot image is still loading, keep pushing the deadline forward + // so the delay doesn't expire before the user has had a chance to see it. + if (mConfig.showSnapshotDelay && mStaticImage.isAsyncPending()) + { + mStartTime = SDL_GetTicks() + mConfig.startDelay; + return; + } if (mStartTime > SDL_GetTicks()) { // Timeout not yet completed From 0541690c11a366a4117177b872fbc69ed45bc35d Mon Sep 17 00:00:00 2001 From: Ryan McClelland Date: Mon, 2 Mar 2026 21:57:34 -0800 Subject: [PATCH 15/16] Log elapsed time in ms for async image loads Co-Authored-By: Claude Sonnet 4.6 --- es-core/src/components/ImageComponent.cpp | 4 +++- es-core/src/components/ImageComponent.h | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/es-core/src/components/ImageComponent.cpp b/es-core/src/components/ImageComponent.cpp index 83491bb8ee..dd6ca48ad7 100644 --- a/es-core/src/components/ImageComponent.cpp +++ b/es-core/src/components/ImageComponent.cpp @@ -4,6 +4,7 @@ #include "Log.h" #include "Settings.h" #include "ThemeData.h" +#include Vector2i ImageComponent::getTextureSize() const { @@ -204,6 +205,7 @@ void ImageComponent::setImageAsync(std::string path, bool tile) // Texture is loading in background - resize() will be called from update() when ready LOG(LogDebug) << "setImageAsync: queued async for " << path; mAsyncPending = true; + mAsyncStartTime = SDL_GetTicks(); } } else @@ -220,7 +222,7 @@ void ImageComponent::update(int deltaTime) { if(mTexture->updateTextureSize()) { - LOG(LogDebug) << "ImageComponent::update: async load complete for " << mTexturePath << " size=" << mTexture->getSize().x() << "x" << mTexture->getSize().y(); + LOG(LogDebug) << "ImageComponent::update: async load complete for " << mTexturePath << " size=" << mTexture->getSize().x() << "x" << mTexture->getSize().y() << " time=" << (SDL_GetTicks() - mAsyncStartTime) << "ms"; mAsyncPending = false; resize(); } diff --git a/es-core/src/components/ImageComponent.h b/es-core/src/components/ImageComponent.h index 4a8b5b3679..5bbc12d097 100644 --- a/es-core/src/components/ImageComponent.h +++ b/es-core/src/components/ImageComponent.h @@ -112,6 +112,7 @@ class ImageComponent : public GuiComponent bool mDynamic; bool mRotateByTargetSize; bool mAsyncPending; + int mAsyncStartTime; Vector2f mTopLeftCrop; Vector2f mBottomRightCrop; From d609100af934d2d61f78bbb5c17baca6ef81d71a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 16:13:28 +0000 Subject: [PATCH 16/16] Fix non-stop texture flickering and unclean process exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs reported after async image loading: 1. Texture flickering after screensaver + random navigation The LRU eviction loop in TextureDataManager::load() was calling mLoader->remove() on LOADING textures, which have no data in RAM or VRAM (getVRAMUsage()==0). Evicting them frees zero memory but cancels their pending background load. The next bind() then re-triggers load(), which runs the eviction loop again — creating an evict→cancel→re-queue→ evict cascade that causes non-stop flickering under memory pressure. Fix: skip any texture whose loadStatus() != LOADED in the eviction loop. Only textures that actually hold pixel data are worth evicting. 2. Process stays alive after quit (two open unix streams in lsof) VideoVlcComponent::cleanupWorker() ran an infinite while(true) with no exit condition, and the thread was detach()ed so it could never be joined. Its condition variable and mutex were static class members whose destructors could be called while the thread was still blocked on them — undefined behaviour that can hang the process. Additionally, libvlc_release() was never called, leaving VLC's internal unix-domain sockets open. Fix: - Add sCleanupExit flag; cleanupWorker() breaks when flag is set and queue is empty. - Keep thread joinable (remove detach()). - Add VideoVlcComponent::deinit(): signals exit, joins thread, releases libvlc instance. - Call VideoVlcComponent::deinit() from main() after window.deinit() so all VideoVlcComponents are destroyed before the join. https://claude.ai/code/session_01PEfY7jzoFWHyPex93Ma7Ti --- es-app/src/main.cpp | 6 +++++ es-core/src/components/VideoVlcComponent.cpp | 26 ++++++++++++++++++-- es-core/src/components/VideoVlcComponent.h | 5 ++++ es-core/src/resources/TextureDataManager.cpp | 11 ++++++--- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/es-app/src/main.cpp b/es-app/src/main.cpp index 4447ed6220..a5abf7fede 100644 --- a/es-app/src/main.cpp +++ b/es-app/src/main.cpp @@ -18,6 +18,7 @@ #include "Settings.h" #include "SystemData.h" #include "SystemScreenSaver.h" +#include "components/VideoVlcComponent.h" #include #include #include @@ -483,6 +484,11 @@ int main(int argc, char* argv[]) InputManager::getInstance()->deinit(); window.deinit(); + // Join the VLC cleanup worker and release the VLC instance. Must happen after + // window.deinit() so all VideoVlcComponents are destroyed and their final + // stopVideo() cleanup tasks are already posted to the queue. + VideoVlcComponent::deinit(); + MameNames::deinit(); CollectionSystemManager::deinit(); waitForGamelistWrites(); diff --git a/es-core/src/components/VideoVlcComponent.cpp b/es-core/src/components/VideoVlcComponent.cpp index 3e1d6bbc1f..cb00d55180 100644 --- a/es-core/src/components/VideoVlcComponent.cpp +++ b/es-core/src/components/VideoVlcComponent.cpp @@ -23,6 +23,7 @@ std::mutex VideoVlcComponent::sCleanupMutex; std::condition_variable VideoVlcComponent::sCleanupCond; std::deque> VideoVlcComponent::sCleanupQueue; bool VideoVlcComponent::sCleanupRunning = false; +bool VideoVlcComponent::sCleanupExit = false; void VideoVlcComponent::cleanupWorker() { @@ -31,7 +32,9 @@ void VideoVlcComponent::cleanupWorker() std::function task; { std::unique_lock lock(sCleanupMutex); - sCleanupCond.wait(lock, [] { return !sCleanupQueue.empty(); }); + sCleanupCond.wait(lock, [] { return !sCleanupQueue.empty() || sCleanupExit; }); + if (sCleanupQueue.empty()) + break; // exit flag set and no remaining work task = std::move(sCleanupQueue.front()); sCleanupQueue.pop_front(); } @@ -48,13 +51,32 @@ void VideoVlcComponent::postCleanupTask(std::function task) { sCleanupRunning = true; sCleanupThread = std::thread(cleanupWorker); - sCleanupThread.detach(); + // Thread is kept joinable — deinit() will join it on shutdown. } sCleanupQueue.push_back(std::move(task)); } sCleanupCond.notify_one(); } +void VideoVlcComponent::deinit() +{ + // Signal the worker thread to exit once the queue is drained + { + std::lock_guard lock(sCleanupMutex); + sCleanupExit = true; + } + sCleanupCond.notify_one(); + + if (sCleanupRunning && sCleanupThread.joinable()) + sCleanupThread.join(); + + if (mVLC) + { + libvlc_release(mVLC); + mVLC = nullptr; + } +} + // VLC prepares to render a video frame. static void *lock(void *data, void **p_pixels) { struct VideoContext *c = (struct VideoContext *)data; diff --git a/es-core/src/components/VideoVlcComponent.h b/es-core/src/components/VideoVlcComponent.h index 16a5ba253e..bf7182db84 100644 --- a/es-core/src/components/VideoVlcComponent.h +++ b/es-core/src/components/VideoVlcComponent.h @@ -52,6 +52,10 @@ class VideoVlcComponent : public VideoComponent // Never breaks the aspect ratio. setMaxSize() and setResize() are mutually exclusive. void setMaxSize(float width, float height) override; + // Signal the cleanup worker to exit and wait for it to finish. + // Must be called after all VideoVlcComponent instances are destroyed. + static void deinit(); + private: // Calculates the correct mSize from our resizing information (set by setResize/setMaxSize). // Used internally whenever the resizing parameters or texture change. @@ -85,6 +89,7 @@ class VideoVlcComponent : public VideoComponent static std::condition_variable sCleanupCond; static std::deque> sCleanupQueue; static bool sCleanupRunning; + static bool sCleanupExit; libvlc_media_t* mMedia; libvlc_media_player_t* mMediaPlayer; VideoContext* mContext; diff --git a/es-core/src/resources/TextureDataManager.cpp b/es-core/src/resources/TextureDataManager.cpp index b75c10de44..b3dd87cf52 100644 --- a/es-core/src/resources/TextureDataManager.cpp +++ b/es-core/src/resources/TextureDataManager.cpp @@ -126,12 +126,15 @@ void TextureDataManager::load(std::shared_ptr tex, bool block) { if (size < max_texture) break; - //size -= (*it)->getVRAMUsage(); + // Only evict textures that actually have data in RAM or VRAM. + // Evicting a LOADING texture frees no memory (getVRAMUsage()==0) and cancels + // its pending background load — which then gets immediately re-queued on the + // next bind(), triggering another eviction cycle. Skipping these breaks the + // evict→cancel→re-queue→evict cascade that causes non-stop texture flickering. + if ((*it)->loadStatus() != TextureData::LoadStatus::LOADED) + continue; (*it)->releaseVRAM(); (*it)->releaseRAM(); - // It may be already in the loader queue. In this case it wouldn't have been using - // any VRAM yet but it will be. Remove it from the loader queue - mLoader->remove(*it); size = TextureResource::getTotalMemUsage(); } }