Skip to content

feat(mobile): add support for animated images (GIFs) in the asset viewer#26148

Closed
LeLunZ wants to merge 10 commits intoimmich-app:mainfrom
LeLunZ:feature/animated-images
Closed

feat(mobile): add support for animated images (GIFs) in the asset viewer#26148
LeLunZ wants to merge 10 commits intoimmich-app:mainfrom
LeLunZ:feature/animated-images

Conversation

@LeLunZ
Copy link
Collaborator

@LeLunZ LeLunZ commented Feb 11, 2026

Description

This PR introduces proper animated image (e.g. GIF) support to the FullImageProviders in the mobile app. Until now, animated images were displayed as static frames, effectively demoting GIFs to old school static JPEGs.
With this change, animated images finally animate in the full image view.
In the Gallery an animated Image can be recognised by the GIF Badge where also the LivePhoto or Video badge is located. See the Screenshot section

Open Question

The only unresolved design concerns:

  • For local-only images only (not uploaded to immich!), we don’t have a, pre-known way to determine whether the image is animated (e.g., checking duration or frame count efficiently).
    We could load the image, and check exif data, but I wanted some feedback first.
  • we have to return a Codec from the loadCodec function, so if the request gets cancelled we can't return null but instead throw an Error. If you have other ideas please let me know.

Fixes #9628

How Has This Been Tested?

  • See screenshots.
  • uploaded gifs from other device and tested remote only
  • uploaded gif from same device and tested remote + local
  • tested local only (sadly not working)

Screenshots (if appropriate)

Bildschirmfoto 2026-02-11 um 21 54 34
Bildschirmaufnahme.2026-02-11.um.21.55.11.mov

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.

Claude implemented the loadCodec method of the LocalImageRequest, after I had a draft version of the whole RemoteImage working.

Copilot AI review requested due to automatic review settings February 11, 2026 21:42
@immich-push-o-matic
Copy link

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

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

@LeLunZ
Copy link
Collaborator Author

LeLunZ commented Feb 11, 2026

Technical Summary

  • The detection logic for determining whether an image is animated is aligned with the web implementation.
    • check if isImage && durationInSeconds != null && durationInSeconds! > 0

1. Animated Image Support in FullImageProviders

  • FullRemoteImageProvider and FullLocalImageProvider now support animated images.
  • If an image is marked as animated, an AnimatedImageStreamCompleter is used instead of OneFramePlaceholderImageStreamCompleter.
  • Animated images require loading the original asset, not thumbnails. I copied that from the web app, as I noticed the generated Images aren't animated.

2. ImageRequest Refactoring

Several structural updates were necessary:

New Method: loadCodec

which returns Future<Codec> and is passed into the new AnimatedImageStreamCompleter.

loadCodec Added to:

  • RemoteImageRequest
  • LocalImageRequest

This allows retrieving a Codec directly without forcing frame decoding.

_fromEncodedPlatformImage Split

Split into:

  • new _codecFromEncodedPlatformImage (returns Codec only, does not call getNextFrame())
  • old _fromEncodedPlatformImage (calls new method and decodes first frame from codec; Behaviour is unchanged)

3. LocalImageRequest Implementation Detail

LocalImageRequest.loadCodec currently:

  • Resolves the actual file directly
  • Does not use the native platform plugin

Feedback on this would be appreciated.


4. AnimatedImageStreamCompleter

Added new:

AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter
  • Proper multi-frame image streaming
  • Cancels image requests when the provider is no longer needed
  • Integrates cleanly with the existing provider

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

Adds animated image (GIF) playback support to the mobile asset viewer by introducing multi-frame codec loading and wiring an isAnimated flag through the full-image providers, plus a GIF badge in the gallery tiles.

Changes:

  • Add isAnimated detection (BaseAsset.isAnimatedImage) and propagate it into LocalFullImageProvider / RemoteFullImageProvider.
  • Implement animated image loading via ui.Codec (loadCodec) and a new AnimatedImageStreamCompleter.
  • Show a GIF badge overlay in thumbnail tiles when the asset is detected as animated.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
mobile/lib/domain/models/asset/base_asset.model.dart Adds isAnimatedImage getter used to decide whether to load as animated.
mobile/lib/presentation/widgets/images/image_provider.dart Passes isAnimated into full-image providers.
mobile/lib/presentation/widgets/images/local_image_provider.dart Adds animated loading path using AnimatedImageStreamCompleter + local loadCodec().
mobile/lib/presentation/widgets/images/remote_image_provider.dart Adds animated loading path using AnimatedImageStreamCompleter + remote loadCodec().
mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart New stream completer to cancel/cleanup when listeners are gone for animated images.
mobile/lib/infrastructure/loaders/image_request.dart Refactors to expose _codecFromEncodedPlatformImage and adjusts imports/parts for codec loading.
mobile/lib/infrastructure/loaders/local_image_request.dart Adds loadCodec() that builds a multi-frame ui.Codec from the origin file.
mobile/lib/infrastructure/loaders/remote_image_request.dart Adds loadCodec() for remote images (currently only for encoded pointer/length responses).
mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart Adds GIF badge overlay based on asset.isAnimatedImage.
mobile/lib/widgets/common/immich_image.dart Updates provider construction to pass isAnimated: false for video usage.

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

import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;

import '../../domain/models/asset/base_asset.model.dart';
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

This file mixes package: imports with a relative import for base_asset.model.dart. If the project’s linting prefers package imports (which appears to be the norm elsewhere), switch this back to a package:immich_mobile/... import for consistency and to avoid relative-path brittleness during refactors.

Copilot uses AI. Check for mistakes.
@LeLunZ LeLunZ marked this pull request as draft February 11, 2026 22:16
@LeLunZ
Copy link
Collaborator Author

LeLunZ commented Feb 11, 2026

Hmm, just found out IOS uses a native decoder, needs a bit more changes :(

Comment on lines +39 to +47
final entity = await AssetEntity.fromId(localId);
if (entity == null || _isCancelled) {
return null;
}

final file = await entity.originFile;
if (file == null || _isCancelled) {
return null;
}
Copy link
Member

Choose a reason for hiding this comment

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

Image loading is handled platform-side using native APIs and should not involve external dependencies beyond Pigeon. For local assets, the approach would be to check asset.playbackStyle == .imageAnimated in the Swift requestImage impl and return the encoded image using requestImageDataAndOrientation instead in this case. For remote images, the isAnimatedImage flag you have could be passed to the RemoteImageImpl's requestImage.

Copy link
Collaborator Author

@LeLunZ LeLunZ Feb 12, 2026

Choose a reason for hiding this comment

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

For local assets, the approach would be to check asset.playbackStyle == .imageAnimated in the Swift requestImage impl and return the encoded image using requestImageDataAndOrientation

So you mean for local images the steps should be:

  • requestImage
  • check what got returned
  • if its the encoded image we have to return the AnimatedImageStreamCompleter
  • if its not, we use the OnePlaceholderImageStreamCompleter

The problem: with this approach, we can't show the GIF badge for Local Only assets in the gallery, as we only know if the image is animated after loading it.

For images that are local and remote, we can show the GIF badge, but if Setting.preferRemoteImage if false, it would still depend on the image thats available locally if we then actually can show an animation or not.

Would it be possible to check if an image is animated, in the sync job/when querying the list of local assets?
Then we could also show the information in the gallery.


For remote, I already tried it like you suggested, passing the flag into the requestImage works great.

Copy link
Member

Choose a reason for hiding this comment

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

I think it'd make sense to add it to the local sync with a playbackStyle enum column in the local asset table.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have found an issue when testing.

When checking the duration for remote assets everything works fine for GIFs that are longer than a second, but as the durationInSeconds is of type int, we can't detect animated images that are shorter than a second.

This would need a change from durationInSeconds to durationInMilliseconds or from int to double.
I generally prefer the durationInMilliseconds but that would be a really big change. What do you prefer?

The web already does the millisecond check, because there a timestring is currently saved for the duration see here: asset.duration && !asset.duration.includes('0:00:00.000');

Would this be okay, if done in a later pr? Or should it be done in here?

Copy link
Member

Choose a reason for hiding this comment

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

I think int milliseconds is better, but a change like that is out of scope for this PR. For now, treating <1s as static is fine.

@LeLunZ LeLunZ force-pushed the feature/animated-images branch from 4c84be6 to 00e38da Compare February 13, 2026 23:46
@LeLunZ
Copy link
Collaborator Author

LeLunZ commented Feb 13, 2026

@mertalev added the discussed changes:

  • there is now a encoding flag for the image requests.
  • and in the local assets there is a new enum entry for playback playbackStyle
  • on android animated videos are only detected by the mime_type, and I only added GIF there. Not sure if there is a better solution...

I wasn't sure where to add the playback style, for example if it's also wanted in the merged asset. So for now I just added the property to the local assets.

Also on IOS there is the playback style .videoLooping which I didn't add additionally and just put it in as "video". Do we want to add videoLooping also as playback type?

A lot of the native changes are done with LLM support, so thats something you please have to look over. I tried my best and confirmed changes with multiple llms, but yeah...

Also I didn't know if I should create a new migration or just append to v19, as this one didn't make it to a release yet.
For now I added it to v19, but changing it, isn't a hassle.


I think the pr is now ready for some more feedback :D

@LeLunZ LeLunZ requested a review from mertalev February 13, 2026 23:54
@LeLunZ LeLunZ marked this pull request as ready for review February 13, 2026 23:55
@LeLunZ LeLunZ force-pushed the feature/animated-images branch from cb7af7a to e631ae6 Compare February 14, 2026 20:46
@LeLunZ
Copy link
Collaborator Author

LeLunZ commented Feb 14, 2026

Okay, so I asked in discord about the db migration, and there the answer was to create a new one. So I reseted v19 to be same previously and created v20.

Additionally I noticed instead of setting null, when a image is restored from trash, we can actually set the playback style if we check the image type and duration.

@mertalev
Copy link
Member

I haven't forgotten about this PR, but it makes a lot of changes so will take time to get through it. It might be best to split it up so the migration is done in one PR, populating it in the local sync is another PR and using it to determine how to load images is another PR.

@LeLunZ
Copy link
Collaborator Author

LeLunZ commented Feb 26, 2026

Sure no problem I can split it up :)

@LeLunZ
Copy link
Collaborator Author

LeLunZ commented Mar 2, 2026

Closing this in favour of smaller PRs

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.

Unable to play GIF on mobile

3 participants