Skip to content

Commit

Permalink
refactor(mobile): Immich image provider (#7016)
Browse files Browse the repository at this point in the history
* Adds image provider

* uses image provider

* wip load preview

* wip everything but activity asset thumbnail needs some help with a remote id

* Immich provider used in gallery

* First draft of the immich image provider, working nicely!

* Removed OriginalImageProvider

* Fixes for thumbnails

* feat(mobile): thumbhash support (#7028)

* feat(mobile): thumbhash support

* perf(mobile): store bmp thumbhash bytes in Isar

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

* Uses octoimage for fade in and placeholders

* fixes thumbnails, removes unused values, adds better thumbnail size

* removes thumbhash support for now

* Forgot one thumbhash removal

* Use big thumbnail for local image on ios

* fix(mobile): Multipart image loading for iOS double swipe (#7064)

* uses local thumb first

* Multipart thumbnail

* Clean up file delete

* await file delete

* Fynn's comments, made thumbnail smaller and doesn't crash on erroring out on thumbnail

* lint

---------

Co-authored-by: Marty Fuhry <marty@fuhry.farm>
Co-authored-by: Alex <alex.tran1502@gmail.com>

* Moves http client to global private place for reuse

* Got rid of usePreview for local image providers since we always show a thumbnail anyway first

* linter

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Marty Fuhry <marty@fuhry.farm>
  • Loading branch information
5 people authored Feb 13, 2024
1 parent 4b3f8d1 commit 9b4a770
Show file tree
Hide file tree
Showing 21 changed files with 540 additions and 364 deletions.
2 changes: 1 addition & 1 deletion mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d

COCOAPODS: 1.11.3
COCOAPODS: 1.12.1
7 changes: 5 additions & 2 deletions mobile/lib/modules/activities/widgets/activity_tile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';

class ActivityTile extends HookConsumerWidget {
Expand Down Expand Up @@ -106,7 +106,10 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: ImmichImage.remoteThumbnailProviderForId(assetId),
image: ImmichRemoteImageProvider(
assetId: assetId,
isThumbnail: true,
),
fit: BoxFit.cover,
),
),
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/modules/album/ui/album_thumbnail_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class AlbumThumbnailCard extends StatelessWidget {
);
}

buildAlbumThumbnail() => ImmichImage(
buildAlbumThumbnail() => ImmichImage.thumbnail(
album.thumbnail.value,
width: cardSize,
height: cardSize,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
},
child: Stack(
children: [
ImmichImage(asset, width: 500, height: 500),
ImmichImage.thumbnail(
asset,
width: 500,
height: 500,
),
],
),
);
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/modules/album/views/sharing_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class SharingPage extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichImage(
child: ImmichImage.thumbnail(
album.thumbnail.value,
width: 60,
height: 60,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;

import 'package:cached_network_image/cached_network_image.dart';

import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:photo_manager/photo_manager.dart';

/// The local image provider for an asset
/// Only viable
class ImmichLocalImageProvider extends ImageProvider<Asset> {
final Asset asset;

ImmichLocalImageProvider({
required this.asset,
}) : assert(asset.local != null, 'Only usable when asset.local is set');

/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<Asset> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(asset);
}

@override
ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
}

// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a small thumbnail
final thumbBytes = await asset.local?.thumbnailDataWithSize(
const ThumbnailSize.square(256),
quality: 80,
);
if (thumbBytes != null) {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer);
yield codec;
} else {
debugPrint("Loading thumb for ${asset.fileName} failed");
}

if (asset.isImage) {
/// Using 2K thumbnail for local iOS image to avoid double swiping issue
if (Platform.isIOS) {
final largeImageBytes = await asset.local
?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160));
if (largeImageBytes == null) {
throw StateError(
"Loading thumb for local photo ${asset.fileName} failed",
);
}
final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes);
final codec = await decode(buffer);
yield codec;
} else {
// Use the original file for Android
final File? file = await asset.local?.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
try {
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
final codec = await decode(buffer);
yield codec;
} catch (error) {
throw StateError("Loading asset ${asset.fileName} failed");
} finally {
if (Platform.isIOS) {
// Clean up this file
await file.delete();
}
}
}
}

chunkEvents.close();
}

@override
bool operator ==(Object other) {
if (other is! ImmichLocalImageProvider) return false;
if (identical(this, other)) return true;
return asset == other.asset;
}

@override
int get hashCode => asset.hashCode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;

import 'package:cached_network_image/cached_network_image.dart';
import 'package:openapi/api.dart' as api;

import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';

/// Our Image Provider HTTP client to make the request
final _httpClient = HttpClient()..autoUncompress = false;

/// The remote image provider
class ImmichRemoteImageProvider extends ImageProvider<String> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;

// If this is a thumbnail, we stop at loading the
// smallest version of the remote image
final bool isThumbnail;

ImmichRemoteImageProvider({
required this.assetId,
this.isThumbnail = false,
});

/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<String> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture('$assetId,$isThumbnail');
}

@override
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) {
final id = key.split(',').first;
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(id, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
}

/// Whether to show the original file or load a compressed version
bool get _useOriginal => Store.get(
AppSettingsEnum.loadOriginal.storeKey,
AppSettingsEnum.loadOriginal.defaultValue,
);

/// Whether to load the preview thumbnail first or not
bool get _loadPreview => Store.get(
AppSettingsEnum.loadPreview.storeKey,
AppSettingsEnum.loadPreview.defaultValue,
);

// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
String key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a preview to the chunk events
if (_loadPreview || isThumbnail) {
final preview = getThumbnailUrlForRemoteId(
assetId,
type: api.ThumbnailFormat.WEBP,
);

yield await _loadFromUri(
Uri.parse(preview),
decode,
chunkEvents,
);
}

// Guard thumnbail rendering
if (isThumbnail) {
await chunkEvents.close();
return;
}

// Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId(
assetId,
type: api.ThumbnailFormat.JPEG,
);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
yield codec;

// Load the final remote image
if (_useOriginal) {
// Load the original image
final url = getImageUrlFromId(assetId);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
yield codec;
}
await chunkEvents.close();
}

// Loads the codec from the URI and sends the events to the [chunkEvents] stream
Future<ui.Codec> _loadFromUri(
Uri uri,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
final request = await _httpClient.getUrl(uri);
request.headers.add(
'x-immich-user-token',
Store.get(StoreKey.accessToken),
);
final response = await request.close();
// Chunks of the completed image can be shown
final data = await consolidateHttpClientResponseBytes(
response,
onBytesReceived: (cumulative, total) {
chunkEvents.add(
ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
),
);
},
);

// Decode the response
final buffer = await ui.ImmutableBuffer.fromUint8List(data);
return decode(buffer);
}

@override
bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true;
return assetId == other.assetId;
}

@override
int get hashCode => assetId.hashCode;
}
Loading

0 comments on commit 9b4a770

Please sign in to comment.