mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00: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
21 changed files with 540 additions and 364 deletions
|
@ -180,4 +180,4 @@ SPEC CHECKSUMS:
|
|||
|
||||
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/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 {
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -45,7 +45,7 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() => ImmichImage(
|
||||
buildAlbumThumbnail() => ImmichImage.thumbnail(
|
||||
album.thumbnail.value,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
|
|
|
@ -16,7 +16,11 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||
},
|
||||
child: Stack(
|
||||
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),
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: ImmichImage(
|
||||
child: ImmichImage.thumbnail(
|
||||
album.thumbnail.value,
|
||||
width: 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/home/ui/upload_dialog.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/shared/models/store.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/models/asset.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:openapi/api.dart' show ThumbnailFormat;
|
||||
|
||||
|
@ -78,7 +75,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
Offset? localPosition;
|
||||
final header = {"x-immich-user-token": Store.get(StoreKey.accessToken)};
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
final isTrashEnabled =
|
||||
|
@ -135,53 +131,18 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
void toggleFavorite(Asset 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 onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
||||
}
|
||||
if (index < totalAssets && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
for (final imageProvider in allImageProviders(asset)) {
|
||||
precacheImage(imageProvider, context, onError: onError);
|
||||
}
|
||||
precacheImage(
|
||||
ImmichImage.imageProvider(asset: asset),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -765,6 +726,10 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||
},
|
||||
loadingBuilder: (context, event, index) => ImmichImage.thumbnail(
|
||||
asset(),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
|
@ -781,47 +746,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
stackIndex.value = -1;
|
||||
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) {
|
||||
final a =
|
||||
index == currentIndex.value ? asset() : loadAsset(index);
|
||||
final ImageProvider provider = finalImageProvider(a);
|
||||
final ImageProvider provider =
|
||||
ImmichImage.imageProvider(asset: a);
|
||||
|
||||
if (a.isImage && !isPlayingMotionVideo.value) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
|
|
|
@ -136,10 +136,8 @@ class ThumbnailImage extends StatelessWidget {
|
|||
tag: isFromDto
|
||||
? '${asset.remoteId}-$heroOffset'
|
||||
: asset.id + heroOffset,
|
||||
child: ImmichImage(
|
||||
child: ImmichImage.thumbnail(
|
||||
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/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class MemoryCard extends StatelessWidget {
|
||||
final Asset asset;
|
||||
|
@ -84,8 +83,6 @@ class MemoryCard extends StatelessWidget {
|
|||
fit: fit,
|
||||
height: double.infinity,
|
||||
width: double.infinity,
|
||||
type: ThumbnailFormat.JPEG,
|
||||
preferredLocalAssetSize: 2048,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
@ -97,8 +94,6 @@ class MemoryCard extends StatelessWidget {
|
|||
placeholder: ImmichImage(
|
||||
asset,
|
||||
fit: fit,
|
||||
type: ThumbnailFormat.JPEG,
|
||||
preferredLocalAssetSize: 2048,
|
||||
),
|
||||
hideControlsTimer: const Duration(seconds: 2),
|
||||
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/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class MemoryLane extends HookConsumerWidget {
|
||||
const MemoryLane({super.key});
|
||||
|
@ -62,7 +61,6 @@ class MemoryLane extends HookConsumerWidget {
|
|||
width: 130,
|
||||
height: 200,
|
||||
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/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
@RoutePage()
|
||||
class MemoryPage extends HookConsumerWidget {
|
||||
|
@ -113,23 +112,21 @@ class MemoryPage extends HookConsumerWidget {
|
|||
// Gets the thumbnail url and precaches it
|
||||
final precaches = <Future<dynamic>>[];
|
||||
|
||||
precaches.add(
|
||||
ImmichImage.precacheAsset(
|
||||
asset,
|
||||
precaches.addAll([
|
||||
precacheImage(
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
context,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
size: 2048,
|
||||
),
|
||||
);
|
||||
precaches.add(
|
||||
ImmichImage.precacheAsset(
|
||||
asset,
|
||||
precacheImage(
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
isThumbnail: true,
|
||||
),
|
||||
context,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
size: 2048,
|
||||
),
|
||||
);
|
||||
|
||||
]);
|
||||
await Future.wait(precaches);
|
||||
}
|
||||
|
||||
|
|
|
@ -354,6 +354,9 @@ abstract class _$AppRouter extends RootStackRouter {
|
|||
onPlaying: args.onPlaying,
|
||||
onPaused: args.onPaused,
|
||||
placeholder: args.placeholder,
|
||||
showControls: args.showControls,
|
||||
hideControlsTimer: args.hideControlsTimer,
|
||||
showDownloadingIndicator: args.showDownloadingIndicator,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -1384,11 +1387,14 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
|||
VideoViewerRoute({
|
||||
Key? key,
|
||||
required Asset asset,
|
||||
required bool isMotionVideo,
|
||||
required void Function() onVideoEnded,
|
||||
bool isMotionVideo = false,
|
||||
void Function()? onVideoEnded,
|
||||
void Function()? onPlaying,
|
||||
void Function()? onPaused,
|
||||
Widget? placeholder,
|
||||
bool showControls = true,
|
||||
Duration hideControlsTimer = const Duration(seconds: 5),
|
||||
bool showDownloadingIndicator = true,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
VideoViewerRoute.name,
|
||||
|
@ -1400,6 +1406,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
|||
onPlaying: onPlaying,
|
||||
onPaused: onPaused,
|
||||
placeholder: placeholder,
|
||||
showControls: showControls,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
showDownloadingIndicator: showDownloadingIndicator,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
@ -1414,11 +1423,14 @@ class VideoViewerRouteArgs {
|
|||
const VideoViewerRouteArgs({
|
||||
this.key,
|
||||
required this.asset,
|
||||
required this.isMotionVideo,
|
||||
required this.onVideoEnded,
|
||||
this.isMotionVideo = false,
|
||||
this.onVideoEnded,
|
||||
this.onPlaying,
|
||||
this.onPaused,
|
||||
this.placeholder,
|
||||
this.showControls = true,
|
||||
this.hideControlsTimer = const Duration(seconds: 5),
|
||||
this.showDownloadingIndicator = true,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
@ -1427,7 +1439,7 @@ class VideoViewerRouteArgs {
|
|||
|
||||
final bool isMotionVideo;
|
||||
|
||||
final void Function() onVideoEnded;
|
||||
final void Function()? onVideoEnded;
|
||||
|
||||
final void Function()? onPlaying;
|
||||
|
||||
|
@ -1435,8 +1447,14 @@ class VideoViewerRouteArgs {
|
|||
|
||||
final Widget? placeholder;
|
||||
|
||||
final bool showControls;
|
||||
|
||||
final Duration hideControlsTimer;
|
||||
|
||||
final bool showDownloadingIndicator;
|
||||
|
||||
@override
|
||||
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 'original_image_provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart';
|
||||
|
||||
/// [ImageCache] that uses two caches for small and large images
|
||||
/// so that a single large image does not evict all small iamges
|
||||
|
@ -34,7 +33,7 @@ final class CustomImageCache implements ImageCache {
|
|||
|
||||
@override
|
||||
bool containsKey(Object key) =>
|
||||
(key is OriginalImageProvider ? _large : _small).containsKey(key);
|
||||
(key is ImmichLocalImageProvider ? _large : _small).containsKey(key);
|
||||
|
||||
@override
|
||||
int get currentSize => _small.currentSize + _large.currentSize;
|
||||
|
@ -44,7 +43,7 @@ final class CustomImageCache implements ImageCache {
|
|||
|
||||
@override
|
||||
bool evict(Object key, {bool includeLive = true}) =>
|
||||
(key is OriginalImageProvider ? _large : _small)
|
||||
(key is ImmichLocalImageProvider ? _large : _small)
|
||||
.evict(key, includeLive: includeLive);
|
||||
|
||||
@override
|
||||
|
@ -60,10 +59,10 @@ final class CustomImageCache implements ImageCache {
|
|||
ImageStreamCompleter Function() loader, {
|
||||
ImageErrorListener? onError,
|
||||
}) =>
|
||||
(key is OriginalImageProvider ? _large : _small)
|
||||
(key is ImmichLocalImageProvider ? _large : _small)
|
||||
.putIfAbsent(key, loader, onError: onError);
|
||||
|
||||
@override
|
||||
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;
|
||||
}
|
|
@ -437,17 +437,17 @@ class Asset {
|
|||
"remoteId": "${remoteId ?? "N/A"}",
|
||||
"localId": "${localId ?? "N/A"}",
|
||||
"checksum": "$checksum",
|
||||
"ownerId": $ownerId,
|
||||
"ownerId": $ownerId,
|
||||
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
|
||||
"stackCount": "$stackCount",
|
||||
"stackParentId": "${stackParentId ?? "N/A"}",
|
||||
"fileCreatedAt": "$fileCreatedAt",
|
||||
"fileModifiedAt": "$fileModifiedAt",
|
||||
"updatedAt": "$updatedAt",
|
||||
"durationInSeconds": $durationInSeconds,
|
||||
"fileModifiedAt": "$fileModifiedAt",
|
||||
"updatedAt": "$updatedAt",
|
||||
"durationInSeconds": $durationInSeconds,
|
||||
"type": "$type",
|
||||
"fileName": "$fileName",
|
||||
"isFavorite": $isFavorite,
|
||||
"fileName": "$fileName",
|
||||
"isFavorite": $isFavorite,
|
||||
"isRemote": $isRemote,
|
||||
"storage": "$storage",
|
||||
"width": ${width ?? "N/A"},
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.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/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/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:openapi/api.dart' as api;
|
||||
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 {
|
||||
const ImmichImage(
|
||||
this.asset, {
|
||||
|
@ -18,23 +18,89 @@ class ImmichImage extends StatelessWidget {
|
|||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
this.useProgressIndicator = false,
|
||||
this.type = api.ThumbnailFormat.WEBP,
|
||||
this.preferredLocalAssetSize = 250,
|
||||
this.isThumbnail = false,
|
||||
this.thumbnailSize = 250,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Asset? asset;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
final bool useProgressIndicator;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final api.ThumbnailFormat type;
|
||||
final int preferredLocalAssetSize;
|
||||
final bool isThumbnail;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
if (this.asset == null) {
|
||||
|
||||
if (asset == null) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.grey,
|
||||
|
@ -48,96 +114,39 @@ 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 Stack(
|
||||
children: [
|
||||
if (useGrayBoxPlaceholder)
|
||||
const SizedBox.square(
|
||||
dimension: 250,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (useProgressIndicator)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 400),
|
||||
placeholderBuilder: (context) {
|
||||
if (useGrayBoxPlaceholder) {
|
||||
// Use the gray box placeholder
|
||||
return const SizedBox.expand(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (error is PlatformException &&
|
||||
error.code == "The asset not found!") {
|
||||
debugPrint(
|
||||
"Asset ${asset.localId} does not exist anymore on device!",
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
"Error getting thumb for assetId=${asset.localId}: $error",
|
||||
);
|
||||
}
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: context.primaryColor,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
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),
|
||||
}
|
||||
// No placeholder
|
||||
return const SizedBox();
|
||||
},
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
isThumbnail: isThumbnail,
|
||||
),
|
||||
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);
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (error is PlatformException &&
|
||||
error.code == "The asset not found!") {
|
||||
debugPrint(
|
||||
"Asset ${asset?.localId} does not exist anymore on device!",
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
"Error getting thumb for assetId=${asset?.localId}: $error",
|
||||
);
|
||||
}
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
|
@ -146,65 +155,4 @@ class ImmichImage extends StatelessWidget {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
|
|
|
@ -960,7 +960,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.5.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: octo_image
|
||||
sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d"
|
||||
|
|
|
@ -56,6 +56,7 @@ dependencies:
|
|||
wakelock_plus: ^1.1.4
|
||||
flutter_local_notifications: ^16.3.2
|
||||
timezone: ^0.9.2
|
||||
octo_image: ^2.0.0
|
||||
|
||||
openapi:
|
||||
path: openapi
|
||||
|
|
Loading…
Reference in a new issue