Skip to content

fix(mobile): handle image stream completion when no image is emitted#25895

Closed
LeLunZ wants to merge 12 commits intoimmich-app:mainfrom
LeLunZ:bugfix/image-loading
Closed

fix(mobile): handle image stream completion when no image is emitted#25895
LeLunZ wants to merge 12 commits intoimmich-app:mainfrom
LeLunZ:bugfix/image-loading

Conversation

@LeLunZ
Copy link
Contributor

@LeLunZ LeLunZ commented Feb 4, 2026

Description

Fix fullscreen image stuck state by handling “stream completed without frames” in OneFramePlaceholderImageStreamCompleter.

When a thumbnail request is cancelled mid-load, the image stream can finish without emitting an ImageInfo and without an error. In that case getInitialImage() never completes its cachedOperation, so RemoteFullImageProvider blocks indefinitely on initialImageStream() and image never advances to preview/original.
This PR adds an onDone handler that reports a silent error when the stream completes without producing any frames or errors, unblocking getInitialImage() waiters and allowing fullscreen loading to proceed.

Other questions that are still open:

  • I thought cancelled images are getting evicted from the cache so...
    • How is the request getting cancelled when closing the timeline/gallery
    • And still reused in the full screen photo view? Thought its directly evicted from the cache

Another thing which I thought about what would be even better:

  • Why is the Thumbnail request even getting cancelled when clicking on a thumbnail to open it?
    • Isn't the whole point of having the caching, so we can hook into the request and not make another one?

But I am not sure how/if thats currently doable.

I don't really get the whole image loading pipeline, so please let me know what you think.

Fixes #25723

How Has This Been Tested?

  • Tested locally by opening a bunch of images on a really slow network connection

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if applicable
  • I have no unrelated changes in the PR.
  • I have confirmed that any new dependencies are strictly necessary.
  • I have written tests for new code (if applicable)
  • I have followed naming conventions/patterns in the surrounding code
  • All code in src/services/ uses repositories implementations for database calls, filesystem operations, etc.
  • All code in src/repositories/ is pretty basic/simple and does not have any immich specific logic (that belongs in src/services/)

Please describe to which degree, if any, an LLM was used in creating this pull request.

Getting a quick overview of the code, and some insights on where to investigate.

Copilot AI review requested due to automatic review settings February 4, 2026 18:57
@immich-push-o-matic
Copy link

immich-push-o-matic bot commented Feb 4, 2026

Label error. Requires exactly 1 of: changelog:.*. Found: 📱mobile. A maintainer will add the required label.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a mobile app issue where full-screen images get stuck in a loading state when users click on thumbnails before they finish loading. The fix adds an onDone handler to the OneFramePlaceholderImageStreamCompleter that reports a silent error when the image stream completes without emitting any frames or errors, unblocking waiting code and allowing image loading to proceed.

Changes:

  • Added boolean flags _emittedImage and _emittedError to track stream state
  • Added onDone handler that reports an error when stream completes without emitting anything
  • Wrapped stream callbacks to set the tracking flags appropriately

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@alextran1502 alextran1502 requested a review from mertalev February 4, 2026 19:12
@mertalev
Copy link
Member

mertalev commented Feb 4, 2026

Handling this in the stream completer with an error seems odd. I think this should be handled in the image provider. It seems like a control flow / sequencing issue if throwing an error like that makes it work.

@LeLunZ
Copy link
Contributor Author

LeLunZ commented Feb 5, 2026

@mertalev thx for the fast answer.

The problem I found is that the image loading is stuck at final cachedImage = await cachedOperation.valueOrCancellation(); in the initialImageStream method. And then nothing more happens.

I placed a log at the _ThumbnailState.dipose:

@override
  void dispose() {
    final imageProvider = widget.imageProvider;
    if (imageProvider is CancellableImageProviderMixin) {
      print('Cancelling image provider with asset ${imageProvider} ${imageProvider.isCancelled}');
      imageProvider.cancel();
    }

Then logs in CancellableImageProvider.initialImageStream:

@override
  Stream<ImageInfo> initialImageStream() async* {
    final cachedOperation = this.cachedOperation;
    print('Checking for cached image for assetId: ${assetId}');
    if (cachedOperation == null) {
      print('No cached image for assetId: ${assetId}');
      return;
    }

    try {
      print('Loading initial cached image for assetId: ${assetId}');
      final cachedImage = await cachedOperation.valueOrCancellation();
      print('Loaded initial cached image for assetId: ${assetId}: $cachedImage');
      if (cachedImage != null && !isCancelled) {
        yield cachedImage;
      }
    } catch (e, stack) {
      _log.severe('Error loading initial image', e, stack);
    } finally {
      this.cachedOperation = null;
    }
  }

The logs look like this

I/flutter (30848): Cancelling image provider with asset RemoteThumbProvider(assetId=c6478baf-bd2d-4312-8695-3a971877c98a, thumbhash=true) false
I/flutter (30848): Loading remote full image for assetId: c6478baf-bd2d-4312-8695-3a971877c98a
I/flutter (30848): Checking for cached image for assetId: c6478baf-bd2d-4312-8695-3a971877c98a
I/flutter (30848): Loading initial cached image for assetId: c6478baf-bd2d-4312-8695-3a971877c98a
I/flutter (30848): Cancelling image provider with asset RemoteThumbProvider(assetId=c6478baf-bd2d-4312-8695-3a971877c98a, thumbhash=true) false

As you can see from the logs it cancels the image from the dispose method of the Thumbnail widget (interestingly twice?)

Then initialImageStream is awaited on in _codec, in initialImageStream cachedOperation.valueOrCancellation is awaited and then initialImageStream never continues. Even though the next log message shows that the same asset request is cancelled.


For me it seems when the original OneFramePlaceholderImageStreamCompleter of the RemoteThumbProvider doesn't get an image (the original provider is cancelled), and also doesn't error, it goes stale.
It's still reused next time the when the same image gets resolved.
Adding the error, seems to signal that the ImageStreamCompleter is done.


I think important to understand is that getInitialImage only ever compltes if:

  • an image received
  • an error received

Both doesn't happen when the image request is cancelled. Also completer.operation.valueOrCancellation().whenComplete(() { doesn't run, as the CancelableCompleter itself is never cancelled, with completer.operation.cancel() (even though the operation is cancelled in the cancel method...)

@LeLunZ
Copy link
Contributor Author

LeLunZ commented Feb 5, 2026

Important finding: This never runs in the cancel method of the CancellableImageProvider:
Seems like the operation is always null, even when cancelled.

final operation = cachedOperation;
    if (operation != null) {
      print('Cancelling cached operation');
      cachedOperation = null;
      operation.cancel();
    }

@LeLunZ
Copy link
Contributor Author

LeLunZ commented Feb 6, 2026

@mertalev I studied the code a bit more, and found the issue + a fix.

Issue:
Currently if you press on a loading Thumbnail, the new route (or how its called in flutter) gets created where the RemoteFullImageProvider will try to wait on the initialImageStream of the same Thumbnail (where the request is loading and is still the cache).

The problem: the Thumbnail widget calls .cancel on the Thumbnail provider after the RemoteFullImageProvider already loaded the provider RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash).

So now the RemoteThumbProvider waits on the initialImage, while the Thumbnail widget canceled the request. So the provider never emits an image, and the RemoteFullImageProvider will wait forever.


In the current immich application there are 2 ways the image request can get cancelled:

  • by calling provider.cancel() (currently only the Thumbnail widget does that)
  • by the OneFramePlaceholderImageStreamCompleter if its disposed and invokes the callback

That makes it problematical, as no Widget actually owns an image/imagestream or whatever. So it shouldn't be able to forcefully cancel a request.
It can be that the "same" stream is used by multiple widgets


The fix:

Instead of letting widgets call cancel or waiting till the OneFramePlaceholderImageStreamCompleter is disposed (whenever flutter engine decides to), I think we should just cancel the image if no more listener is subscribed to the ImageStream.

So I think I implemented the quickest fix with the addOnLastListenerRemovedCallback in the ImageStreamCompleter.

No need to call .cancel anymore from the widgets, its just important that you always unsubscribe the stream if you don't need it anymore (same as before) in the dispose.


Another question (which doesn't have anything to do with the issue but just in general):
Why are we waiting on the initialImageStream in the FullImageProviders? If the images already exists in cache OneFramePlaceholderImageStreamCompleter synchronously sets it as preview.
If the preview don't exist, why wait on it and then send the actual request directly instead?

From OneFramePlaceholderImageStreamCompleter

 /// The [initialImage] is an optional image that will be emitted synchronously
  /// until the first stream image is completed, useful as a thumbnail or placeholder.

Or the other way around, why even have the getInitialImage function, if we do yield the initial image, in the _codec?

@LeLunZ
Copy link
Contributor Author

LeLunZ commented Feb 6, 2026

And it seems I messed up and also put other changes in the PR. Really weird.

Will close this and create a new one.

Sry

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile: Image doesn’t load if clicked before thumbnail has finished loading

3 participants