feat: full local assets / album sync

This commit is contained in:
shenlong-tanwen
2024-10-17 23:33:00 +05:30
parent a09710ec7b
commit c91a2878dc
87 changed files with 2417 additions and 366 deletions
@@ -0,0 +1,40 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/utils/constants/globals.dart';
/// The cache manager for thumbnail images [ImRemoteThumbnailProvider]
class ImRemoteThumbnailCacheManager extends CacheManager {
static final ImRemoteThumbnailCacheManager _instance =
ImRemoteThumbnailCacheManager._();
factory ImRemoteThumbnailCacheManager() {
return _instance;
}
ImRemoteThumbnailCacheManager._()
: super(
Config(
kCacheThumbnailsKey,
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
stalePeriod: const Duration(days: kCacheStalePeriod),
),
);
}
/// The cache manager for full size images [ImRemoteImageProvider]
class ImRemoteImageCacheManager extends CacheManager {
static final ImRemoteImageCacheManager _instance =
ImRemoteImageCacheManager._();
factory ImRemoteImageCacheManager() {
return _instance;
}
ImRemoteImageCacheManager._()
: super(
Config(
kCacheFullImagesKey,
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
stalePeriod: const Duration(days: kCacheStalePeriod),
),
);
}
@@ -0,0 +1,55 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/immich_api_client.dart';
/// An exception for the [ImageLoader] and the Immich image providers
class ImageLoadingException implements Exception {
final String message;
const ImageLoadingException(this.message);
@override
String toString() => 'ImageLoadingException: $message';
}
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
///
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
/// for this wonderful implementation of their image loader
class ImageLoader {
static Future<ui.Codec> loadImageFromCache(
String uri, {
required CacheManager cache,
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? chunkEvents,
}) async {
final stream = cache.getFileStream(
uri,
withProgress: chunkEvents != null,
headers: di<ImApiClient>().headers,
);
await for (final result in stream) {
if (result is DownloadProgress) {
// We are downloading the file, so update the [chunkEvents]
chunkEvents?.add(
ImageChunkEvent(
cumulativeBytesLoaded: result.downloaded,
expectedTotalBytes: result.totalSize,
),
);
} else if (result is FileInfo) {
// We have the file
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
final decoded = await decode(buffer);
return decoded;
}
}
// If we get here, the image failed to load from the cache stream
throw const ImageLoadingException('Could not load image from stream');
}
}
@@ -1,48 +1,89 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/immich_api_client.dart';
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:immich_mobile/presentation/components/image/provider/immich_local_image_provider.dart';
import 'package:immich_mobile/presentation/components/image/provider/immich_remote_image_provider.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
import 'package:octo_image/octo_image.dart';
class ImImagePlaceholder extends StatelessWidget {
const ImImagePlaceholder({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 200,
height: 200,
color: context.colorScheme.surfaceContainerHighest,
);
}
}
// ignore: prefer-single-widget-per-file
class ImImage extends StatelessWidget {
final Asset asset;
final double? width;
final double? height;
final Widget placeholder;
const ImImage(this.asset, {this.width, this.height, super.key});
const ImImage(
this.asset, {
this.width,
this.height,
this.placeholder = const ImImagePlaceholder(),
super.key,
});
// 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}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return ImRemoteImageProvider(assetId: assetId!);
}
// Whether to use the local asset image provider or a remote one
final useLocal = !asset.isRemote || asset.isLocal;
if (useLocal) {
return ImLocalImageProvider(asset: asset);
}
return ImRemoteImageProvider(assetId: asset.remoteId!);
}
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: ImImageUrlHelper.getThumbnailUrl(asset),
httpHeaders: di<ImmichApiClient>().headers,
cacheKey: ImImageUrlHelper.getThumbnailUrl(asset),
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 200),
placeholderBuilder: (_) => placeholder,
image: ImImage.imageProvider(asset: asset),
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: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (_, url, downloadProgress) {
// Show loading if desired
return const SizedBox.square(
dimension: 250,
child: DecoratedBox(decoration: BoxDecoration(color: Colors.grey)),
);
},
errorWidget: (_, url, error) {
if (error is HttpExceptionWithStatus &&
error.statusCode >= 400 &&
error.statusCode < 500) {
CachedNetworkImage.evictFromCache(url);
errorBuilder: (_, error, stackTrace) {
if (error is PlatformException &&
error.code == "The asset not found!") {
debugPrint(
"Asset ${asset.localId ?? asset.id ?? "-"} does not exist anymore on device!",
);
} else {
debugPrint(
"Error getting thumb for assetId=${asset.localId ?? asset.id ?? "-"}: $error",
);
}
return Icon(
Symbols.image_not_supported_rounded,
color: Theme.of(context).primaryColor,
Icons.image_not_supported_outlined,
color: context.colorScheme.primary,
);
},
);
@@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget {
}
}
// ignore: prefer-single-widget-per-file
class ImLogoText extends StatelessWidget {
const ImLogoText({
super.key,
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
import 'package:material_symbols_icons/symbols.dart';
IconData _getStorageIcon(Asset asset) {
if (asset.isMerged) {
return Symbols.cloud_done_rounded;
} else if (asset.isRemote) {
return Symbols.cloud_rounded;
}
return Symbols.cloud_off_rounded;
}
class ImThumbnail extends StatelessWidget {
final Asset asset;
final double? width;
final double? height;
const ImThumbnail(this.asset, {this.width, this.height, super.key});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(child: ImImage(asset, width: width, height: height)),
_PadAlignedIcon(
alignment: Alignment.bottomRight,
icon: _getStorageIcon(asset),
),
if (!asset.isImage)
const _PadAlignedIcon(
alignment: Alignment.topLeft,
icon: Symbols.play_circle_rounded,
filled: true,
),
],
);
}
}
class _PadAlignedIcon extends StatelessWidget {
final Alignment alignment;
final IconData icon;
final bool? filled;
const _PadAlignedIcon({
required this.alignment,
required this.icon,
this.filled,
});
double _calculateLeft(Alignment align) {
return align.x == -1 ? 5 : 0;
}
double _calculateTop(Alignment align) {
return align.y == -1 ? 4 : 0;
}
double _calculateRight(Alignment align) {
return align.x == 1 ? 5 : 0;
}
double _calculateBottom(Alignment align) {
return align.y == 1 ? 4 : 0;
}
@override
Widget build(BuildContext context) {
return Positioned(
left: _calculateLeft(alignment),
top: _calculateTop(alignment),
right: _calculateRight(alignment),
bottom: _calculateBottom(alignment),
child: Align(
alignment: alignment,
child: Icon(
icon,
color: Colors.white,
size: 20,
fill: (filled != null && filled!) ? 1 : null,
),
),
);
}
}
@@ -0,0 +1,109 @@
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/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/extensions/file.extension.dart';
/// The local image provider for an asset
class ImLocalImageProvider extends ImageProvider<ImLocalImageProvider> {
final Asset asset;
ImLocalImageProvider({required this.asset})
: assert(asset.localId != 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<ImLocalImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
ImLocalImageProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key.asset, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(asset.name);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset a,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a small thumbnail
final thumbBytes =
await di<IDeviceAssetRepository>().getThumbnail(a.localId!);
if (thumbBytes != null) {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer);
yield codec;
} else {
debugPrint("Loading thumb for ${a.name} failed");
}
if (asset.isImage) {
/// Using 2K thumbnail for local iOS image to avoid double swiping issue
if (Platform.isIOS) {
final largeImageBytes = await di<IDeviceAssetRepository>()
.getThumbnail(a.localId!, width: 3840, height: 2160);
if (largeImageBytes == null) {
throw StateError("Loading thumb for local photo ${a.name} 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 di<IDeviceAssetRepository>().getOriginalFile(a.localId!);
if (file == null) {
throw StateError("Opening file for asset ${a.name} failed");
}
try {
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
final codec = await decode(buffer);
yield codec;
} catch (error, stack) {
Error.throwWithStackTrace(
StateError("Loading asset ${a.name} failed"),
stack,
);
} finally {
await file.deleteDarwinCache();
}
}
}
await chunkEvents.close();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImLocalImageProvider) {
return asset == other.asset;
}
return false;
}
@override
int get hashCode => asset.hashCode;
}
@@ -0,0 +1,88 @@
import 'dart:async';
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/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/asset.model.dart';
import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/constants/globals.dart';
/// The local image provider for an asset
/// Only viable
class ImLocalThumbnailProvider extends ImageProvider<ImLocalThumbnailProvider> {
final Asset asset;
final int height;
final int width;
ImLocalThumbnailProvider({
required this.asset,
this.height = kGridThumbnailSize,
this.width = kGridThumbnailSize,
}) : assert(asset.localId != 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<ImLocalThumbnailProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
ImLocalThumbnailProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key.asset, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(asset.name);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset a,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a small thumbnail
final thumbBytes = await di<IDeviceAssetRepository>()
.getThumbnail(a.localId!, width: 32, height: 32, quality: 75);
if (thumbBytes != null) {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer);
yield codec;
} else {
debugPrint("Loading thumb for ${a.name} failed");
}
final normalThumbBytes = await di<IDeviceAssetRepository>()
.getThumbnail(a.localId!, width: width, height: height);
if (normalThumbBytes == null) {
throw StateError("Loading thumb for local photo ${a.name} failed");
}
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
final codec = await decode(buffer);
yield codec;
await chunkEvents.close();
}
@override
bool operator ==(Object other) {
if (other is! ImLocalThumbnailProvider) return false;
if (identical(this, other)) return true;
return asset == other.asset;
}
@override
int get hashCode => asset.hashCode;
}
@@ -0,0 +1,88 @@
import 'dart:async';
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:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart';
import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart';
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
/// The remote image provider for full size remote images
class ImRemoteImageProvider extends ImageProvider<ImRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
/// The image cache manager
final CacheManager? cacheManager;
const ImRemoteImageProvider({required this.assetId, this.cacheManager});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImRemoteImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
ImRemoteImageProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ImRemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
ImRemoteImageProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a preview to the chunk events
final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId);
yield await ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
// Load the higher resolution version of the image
final url = ImImageUrlHelper.getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
);
final codec = await ImageLoader.loadImageFromCache(
url,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
yield codec;
await chunkEvents.close();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImRemoteImageProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}
@@ -0,0 +1,80 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart';
import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart';
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
/// The remote image provider
class ImmichRemoteThumbnailProvider
extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
final int? height;
final int? width;
/// The image cache manager
final CacheManager? cacheManager;
const ImmichRemoteThumbnailProvider({
required this.assetId,
this.height,
this.width,
this.cacheManager,
});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichRemoteThumbnailProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(
ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode,
) {
final cache = cacheManager ?? ImRemoteThumbnailCacheManager();
return MultiImageStreamCompleter(
codec: _codec(key, cache, decode),
scale: 1.0,
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
ImmichRemoteThumbnailProvider key,
CacheManager cache,
ImageDecoderCallback decode,
) async* {
final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId);
yield await ImageLoader.loadImageFromCache(
preview,
cache: cache,
decode: decode,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichRemoteThumbnailProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}