mirror of https://github.com/immich-app/immich.git
refactor(mobile): Immich image provider (#7016)
* 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>pull/7098/head
parent
4b3f8d1946
commit
9b4a770b9d
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
|
||||||
|
|
||||||
/// Loads the original image for local assets
|
|
||||||
@immutable
|
|
||||||
final class OriginalImageProvider extends ImageProvider<OriginalImageProvider> {
|
|
||||||
final Asset asset;
|
|
||||||
|
|
||||||
const OriginalImageProvider(this.asset);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<OriginalImageProvider> obtainKey(ImageConfiguration configuration) =>
|
|
||||||
SynchronousFuture<OriginalImageProvider>(this);
|
|
||||||
|
|
||||||
@override
|
|
||||||
ImageStreamCompleter loadImage(
|
|
||||||
OriginalImageProvider key,
|
|
||||||
ImageDecoderCallback decode,
|
|
||||||
) =>
|
|
||||||
MultiFrameImageStreamCompleter(
|
|
||||||
codec: _loadAsync(key, decode),
|
|
||||||
scale: 1.0,
|
|
||||||
informationCollector: () sync* {
|
|
||||||
yield ErrorDescription(asset.fileName);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<ui.Codec> _loadAsync(
|
|
||||||
OriginalImageProvider key,
|
|
||||||
ImageDecoderCallback decode,
|
|
||||||
) 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! OriginalImageProvider) return false;
|
|
||||||
if (identical(this, other)) return true;
|
|
||||||
return asset == other.asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => asset.hashCode;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue