Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(mobile): Immich image provider #7016

Merged
merged 19 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,112 @@
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/modules/settings/services/app_settings.service.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);
},
);
}

//bool get _useOriginal => AppSettingsEnum.loadOriginal.defaultValue;
martyfuhry marked this conversation as resolved.
Show resolved Hide resolved
bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue;

// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
if (_loadPreview) {
martyfuhry marked this conversation as resolved.
Show resolved Hide resolved
// 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;
}