mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
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>
This commit is contained in:
parent
4b3f8d1946
commit
9b4a770b9d
@ -180,4 +180,4 @@ SPEC CHECKSUMS:
|
|||||||
|
|
||||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
||||||
|
|
||||||
COCOAPODS: 1.11.3
|
COCOAPODS: 1.12.1
|
||||||
|
@ -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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/datetime_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/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/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';
|
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
|
||||||
|
|
||||||
class ActivityTile extends HookConsumerWidget {
|
class ActivityTile extends HookConsumerWidget {
|
||||||
@ -106,7 +106,10 @@ class _ActivityAssetThumbnail extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: ImmichImage.remoteThumbnailProviderForId(assetId),
|
image: ImmichRemoteImageProvider(
|
||||||
|
assetId: assetId,
|
||||||
|
isThumbnail: true,
|
||||||
|
),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -45,7 +45,7 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildAlbumThumbnail() => ImmichImage(
|
buildAlbumThumbnail() => ImmichImage.thumbnail(
|
||||||
album.thumbnail.value,
|
album.thumbnail.value,
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
|
@ -16,7 +16,11 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ImmichImage(asset, width: 500, height: 500),
|
ImmichImage.thumbnail(
|
||||||
|
asset,
|
||||||
|
width: 500,
|
||||||
|
height: 500,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -72,7 +72,7 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
child: ImmichImage(
|
child: ImmichImage.thumbnail(
|
||||||
album.thumbnail.value,
|
album.thumbnail.value,
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -25,7 +25,6 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'
|
|||||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||||
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
|
||||||
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||||
@ -41,8 +40,6 @@ import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.da
|
|||||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart' show ThumbnailFormat;
|
import 'package:openapi/api.dart' show ThumbnailFormat;
|
||||||
|
|
||||||
@ -78,7 +75,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final isPlayingMotionVideo = useState(false);
|
final isPlayingMotionVideo = useState(false);
|
||||||
final isPlayingVideo = useState(false);
|
final isPlayingVideo = useState(false);
|
||||||
Offset? localPosition;
|
Offset? localPosition;
|
||||||
final header = {"x-immich-user-token": Store.get(StoreKey.accessToken)};
|
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useState(initialIndex);
|
||||||
final currentAsset = loadAsset(currentIndex.value);
|
final currentAsset = loadAsset(currentIndex.value);
|
||||||
final isTrashEnabled =
|
final isTrashEnabled =
|
||||||
@ -135,53 +131,18 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
void toggleFavorite(Asset asset) =>
|
void toggleFavorite(Asset asset) =>
|
||||||
ref.read(assetProvider.notifier).toggleFavorite([asset]);
|
ref.read(assetProvider.notifier).toggleFavorite([asset]);
|
||||||
|
|
||||||
/// Original (large) image of a remote asset. Required asset.isRemote
|
|
||||||
ImageProvider remoteOriginalProvider(Asset asset) =>
|
|
||||||
CachedNetworkImageProvider(
|
|
||||||
getImageUrl(asset),
|
|
||||||
cacheKey: getImageCacheKey(asset),
|
|
||||||
headers: header,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Original (large) image of a local asset. Required asset.isLocal
|
|
||||||
ImageProvider localOriginalProvider(Asset asset) =>
|
|
||||||
OriginalImageProvider(asset);
|
|
||||||
|
|
||||||
ImageProvider finalImageProvider(Asset asset) {
|
|
||||||
if (ImmichImage.useLocal(asset)) {
|
|
||||||
return localOriginalProvider(asset);
|
|
||||||
} else if (isLoadOriginal.value) {
|
|
||||||
return remoteOriginalProvider(asset);
|
|
||||||
} else if (isLoadPreview.value) {
|
|
||||||
return ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
|
|
||||||
}
|
|
||||||
return ImmichImage.remoteThumbnailProvider(asset, webp, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
Iterable<ImageProvider> allImageProviders(Asset asset) sync* {
|
|
||||||
if (ImmichImage.useLocal(asset)) {
|
|
||||||
yield ImmichImage.localImageProvider(asset);
|
|
||||||
yield localOriginalProvider(asset);
|
|
||||||
} else {
|
|
||||||
yield ImmichImage.remoteThumbnailProvider(asset, webp, header);
|
|
||||||
if (isLoadPreview.value) {
|
|
||||||
yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
|
|
||||||
}
|
|
||||||
if (isLoadOriginal.value) {
|
|
||||||
yield remoteOriginalProvider(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void precacheNextImage(int index) {
|
void precacheNextImage(int index) {
|
||||||
void onError(Object exception, StackTrace? stackTrace) {
|
void onError(Object exception, StackTrace? stackTrace) {
|
||||||
// swallow error silently
|
// swallow error silently
|
||||||
|
debugPrint('Error precaching next image: $exception, $stackTrace');
|
||||||
}
|
}
|
||||||
if (index < totalAssets && index >= 0) {
|
if (index < totalAssets && index >= 0) {
|
||||||
final asset = loadAsset(index);
|
final asset = loadAsset(index);
|
||||||
for (final imageProvider in allImageProviders(asset)) {
|
precacheImage(
|
||||||
precacheImage(imageProvider, context, onError: onError);
|
ImmichImage.imageProvider(asset: asset),
|
||||||
}
|
context,
|
||||||
|
onError: onError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -765,6 +726,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||||
},
|
},
|
||||||
|
loadingBuilder: (context, event, index) => ImmichImage.thumbnail(
|
||||||
|
asset(),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
pageController: controller,
|
pageController: controller,
|
||||||
scrollPhysics: isZoomed.value
|
scrollPhysics: isZoomed.value
|
||||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||||
@ -781,47 +746,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
stackIndex.value = -1;
|
stackIndex.value = -1;
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
},
|
},
|
||||||
loadingBuilder: (context, event, index) {
|
|
||||||
final a = loadAsset(index);
|
|
||||||
if (ImmichImage.useLocal(a)) {
|
|
||||||
return Image(
|
|
||||||
image: ImmichImage.localImageProvider(a),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
|
||||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
|
||||||
final webPThumbnail = CachedNetworkImage(
|
|
||||||
imageUrl: getThumbnailUrl(a, type: webp),
|
|
||||||
cacheKey: getThumbnailCacheKey(a, type: webp),
|
|
||||||
httpHeaders: header,
|
|
||||||
progressIndicatorBuilder: (_, __, ___) => const Center(
|
|
||||||
child: ImmichLoadingIndicator(),
|
|
||||||
),
|
|
||||||
fadeInDuration: const Duration(milliseconds: 0),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
errorWidget: (context, url, error) =>
|
|
||||||
const Icon(Icons.image_not_supported_outlined),
|
|
||||||
);
|
|
||||||
|
|
||||||
// loading the preview in the loadingBuilder only
|
|
||||||
// makes sense if the original is loaded in the builder
|
|
||||||
return isLoadPreview.value && isLoadOriginal.value
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: getThumbnailUrl(a, type: jpeg),
|
|
||||||
cacheKey: getThumbnailCacheKey(a, type: jpeg),
|
|
||||||
httpHeaders: header,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
fadeInDuration: const Duration(milliseconds: 0),
|
|
||||||
placeholder: (_, __) => webPThumbnail,
|
|
||||||
errorWidget: (_, __, ___) => webPThumbnail,
|
|
||||||
)
|
|
||||||
: webPThumbnail;
|
|
||||||
},
|
|
||||||
builder: (context, index) {
|
builder: (context, index) {
|
||||||
final a =
|
final a =
|
||||||
index == currentIndex.value ? asset() : loadAsset(index);
|
index == currentIndex.value ? asset() : loadAsset(index);
|
||||||
final ImageProvider provider = finalImageProvider(a);
|
final ImageProvider provider =
|
||||||
|
ImmichImage.imageProvider(asset: a);
|
||||||
|
|
||||||
if (a.isImage && !isPlayingMotionVideo.value) {
|
if (a.isImage && !isPlayingMotionVideo.value) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
|
@ -136,10 +136,8 @@ class ThumbnailImage extends StatelessWidget {
|
|||||||
tag: isFromDto
|
tag: isFromDto
|
||||||
? '${asset.remoteId}-$heroOffset'
|
? '${asset.remoteId}-$heroOffset'
|
||||||
: asset.id + heroOffset,
|
: asset.id + heroOffset,
|
||||||
child: ImmichImage(
|
child: ImmichImage.thumbnail(
|
||||||
asset,
|
asset,
|
||||||
useGrayBoxPlaceholder: useGrayBoxPlaceholder,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -8,7 +8,6 @@ import 'package:immich_mobile/shared/models/asset.dart';
|
|||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class MemoryCard extends StatelessWidget {
|
class MemoryCard extends StatelessWidget {
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
@ -84,8 +83,6 @@ class MemoryCard extends StatelessWidget {
|
|||||||
fit: fit,
|
fit: fit,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
type: ThumbnailFormat.JPEG,
|
|
||||||
preferredLocalAssetSize: 2048,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -97,8 +94,6 @@ class MemoryCard extends StatelessWidget {
|
|||||||
placeholder: ImmichImage(
|
placeholder: ImmichImage(
|
||||||
asset,
|
asset,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
type: ThumbnailFormat.JPEG,
|
|
||||||
preferredLocalAssetSize: 2048,
|
|
||||||
),
|
),
|
||||||
hideControlsTimer: const Duration(seconds: 2),
|
hideControlsTimer: const Duration(seconds: 2),
|
||||||
onVideoEnded: onVideoEnded,
|
onVideoEnded: onVideoEnded,
|
||||||
|
@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
|
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class MemoryLane extends HookConsumerWidget {
|
class MemoryLane extends HookConsumerWidget {
|
||||||
const MemoryLane({super.key});
|
const MemoryLane({super.key});
|
||||||
@ -62,7 +61,6 @@ class MemoryLane extends HookConsumerWidget {
|
|||||||
width: 130,
|
width: 130,
|
||||||
height: 200,
|
height: 200,
|
||||||
useGrayBoxPlaceholder: true,
|
useGrayBoxPlaceholder: true,
|
||||||
type: ThumbnailFormat.JPEG,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart';
|
|||||||
import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart';
|
import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:openapi/api.dart' as api;
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class MemoryPage extends HookConsumerWidget {
|
class MemoryPage extends HookConsumerWidget {
|
||||||
@ -113,23 +112,21 @@ class MemoryPage extends HookConsumerWidget {
|
|||||||
// Gets the thumbnail url and precaches it
|
// Gets the thumbnail url and precaches it
|
||||||
final precaches = <Future<dynamic>>[];
|
final precaches = <Future<dynamic>>[];
|
||||||
|
|
||||||
precaches.add(
|
precaches.addAll([
|
||||||
ImmichImage.precacheAsset(
|
precacheImage(
|
||||||
asset,
|
ImmichImage.imageProvider(
|
||||||
context,
|
asset: asset,
|
||||||
type: api.ThumbnailFormat.WEBP,
|
|
||||||
size: 2048,
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
precaches.add(
|
|
||||||
ImmichImage.precacheAsset(
|
|
||||||
asset,
|
|
||||||
context,
|
context,
|
||||||
type: api.ThumbnailFormat.JPEG,
|
|
||||||
size: 2048,
|
|
||||||
),
|
),
|
||||||
);
|
precacheImage(
|
||||||
|
ImmichImage.imageProvider(
|
||||||
|
asset: asset,
|
||||||
|
isThumbnail: true,
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
]);
|
||||||
await Future.wait(precaches);
|
await Future.wait(precaches);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,6 +354,9 @@ abstract class _$AppRouter extends RootStackRouter {
|
|||||||
onPlaying: args.onPlaying,
|
onPlaying: args.onPlaying,
|
||||||
onPaused: args.onPaused,
|
onPaused: args.onPaused,
|
||||||
placeholder: args.placeholder,
|
placeholder: args.placeholder,
|
||||||
|
showControls: args.showControls,
|
||||||
|
hideControlsTimer: args.hideControlsTimer,
|
||||||
|
showDownloadingIndicator: args.showDownloadingIndicator,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -1384,11 +1387,14 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
|||||||
VideoViewerRoute({
|
VideoViewerRoute({
|
||||||
Key? key,
|
Key? key,
|
||||||
required Asset asset,
|
required Asset asset,
|
||||||
required bool isMotionVideo,
|
bool isMotionVideo = false,
|
||||||
required void Function() onVideoEnded,
|
void Function()? onVideoEnded,
|
||||||
void Function()? onPlaying,
|
void Function()? onPlaying,
|
||||||
void Function()? onPaused,
|
void Function()? onPaused,
|
||||||
Widget? placeholder,
|
Widget? placeholder,
|
||||||
|
bool showControls = true,
|
||||||
|
Duration hideControlsTimer = const Duration(seconds: 5),
|
||||||
|
bool showDownloadingIndicator = true,
|
||||||
List<PageRouteInfo>? children,
|
List<PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
VideoViewerRoute.name,
|
VideoViewerRoute.name,
|
||||||
@ -1400,6 +1406,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
|||||||
onPlaying: onPlaying,
|
onPlaying: onPlaying,
|
||||||
onPaused: onPaused,
|
onPaused: onPaused,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
|
showControls: showControls,
|
||||||
|
hideControlsTimer: hideControlsTimer,
|
||||||
|
showDownloadingIndicator: showDownloadingIndicator,
|
||||||
),
|
),
|
||||||
initialChildren: children,
|
initialChildren: children,
|
||||||
);
|
);
|
||||||
@ -1414,11 +1423,14 @@ class VideoViewerRouteArgs {
|
|||||||
const VideoViewerRouteArgs({
|
const VideoViewerRouteArgs({
|
||||||
this.key,
|
this.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.isMotionVideo,
|
this.isMotionVideo = false,
|
||||||
required this.onVideoEnded,
|
this.onVideoEnded,
|
||||||
this.onPlaying,
|
this.onPlaying,
|
||||||
this.onPaused,
|
this.onPaused,
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
|
this.showControls = true,
|
||||||
|
this.hideControlsTimer = const Duration(seconds: 5),
|
||||||
|
this.showDownloadingIndicator = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
@ -1427,7 +1439,7 @@ class VideoViewerRouteArgs {
|
|||||||
|
|
||||||
final bool isMotionVideo;
|
final bool isMotionVideo;
|
||||||
|
|
||||||
final void Function() onVideoEnded;
|
final void Function()? onVideoEnded;
|
||||||
|
|
||||||
final void Function()? onPlaying;
|
final void Function()? onPlaying;
|
||||||
|
|
||||||
@ -1435,8 +1447,14 @@ class VideoViewerRouteArgs {
|
|||||||
|
|
||||||
final Widget? placeholder;
|
final Widget? placeholder;
|
||||||
|
|
||||||
|
final bool showControls;
|
||||||
|
|
||||||
|
final Duration hideControlsTimer;
|
||||||
|
|
||||||
|
final bool showDownloadingIndicator;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder}';
|
return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
mobile/lib/shared/cache/custom_image_cache.dart
vendored
11
mobile/lib/shared/cache/custom_image_cache.dart
vendored
@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart';
|
||||||
import 'original_image_provider.dart';
|
|
||||||
|
|
||||||
/// [ImageCache] that uses two caches for small and large images
|
/// [ImageCache] that uses two caches for small and large images
|
||||||
/// so that a single large image does not evict all small iamges
|
/// so that a single large image does not evict all small iamges
|
||||||
@ -34,7 +33,7 @@ final class CustomImageCache implements ImageCache {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool containsKey(Object key) =>
|
bool containsKey(Object key) =>
|
||||||
(key is OriginalImageProvider ? _large : _small).containsKey(key);
|
(key is ImmichLocalImageProvider ? _large : _small).containsKey(key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get currentSize => _small.currentSize + _large.currentSize;
|
int get currentSize => _small.currentSize + _large.currentSize;
|
||||||
@ -44,7 +43,7 @@ final class CustomImageCache implements ImageCache {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool evict(Object key, {bool includeLive = true}) =>
|
bool evict(Object key, {bool includeLive = true}) =>
|
||||||
(key is OriginalImageProvider ? _large : _small)
|
(key is ImmichLocalImageProvider ? _large : _small)
|
||||||
.evict(key, includeLive: includeLive);
|
.evict(key, includeLive: includeLive);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -60,10 +59,10 @@ final class CustomImageCache implements ImageCache {
|
|||||||
ImageStreamCompleter Function() loader, {
|
ImageStreamCompleter Function() loader, {
|
||||||
ImageErrorListener? onError,
|
ImageErrorListener? onError,
|
||||||
}) =>
|
}) =>
|
||||||
(key is OriginalImageProvider ? _large : _small)
|
(key is ImmichLocalImageProvider ? _large : _small)
|
||||||
.putIfAbsent(key, loader, onError: onError);
|
.putIfAbsent(key, loader, onError: onError);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageCacheStatus statusForKey(Object key) =>
|
ImageCacheStatus statusForKey(Object key) =>
|
||||||
(key is OriginalImageProvider ? _large : _small).statusForKey(key);
|
(key is ImmichLocalImageProvider ? _large : _small).statusForKey(key);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -1,16 +1,16 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:octo_image/octo_image.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:openapi/api.dart' as api;
|
|
||||||
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
|
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
|
||||||
|
|
||||||
/// Renders an Asset using local data if available, else remote data
|
|
||||||
class ImmichImage extends StatelessWidget {
|
class ImmichImage extends StatelessWidget {
|
||||||
const ImmichImage(
|
const ImmichImage(
|
||||||
this.asset, {
|
this.asset, {
|
||||||
@ -18,23 +18,89 @@ class ImmichImage extends StatelessWidget {
|
|||||||
this.height,
|
this.height,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
this.useGrayBoxPlaceholder = false,
|
this.useGrayBoxPlaceholder = false,
|
||||||
this.useProgressIndicator = false,
|
this.isThumbnail = false,
|
||||||
this.type = api.ThumbnailFormat.WEBP,
|
this.thumbnailSize = 250,
|
||||||
this.preferredLocalAssetSize = 250,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Asset? asset;
|
final Asset? asset;
|
||||||
final bool useGrayBoxPlaceholder;
|
final bool useGrayBoxPlaceholder;
|
||||||
final bool useProgressIndicator;
|
|
||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
final api.ThumbnailFormat type;
|
final bool isThumbnail;
|
||||||
final int preferredLocalAssetSize;
|
final int thumbnailSize;
|
||||||
|
|
||||||
|
/// Factory constructor to use the thumbnail variant
|
||||||
|
factory ImmichImage.thumbnail(
|
||||||
|
Asset? asset, {
|
||||||
|
BoxFit fit = BoxFit.cover,
|
||||||
|
double? width,
|
||||||
|
double? height,
|
||||||
|
}) {
|
||||||
|
// Use the width and height to derive thumbnail size
|
||||||
|
final thumbnailSize = max(width ?? 250, height ?? 250).toInt();
|
||||||
|
|
||||||
|
return ImmichImage(
|
||||||
|
asset,
|
||||||
|
isThumbnail: true,
|
||||||
|
fit: fit,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
useGrayBoxPlaceholder: true,
|
||||||
|
thumbnailSize: thumbnailSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to return the image provider for the asset
|
||||||
|
// either by using the asset ID or the asset itself
|
||||||
|
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||||
|
/// image provider
|
||||||
|
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
|
||||||
|
/// The size of the square thumbnail to request. Ignored if isThumbnail
|
||||||
|
/// is not true
|
||||||
|
static ImageProvider imageProvider({
|
||||||
|
Asset? asset,
|
||||||
|
String? assetId,
|
||||||
|
bool isThumbnail = false,
|
||||||
|
int thumbnailSize = 250,
|
||||||
|
}) {
|
||||||
|
if (asset == null && assetId == null) {
|
||||||
|
throw Exception('Must supply either asset or assetId');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset == null) {
|
||||||
|
return ImmichRemoteImageProvider(
|
||||||
|
assetId: assetId!,
|
||||||
|
isThumbnail: isThumbnail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useLocal(asset) && isThumbnail) {
|
||||||
|
return AssetEntityImageProvider(
|
||||||
|
asset.local!,
|
||||||
|
isOriginal: false,
|
||||||
|
thumbnailSize: ThumbnailSize.square(thumbnailSize),
|
||||||
|
);
|
||||||
|
} else if (useLocal(asset) && !isThumbnail) {
|
||||||
|
return ImmichLocalImageProvider(
|
||||||
|
asset: asset,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return ImmichRemoteImageProvider(
|
||||||
|
assetId: asset.remoteId!,
|
||||||
|
isThumbnail: isThumbnail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool useLocal(Asset asset) =>
|
||||||
|
!asset.isRemote ||
|
||||||
|
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (this.asset == null) {
|
|
||||||
|
if (asset == null) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
@ -48,44 +114,38 @@ class ImmichImage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final Asset asset = this.asset!;
|
|
||||||
if (useLocal(asset)) {
|
|
||||||
return Image(
|
|
||||||
image: localImageProvider(asset, size: preferredLocalAssetSize),
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
|
||||||
if (wasSynchronouslyLoaded || frame != null) {
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading if desired
|
return OctoImage(
|
||||||
return Stack(
|
fadeInDuration: const Duration(milliseconds: 0),
|
||||||
children: [
|
fadeOutDuration: const Duration(milliseconds: 400),
|
||||||
if (useGrayBoxPlaceholder)
|
placeholderBuilder: (context) {
|
||||||
const SizedBox.square(
|
if (useGrayBoxPlaceholder) {
|
||||||
dimension: 250,
|
// Use the gray box placeholder
|
||||||
|
return const SizedBox.expand(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(color: Colors.grey),
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (useProgressIndicator)
|
|
||||||
const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
// No placeholder
|
||||||
|
return const SizedBox();
|
||||||
},
|
},
|
||||||
|
image: ImmichImage.imageProvider(
|
||||||
|
asset: asset,
|
||||||
|
isThumbnail: isThumbnail,
|
||||||
|
),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
if (error is PlatformException &&
|
if (error is PlatformException &&
|
||||||
error.code == "The asset not found!") {
|
error.code == "The asset not found!") {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Asset ${asset.localId} does not exist anymore on device!",
|
"Asset ${asset?.localId} does not exist anymore on device!",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Error getting thumb for assetId=${asset.localId}: $error",
|
"Error getting thumb for assetId=${asset?.localId}: $error",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Icon(
|
return Icon(
|
||||||
@ -95,116 +155,4 @@ class ImmichImage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final String? accessToken = Store.get(StoreKey.accessToken);
|
|
||||||
final String thumbnailRequestUrl = getThumbnailUrl(asset, type: type);
|
|
||||||
return CachedNetworkImage(
|
|
||||||
imageUrl: thumbnailRequestUrl,
|
|
||||||
httpHeaders: {"x-immich-user-token": accessToken ?? ""},
|
|
||||||
cacheKey: getThumbnailCacheKey(asset, type: type),
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
|
|
||||||
// maxHeightDiskCache = null allows to simply store the webp thumbnail
|
|
||||||
// from the server and use it for all rendered thumbnail sizes
|
|
||||||
fit: fit,
|
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
|
||||||
progressIndicatorBuilder: (context, url, downloadProgress) {
|
|
||||||
// Show loading if desired
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
if (useGrayBoxPlaceholder)
|
|
||||||
const SizedBox.square(
|
|
||||||
dimension: 250,
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (useProgressIndicator)
|
|
||||||
Transform.scale(
|
|
||||||
scale: 2,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator.adaptive(
|
|
||||||
strokeWidth: 1,
|
|
||||||
value: downloadProgress.progress,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
errorWidget: (context, url, error) {
|
|
||||||
if (error is HttpExceptionWithStatus &&
|
|
||||||
error.statusCode >= 400 &&
|
|
||||||
error.statusCode < 500) {
|
|
||||||
debugPrint("Evicting thumbnail '$url' from cache: $error");
|
|
||||||
CachedNetworkImage.evictFromCache(url);
|
|
||||||
}
|
|
||||||
return Icon(
|
|
||||||
Icons.image_not_supported_outlined,
|
|
||||||
color: context.primaryColor,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static AssetEntityImageProvider localImageProvider(
|
|
||||||
Asset asset, {
|
|
||||||
int size = 250,
|
|
||||||
}) =>
|
|
||||||
AssetEntityImageProvider(
|
|
||||||
asset.local!,
|
|
||||||
isOriginal: false,
|
|
||||||
thumbnailSize: ThumbnailSize.square(size),
|
|
||||||
);
|
|
||||||
|
|
||||||
static CachedNetworkImageProvider remoteThumbnailProvider(
|
|
||||||
Asset asset,
|
|
||||||
api.ThumbnailFormat type,
|
|
||||||
Map<String, String> authHeader,
|
|
||||||
) =>
|
|
||||||
CachedNetworkImageProvider(
|
|
||||||
getThumbnailUrl(asset, type: type),
|
|
||||||
cacheKey: getThumbnailCacheKey(asset, type: type),
|
|
||||||
headers: authHeader,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// TODO: refactor image providers to separate class
|
|
||||||
static CachedNetworkImageProvider remoteThumbnailProviderForId(
|
|
||||||
String assetId, {
|
|
||||||
api.ThumbnailFormat type = api.ThumbnailFormat.WEBP,
|
|
||||||
}) =>
|
|
||||||
CachedNetworkImageProvider(
|
|
||||||
getThumbnailUrlForRemoteId(assetId, type: type),
|
|
||||||
cacheKey: getThumbnailCacheKeyForRemoteId(assetId, type: type),
|
|
||||||
headers: {
|
|
||||||
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Precaches this asset for instant load the next time it is shown
|
|
||||||
static Future<void> precacheAsset(
|
|
||||||
Asset asset,
|
|
||||||
BuildContext context, {
|
|
||||||
type = api.ThumbnailFormat.WEBP,
|
|
||||||
size = 250,
|
|
||||||
}) {
|
|
||||||
if (useLocal(asset)) {
|
|
||||||
// Precache the local image
|
|
||||||
return precacheImage(
|
|
||||||
localImageProvider(asset, size: size),
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final accessToken = Store.get(StoreKey.accessToken);
|
|
||||||
// Precache the remote image since we are not using local images
|
|
||||||
return precacheImage(
|
|
||||||
remoteThumbnailProvider(asset, type, {"x-immich-user-token": accessToken}),
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool useLocal(Asset asset) =>
|
|
||||||
!asset.isRemote ||
|
|
||||||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
|
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,11 @@ String getAlbumThumbNailCacheKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
String getImageUrl(final Asset asset) {
|
String getImageUrl(final Asset asset) {
|
||||||
return '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}?isThumb=false';
|
return getImageUrlFromId(asset.remoteId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getImageUrlFromId(final String id) {
|
||||||
|
return '${Store.get(StoreKey.serverEndpoint)}/asset/file/$id?isThumb=false';
|
||||||
}
|
}
|
||||||
|
|
||||||
String getImageCacheKey(final Asset asset) {
|
String getImageCacheKey(final Asset asset) {
|
||||||
|
@ -960,7 +960,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: octo_image
|
name: octo_image
|
||||||
sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d"
|
sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d"
|
||||||
|
@ -56,6 +56,7 @@ dependencies:
|
|||||||
wakelock_plus: ^1.1.4
|
wakelock_plus: ^1.1.4
|
||||||
flutter_local_notifications: ^16.3.2
|
flutter_local_notifications: ^16.3.2
|
||||||
timezone: ^0.9.2
|
timezone: ^0.9.2
|
||||||
|
octo_image: ^2.0.0
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
path: openapi
|
path: openapi
|
||||||
|
Loading…
Reference in New Issue
Block a user