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 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 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,7 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: ImmichImage.remoteThumbnailProviderForId(assetId),
image: ImmichRemoteImageProvider(assetId: assetId),
martyfuhry marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -0,0 +1,98 @@
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';

/// 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
// TODO: Use local preview
}
yield await _loadOriginalCodec(key, decode, chunkEvents);
}

/// The local codec for local images
Future<ui.Codec> _loadOriginalCodec(
Asset key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
final ui.ImmutableBuffer buffer;
if (asset.isImage) {
final File? file = await asset.local?.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
try {
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
} catch (error) {
throw StateError("Loading asset ${asset.fileName} failed");
}
} else {
final thumbBytes = await asset.local?.thumbnailData;
if (thumbBytes == null) {
throw StateError("Loading thumb for video ${asset.fileName} failed");
}
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
}
try {
final codec = await decode(buffer);
debugPrint("Decoded image ${asset.fileName}");
return codec;
} catch (error) {
throw StateError("Decoding asset ${asset.fileName} failed");
}
}

@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,139 @@
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';

/// 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;

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

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 => AppSettingsEnum.loadOriginal.defaultValue;

/// Whether to load the preview thumbnail first or not
bool get _loadPreview => AppSettingsEnum.loadPreview.defaultValue;
martyfuhry marked this conversation as resolved.
Show resolved Hide resolved

// 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;

import 'package:cached_network_image/cached_network_image.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
import 'package:openapi/api.dart' as api;

import 'package:flutter/foundation.dart';
import 'package:flutter/painting.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';

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

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

ImmichRemoteThumbnailProvider({
required this.assetId,
});

/// 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);
}

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

// 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
final preview = getThumbnailUrlForRemoteId(
assetId,
type: api.ThumbnailFormat.WEBP,
);

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

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