1
0
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:
martyfuhry 2024-02-13 16:30:32 -05:00 committed by GitHub
parent 4b3f8d1946
commit 9b4a770b9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 540 additions and 364 deletions

View File

@ -180,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.11.3 COCOAPODS: 1.12.1

View File

@ -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,
), ),
), ),

View File

@ -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,

View File

@ -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,
),
], ],
), ),
); );

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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(

View File

@ -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,
), ),
), ),
); );

View File

@ -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,

View File

@ -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,
), ),
), ),
), ),

View File

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

View File

@ -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}';
} }
} }

View File

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

View File

@ -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;
}

View File

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

View File

@ -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) {

View File

@ -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"

View File

@ -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