diff --git a/Makefile b/Makefile index 767b9e2fee..8cc41062bb 100644 --- a/Makefile +++ b/Makefile @@ -8,4 +8,7 @@ dev-scale: docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich_server=3 --remove-orphans prod: - docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans \ No newline at end of file + docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans + +prod-scale: + docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --scale immich_microservices=3 --remove-orphans \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6a0016f59c..5bc9de64a6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -22,6 +22,7 @@ services: - database networks: - immich_network + restart: unless-stopped immich_microservices: image: immich-microservices:1.4.0 @@ -43,7 +44,7 @@ services: - database networks: - immich_network - + restart: unless-stopped redis: container_name: immich_redis diff --git a/microservices/src/image-classifier/image-classifier.service.ts b/microservices/src/image-classifier/image-classifier.service.ts index d65abb8a61..5c61918b3f 100644 --- a/microservices/src/image-classifier/image-classifier.service.ts +++ b/microservices/src/image-classifier/image-classifier.service.ts @@ -39,6 +39,7 @@ export class ImageClassifierService { } } + tf.dispose(decodedImage); return tags; } } catch (e) { diff --git a/microservices/src/object-detection/object-detection.service.ts b/microservices/src/object-detection/object-detection.service.ts index 532133f3ca..b93b68beb4 100644 --- a/microservices/src/object-detection/object-detection.service.ts +++ b/microservices/src/object-detection/object-detection.service.ts @@ -29,6 +29,7 @@ export class ObjectDetectionService { } } + tf.dispose(decodedImage); return [...tags]; } } catch (e) { diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index f2faf08058..2f7ca021ff 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -51,7 +51,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "app.alextran.immich" - minSdkVersion 20 + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index f9fd6c9379..a0be4dff2c 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -20,4 +20,7 @@ + + + \ No newline at end of file diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6c2db2cba5..fa4fd0b51d 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -13,7 +13,7 @@ PODS: - Flutter - path_provider_ios (0.0.1): - Flutter - - photo_manager (1.0.0): + - photo_manager (2.0.0): - Flutter - FlutterMacOS - SAMKeychain (1.5.3) @@ -70,7 +70,7 @@ SPEC CHECKSUMS: FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 - photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463 + photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index ebd0a876e5..47b9d604ff 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -1,66 +1,72 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Immich - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - immich_mobile - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - 2 - LSRequiresIPhoneOS - - MGLMapboxMetricsEnabledSettingShownInApp - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSLocationAlwaysUsageDescription - Enable location setting to show position of assets on map - NSLocationWhenInUseUsageDescription - Enable location setting to show position of assets on map - NSPhotoLibraryUsageDescription - We need to manage backup your photos album - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Light - UIViewControllerBasedStatusBarAppearance - - io.flutter.embedded_views_preview - - ITSAppUsesNonExemptEncryption - - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Immich + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + immich_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + 2 + LSRequiresIPhoneOS + + MGLMapboxMetricsEnabledSettingShownInApp + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSLocationAlwaysUsageDescription + Enable location setting to show position of assets on map + + NSLocationWhenInUseUsageDescription + Enable location setting to show position of assets on map + + NSPhotoLibraryUsageDescription + We need to manage backup your photos album + + NSPhotoLibraryAddUsageDescription + We need to manage backup your photos album + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + + ITSAppUsesNonExemptEncryption + + + \ No newline at end of file diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 194cd42a96..cb847f8e61 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -97,6 +97,7 @@ class _ImmichAppState extends ConsumerState with WidgetsBindingObserv textTheme: GoogleFonts.workSansTextTheme( Theme.of(context).textTheme.apply(fontSizeFactor: 1.0), ), + snackBarTheme: SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: GoogleFonts.workSans().fontFamily)), scaffoldBackgroundColor: const Color(0xFFf6f8fe), appBarTheme: const AppBarTheme( backgroundColor: Colors.white, diff --git a/mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart b/mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart index 63280ff69d..8590ce6890 100644 --- a/mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart +++ b/mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart @@ -1,28 +1,34 @@ import 'dart:convert'; +enum DownloadAssetStatus { idle, loading, success, error } + class ImageViewerPageState { - final bool isBottomSheetEnable; + // enum + final DownloadAssetStatus downloadAssetStatus; + ImageViewerPageState({ - required this.isBottomSheetEnable, + required this.downloadAssetStatus, }); ImageViewerPageState copyWith({ - bool? isBottomSheetEnable, + DownloadAssetStatus? downloadAssetStatus, }) { return ImageViewerPageState( - isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable, + downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, ); } Map toMap() { - return { - 'isBottomSheetEnable': isBottomSheetEnable, - }; + final result = {}; + + result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); + + return result; } factory ImageViewerPageState.fromMap(Map map) { return ImageViewerPageState( - isBottomSheetEnable: map['isBottomSheetEnable'] ?? false, + downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], ); } @@ -31,15 +37,15 @@ class ImageViewerPageState { factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source)); @override - String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)'; + String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable; + return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus; } @override - int get hashCode => isBottomSheetEnable.hashCode; + int get hashCode => downloadAssetStatus.hashCode; } diff --git a/mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart b/mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart new file mode 100644 index 0000000000..80a99bfe9c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart @@ -0,0 +1,6 @@ +class RequestDownloadAssetInfo { + final String assetId; + final String deviceId; + + RequestDownloadAssetInfo(this.assetId, this.deviceId); +} diff --git a/mobile/lib/modules/asset_viewer/models/store_model_here.txt b/mobile/lib/modules/asset_viewer/models/store_model_here.txt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart index c7854a5316..1ba0029388 100644 --- a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart @@ -1,21 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; -import 'package:immich_mobile/modules/home/models/home_page_state.model.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; -class ImageViewerPageStateNotifier extends StateNotifier { - ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false)); +class ImageViewerStateNotifier extends StateNotifier { + final ImageViewerService _imageViewerService = ImageViewerService(); - void toggleBottomSheet() { - bool isBottomSheetEnable = state.isBottomSheetEnable; + ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle)); - if (isBottomSheetEnable) { - state.copyWith(isBottomSheetEnable: false); + void downloadAsset(ImmichAsset asset, BuildContext context) async { + state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); + + bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); + + if (isSuccess) { + state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); + + ImmichToast.show( + context: context, + msg: "Download Success", + toastType: ToastType.success, + gravity: ToastGravity.BOTTOM, + ); } else { - state.copyWith(isBottomSheetEnable: true); + state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); + ImmichToast.show( + context: context, + msg: "Download Error", + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); } + + state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); } } -final homePageStateProvider = StateNotifierProvider( - ((ref) => ImageViewerPageStateNotifier())); +final imageViewerStateProvider = + StateNotifierProvider(((ref) => ImageViewerStateNotifier())); diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart new file mode 100644 index 0000000000..08e766f4a9 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; +import 'package:path/path.dart' as p; +import 'package:http/http.dart' as http; + +import 'package:photo_manager/photo_manager.dart'; +import 'package:path_provider/path_provider.dart'; + +class ImageViewerService { + Future downloadAssetToDevice(ImmichAsset asset) async { + try { + String fileName = p.basename(asset.originalPath); + var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); + Uri filePath = + Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false"); + + var res = await http.get( + filePath, + headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"}, + ); + + final AssetEntity? entity; + + if (asset.type == 'IMAGE') { + entity = await PhotoManager.editor.saveImage( + res.bodyBytes, + title: p.basename(asset.originalPath), + ); + } else { + final tempDir = await getTemporaryDirectory(); + File tempFile = await File('${tempDir.path}/$fileName').create(); + tempFile.writeAsBytesSync(res.bodyBytes); + entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName); + } + + if (entity != null) { + return true; + } + } catch (e) { + debugPrint("Error saving file $e"); + return false; + } + + return false; + } +} diff --git a/mobile/lib/modules/asset_viewer/services/store_services_here.txt b/mobile/lib/modules/asset_viewer/services/store_services_here.txt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/mobile/lib/modules/asset_viewer/ui/download_loading_indicator.dart b/mobile/lib/modules/asset_viewer/ui/download_loading_indicator.dart new file mode 100644 index 0000000000..f53d9692f4 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/download_loading_indicator.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; + +class DownloadLoadingIndicator extends StatelessWidget { + const DownloadLoadingIndicator({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 60, + width: 60, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(10), + ), + child: const SpinKitDancingSquare( + color: Colors.white, + size: 30.0, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index c8b570c5ad..75a08c25b8 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -1,14 +1,19 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart'; -class TopControlAppBar extends StatelessWidget with PreferredSizeWidget { - const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key); +class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { + const TopControlAppBar( + {Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed}) + : super(key: key); final ImmichAsset asset; final Function onMoreInfoPressed; + final Function onDownloadPressed; + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { double iconSize = 18.0; return AppBar( @@ -29,7 +34,7 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget { iconSize: iconSize, splashRadius: iconSize, onPressed: () { - print("download"); + onDownloadPressed(); }, icon: const Icon(Icons.cloud_download_rounded), ), diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart index 4f3585e3c0..856e4c3764 100644 --- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart @@ -4,6 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart'; @@ -25,6 +28,7 @@ class ImageViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus; var box = Hive.box(userInfoBox); getAssetExif() async { @@ -42,65 +46,77 @@ class ImageViewerPage extends HookConsumerWidget { asset: asset, onMoreInfoPressed: () { showModalBottomSheet( - backgroundColor: Colors.black, - barrierColor: Colors.transparent, - isScrollControlled: false, - context: context, - builder: (context) { - return ExifBottomSheet(assetDetail: assetDetail!); - }); + backgroundColor: Colors.black, + barrierColor: Colors.transparent, + isScrollControlled: false, + context: context, + builder: (context) { + return ExifBottomSheet(assetDetail: assetDetail!); + }, + ); + }, + onDownloadPressed: () { + ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); }, ), body: SafeArea( - child: Center( - child: Hero( - tag: heroTag, - child: CachedNetworkImage( - fit: BoxFit.cover, - imageUrl: imageUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - errorWidget: (context, url, error) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Wrap( - spacing: 32, - runSpacing: 32, - alignment: WrapAlignment.center, - children: [ - const Text( - "Failed To Render Image - Possibly Corrupted Data", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, color: Colors.white), + child: Stack( + children: [ + Center( + child: Hero( + tag: heroTag, + child: CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: imageUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + fadeInDuration: const Duration(milliseconds: 250), + errorWidget: (context, url, error) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Wrap( + spacing: 32, + runSpacing: 32, + alignment: WrapAlignment.center, + children: [ + const Text( + "Failed To Render Image - Possibly Corrupted Data", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.white), + ), + SingleChildScrollView( + child: Text( + error.toString(), + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ), + ], ), - SingleChildScrollView( - child: Text( - error.toString(), - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + placeholder: (context, url) { + return CachedNetworkImage( + cacheKey: thumbnailUrl, + fit: BoxFit.cover, + imageUrl: thumbnailUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + placeholderFadeInDuration: const Duration(milliseconds: 0), + progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( + scale: 0.2, + child: CircularProgressIndicator(value: downloadProgress.progress), ), - ), - ], + errorWidget: (context, url, error) => Icon( + Icons.error, + color: Colors.grey[300], + ), + ); + }, ), ), - placeholder: (context, url) { - return CachedNetworkImage( - cacheKey: thumbnailUrl, - fit: BoxFit.cover, - imageUrl: thumbnailUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - placeholderFadeInDuration: const Duration(milliseconds: 0), - progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale( - scale: 0.2, - child: CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) => Icon( - Icons.error, - color: Colors.grey[300], - ), - ); - }, ), - ), + if (downloadAssetStatus == DownloadAssetStatus.loading) + const Center( + child: DownloadLoadingIndicator(), + ), + ], ), ), ); diff --git a/mobile/lib/shared/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart similarity index 50% rename from mobile/lib/shared/views/video_viewer_page.dart rename to mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index c80393d233..891128313f 100644 --- a/mobile/lib/shared/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -1,35 +1,74 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive/hive.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:chewie/chewie.dart'; +import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; +import 'package:immich_mobile/modules/home/services/asset.service.dart'; +import 'package:immich_mobile/shared/models/immich_asset.model.dart'; +import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart'; import 'package:video_player/video_player.dart'; -class VideoViewerPage extends StatelessWidget { +// ignore: must_be_immutable +class VideoViewerPage extends HookConsumerWidget { final String videoUrl; + final ImmichAsset asset; + ImmichAssetWithExif? assetDetail; + final AssetService _assetService = AssetService(); - const VideoViewerPage({Key? key, required this.videoUrl}) : super(key: key); + VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus; + String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); + getAssetExif() async { + assetDetail = await _assetService.getAssetById(asset.id); + } + + useEffect(() { + getAssetExif(); + return null; + }, []); + return Scaffold( backgroundColor: Colors.black, - appBar: AppBar( - systemOverlayStyle: SystemUiOverlayStyle.light, - backgroundColor: Colors.black, - leading: IconButton( - onPressed: () { - AutoRouter.of(context).pop(); + appBar: TopControlAppBar( + asset: asset, + onMoreInfoPressed: () { + showModalBottomSheet( + backgroundColor: Colors.black, + barrierColor: Colors.transparent, + isScrollControlled: false, + context: context, + builder: (context) { + return ExifBottomSheet(assetDetail: assetDetail!); }, - icon: const Icon(Icons.arrow_back_ios)), + ); + }, + onDownloadPressed: () { + ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context); + }, ), body: SafeArea( - child: VideoThumbnailPlayer( - url: videoUrl, - jwtToken: jwtToken, + child: Stack( + children: [ + VideoThumbnailPlayer( + url: videoUrl, + jwtToken: jwtToken, + ), + if (downloadAssetStatus == DownloadAssetStatus.loading) + const Center( + child: DownloadLoadingIndicator(), + ), + ], ), ), ); diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart index d0dcf13cf3..023e12236d 100644 --- a/mobile/lib/modules/home/ui/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/thumbnail_image.dart @@ -65,8 +65,8 @@ class ThumbnailImage extends HookConsumerWidget { } else { AutoRouter.of(context).push( VideoViewerRoute( - videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', - ), + videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}', + asset: asset), ); } } diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 76e6f5bcb7..28e086f221 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -128,9 +128,10 @@ class LoginButton extends ConsumerWidget { AutoRouter.of(context).pushNamed("/tab-controller-page"); } else { ImmichToast.show( - context: context, - msg: "Error logging you in, check server url, email and password!", - toastType: ToastType.error); + context: context, + msg: "Error logging you in, check server url, email and password!", + toastType: ToastType.error, + ); } }, child: const Text("Login")); diff --git a/mobile/lib/modules/search/ui/thumbnail_with_info.dart b/mobile/lib/modules/search/ui/thumbnail_with_info.dart new file mode 100644 index 0000000000..fe6912526b --- /dev/null +++ b/mobile/lib/modules/search/ui/thumbnail_with_info.dart @@ -0,0 +1,67 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/utils/capitalize_first_letter.dart'; + +class ThumbnailWithInfo extends StatelessWidget { + const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap}) + : super(key: key); + + final String textInfo; + final String imageUrl; + final Function onTap; + + @override + Widget build(BuildContext context) { + var box = Hive.box(userInfoBox); + + return GestureDetector( + onTap: () { + onTap(); + }, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: SizedBox( + width: MediaQuery.of(context).size.width / 2, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Container( + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.black26, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + width: 250, + height: 250, + fit: BoxFit.cover, + imageUrl: imageUrl, + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + ), + ), + ), + Positioned( + bottom: 8, + left: 10, + child: SizedBox( + width: MediaQuery.of(context).size.width / 3, + child: Text( + textInfo.capitalizeFirstLetter(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index ba8bb6259b..9e8b3cd78e 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -10,6 +9,7 @@ import 'package:immich_mobile/modules/search/models/curated_object.model.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/ui/search_bar.dart'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; +import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/capitalize_first_letter.dart'; @@ -40,12 +40,12 @@ class SearchPage extends HookConsumerWidget { _buildPlaces() { return curatedLocation.when( - loading: () => const CircularProgressIndicator(), + loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()), error: (err, stack) => Text('Error: $err'), data: (curatedLocations) { return curatedLocations.isNotEmpty ? SizedBox( - height: MediaQuery.of(context).size.width / 3, + height: MediaQuery.of(context).size.width / 2, child: ListView.builder( padding: const EdgeInsets.only(left: 16), scrollDirection: Axis.horizontal, @@ -66,7 +66,7 @@ class SearchPage extends HookConsumerWidget { ), ) : SizedBox( - height: MediaQuery.of(context).size.width / 3, + height: MediaQuery.of(context).size.width / 2, child: ListView.builder( padding: const EdgeInsets.only(left: 16), scrollDirection: Axis.horizontal, @@ -87,12 +87,12 @@ class SearchPage extends HookConsumerWidget { _buildThings() { return curatedObjects.when( - loading: () => const CircularProgressIndicator(), + loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()), error: (err, stack) => Text('Error: $err'), data: (objects) { return objects.isNotEmpty ? SizedBox( - height: MediaQuery.of(context).size.width / 3, + height: MediaQuery.of(context).size.width / 2, child: ListView.builder( padding: const EdgeInsets.only(left: 16), scrollDirection: Axis.horizontal, @@ -114,7 +114,7 @@ class SearchPage extends HookConsumerWidget { ), ) : SizedBox( - height: MediaQuery.of(context).size.width / 3, + height: MediaQuery.of(context).size.width / 2, child: ListView.builder( padding: const EdgeInsets.only(left: 16), scrollDirection: Axis.horizontal, @@ -172,66 +172,3 @@ class SearchPage extends HookConsumerWidget { ); } } - -class ThumbnailWithInfo extends StatelessWidget { - const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap}) - : super(key: key); - - final String textInfo; - final String imageUrl; - final Function onTap; - - @override - Widget build(BuildContext context) { - var box = Hive.box(userInfoBox); - - return GestureDetector( - onTap: () { - onTap(); - }, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: SizedBox( - width: MediaQuery.of(context).size.width / 3, - height: MediaQuery.of(context).size.width / 3, - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - Container( - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.black26, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: CachedNetworkImage( - width: 150, - height: 150, - fit: BoxFit.cover, - imageUrl: imageUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - ), - ), - ), - Positioned( - bottom: 8, - left: 10, - child: SizedBox( - width: MediaQuery.of(context).size.width / 3, - child: Text( - textInfo.capitalizeFirstLetter(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart index 6f4841861d..456db879ef 100644 --- a/mobile/lib/modules/search/views/search_result_page.dart +++ b/mobile/lib/modules/search/views/search_result_page.dart @@ -1,6 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/daily_title_text.dart'; import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; @@ -107,7 +108,10 @@ class SearchResultPage extends HookConsumerWidget { } if (searchResultPageState.isLoading) { - return const CircularProgressIndicator.adaptive(); + return Center( + child: SpinKitDancingSquare( + color: Theme.of(context).primaryColor, + )); } if (searchResultPageState.isSuccess) { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 92dc4ae8e3..ee38bff71b 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart'; -import 'package:immich_mobile/shared/views/video_viewer_page.dart'; +import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; part 'router.gr.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 040cd58299..06c0c599b0 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -44,7 +44,8 @@ class _$AppRouter extends RootStackRouter { final args = routeData.argsAs(); return MaterialPageX( routeData: routeData, - child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl)); + child: VideoViewerPage( + key: args.key, videoUrl: args.videoUrl, asset: args.asset)); }, BackupControllerRoute.name: (routeData) { return MaterialPageX( @@ -163,24 +164,29 @@ class ImageViewerRouteArgs { /// generated route for /// [VideoViewerPage] class VideoViewerRoute extends PageRouteInfo { - VideoViewerRoute({Key? key, required String videoUrl}) + VideoViewerRoute( + {Key? key, required String videoUrl, required ImmichAsset asset}) : super(VideoViewerRoute.name, path: '/video-viewer-page', - args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl)); + args: VideoViewerRouteArgs( + key: key, videoUrl: videoUrl, asset: asset)); static const String name = 'VideoViewerRoute'; } class VideoViewerRouteArgs { - const VideoViewerRouteArgs({this.key, required this.videoUrl}); + const VideoViewerRouteArgs( + {this.key, required this.videoUrl, required this.asset}); final Key? key; final String videoUrl; + final ImmichAsset asset; + @override String toString() { - return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}'; + return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}'; } } diff --git a/mobile/lib/shared/models/immich_asset.model.dart b/mobile/lib/shared/models/immich_asset.model.dart index 0073dfccc7..5097a36829 100644 --- a/mobile/lib/shared/models/immich_asset.model.dart +++ b/mobile/lib/shared/models/immich_asset.model.dart @@ -10,6 +10,8 @@ class ImmichAsset { final String modifiedAt; final bool isFavorite; final String? duration; + final String originalPath; + final String resizePath; ImmichAsset({ required this.id, @@ -21,6 +23,8 @@ class ImmichAsset { required this.modifiedAt, required this.isFavorite, this.duration, + required this.originalPath, + required this.resizePath, }); ImmichAsset copyWith({ @@ -33,6 +37,8 @@ class ImmichAsset { String? modifiedAt, bool? isFavorite, String? duration, + String? originalPath, + String? resizePath, }) { return ImmichAsset( id: id ?? this.id, @@ -44,6 +50,8 @@ class ImmichAsset { modifiedAt: modifiedAt ?? this.modifiedAt, isFavorite: isFavorite ?? this.isFavorite, duration: duration ?? this.duration, + originalPath: originalPath ?? this.originalPath, + resizePath: resizePath ?? this.resizePath, ); } @@ -58,6 +66,8 @@ class ImmichAsset { 'modifiedAt': modifiedAt, 'isFavorite': isFavorite, 'duration': duration, + 'originalPath': originalPath, + 'resizePath': resizePath, }; } @@ -72,6 +82,8 @@ class ImmichAsset { modifiedAt: map['modifiedAt'] ?? '', isFavorite: map['isFavorite'] ?? false, duration: map['duration'], + originalPath: map['originalPath'] ?? '', + resizePath: map['resizePath'] ?? '', ); } @@ -81,7 +93,7 @@ class ImmichAsset { @override String toString() { - return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration)'; + return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration, originalPath: $originalPath, resizePath: $resizePath)'; } @override @@ -97,7 +109,9 @@ class ImmichAsset { other.createdAt == createdAt && other.modifiedAt == modifiedAt && other.isFavorite == isFavorite && - other.duration == duration; + other.duration == duration && + other.originalPath == originalPath && + other.resizePath == resizePath; } @override @@ -110,6 +124,8 @@ class ImmichAsset { createdAt.hashCode ^ modifiedAt.hashCode ^ isFavorite.hashCode ^ - duration.hashCode; + duration.hashCode ^ + originalPath.hashCode ^ + resizePath.hashCode; } } diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/shared/services/backup.service.dart index c2e0a3552e..d4af18959d 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/shared/services/backup.service.dart @@ -73,7 +73,7 @@ class BackupService { }); // Build thumbnail multipart data - var thumbnailData = await entity.thumbDataWithSize(1280, 720); + var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280)); if (thumbnailData != null) { thumbnailUploadData = MultipartFile.fromBytes( List.from(thumbnailData), diff --git a/mobile/lib/shared/services/network.service.dart b/mobile/lib/shared/services/network.service.dart index e9f1fc2245..45d740e4fc 100644 --- a/mobile/lib/shared/services/network.service.dart +++ b/mobile/lib/shared/services/network.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; @@ -25,16 +26,36 @@ class NetworkService { } } - Future getRequest({required String url}) async { + Future getRequest({required String url, bool isByteResponse = false, bool isStreamReponse = false}) async { try { var dio = Dio(); dio.interceptors.add(AuthenticatedRequestInterceptor()); var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); - Response res = await dio.get('$savedEndpoint/$url'); - if (res.statusCode == 200) { - return res; + if (isByteResponse) { + Response> res = await dio.get>( + '$savedEndpoint/$url', + options: Options(responseType: ResponseType.bytes), + ); + + if (res.statusCode == 200) { + return res; + } + } else if (isStreamReponse) { + Response res = await dio.get( + '$savedEndpoint/$url', + options: Options(responseType: ResponseType.stream), + ); + + if (res.statusCode == 200) { + return res; + } + } else { + Response res = await dio.get('$savedEndpoint/$url'); + if (res.statusCode == 200) { + return res; + } } } on DioError catch (e) { debugPrint("DioError: ${e.response}"); diff --git a/mobile/lib/shared/ui/immich_toast.dart b/mobile/lib/shared/ui/immich_toast.dart index 89781ccb48..b28ef1619c 100644 --- a/mobile/lib/shared/ui/immich_toast.dart +++ b/mobile/lib/shared/ui/immich_toast.dart @@ -8,12 +8,24 @@ class ImmichToast { required BuildContext context, required String msg, ToastType toastType = ToastType.info, + ToastGravity gravity = ToastGravity.TOP, }) { FToast fToast; fToast = FToast(); fToast.init(context); + _getColor(ToastType type, BuildContext context) { + switch (type) { + case ToastType.info: + return Theme.of(context).primaryColor; + case ToastType.success: + return const Color.fromARGB(255, 78, 140, 124); + case ToastType.error: + return const Color.fromARGB(255, 220, 48, 85); + } + } + fToast.showToast( child: Container( padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), @@ -36,8 +48,8 @@ class ImmichToast { : Container(), (toastType == ToastType.success) ? const Icon( - Icons.check, - color: Color.fromARGB(255, 104, 248, 140), + Icons.check_circle_rounded, + color: Color.fromARGB(255, 78, 140, 124), ) : Container(), (toastType == ToastType.error) @@ -53,7 +65,7 @@ class ImmichToast { child: Text( msg, style: TextStyle( - color: Theme.of(context).primaryColor, + color: _getColor(toastType, context), fontWeight: FontWeight.bold, fontSize: 15, ), @@ -62,7 +74,7 @@ class ImmichToast { ], ), ), - gravity: ToastGravity.TOP, + gravity: gravity, toastDuration: const Duration(seconds: 2), ); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0e4b2479e8..d395fb34cd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -328,6 +328,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0-dev.0" + flutter_spinkit: + dependency: "direct main" + description: + name: flutter_spinkit + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -680,7 +687,7 @@ packages: name: photo_manager url: "https://pub.dartlang.org" source: hosted - version: "1.3.10" + version: "2.0.6" photo_view: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6ba29316fc..dea871b0bb 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 - photo_manager: ^1.3.10 + photo_manager: ^2.0.6 flutter_hooks: ^0.18.0 hooks_riverpod: ^2.0.0-dev.0 hive: @@ -33,11 +33,11 @@ dependencies: badges: ^2.0.2 photo_view: ^0.13.0 socket_io_client: ^2.0.0-beta.4-nullsafety.0 - # mapbox_gl: ^0.15.0 flutter_map: ^0.14.0 flutter_udid: ^2.0.0 package_info_plus: ^1.4.0 - + flutter_spinkit: ^5.1.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/server/src/api-v1/asset/asset.controller.ts b/server/src/api-v1/asset/asset.controller.ts index 4af564caf6..5bf9fd6764 100644 --- a/server/src/api-v1/asset/asset.controller.ts +++ b/server/src/api-v1/asset/asset.controller.ts @@ -76,6 +76,15 @@ export class AssetController { return 'ok'; } + @Get('/download') + async downloadFile( + @GetAuthUser() authUser: AuthUserDto, + @Response({ passthrough: true }) res: Res, + @Query(ValidationPipe) query: ServeFileDto, + ) { + return this.assetService.downloadFile(authUser, query, res); + } + @Get('/file') async serveFile( @Headers() headers, diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 487e06571d..a217681366 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -13,6 +13,7 @@ import { Response as Res } from 'express'; import { promisify } from 'util'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; +import path from 'path'; const fileInfo = promisify(stat); @@ -146,10 +147,26 @@ export class AssetService { }); } + public async downloadFile(authUser: AuthUserDto, query: ServeFileDto, res: Res) { + let file = null; + const asset = await this.findOne(authUser, query.did, query.aid); + + if (query.isThumb === 'false' || !query.isThumb) { + file = createReadStream(asset.originalPath); + } else { + file = createReadStream(asset.resizePath); + } + + return new StreamableFile(file); + } + public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) { let file = null; const asset = await this.findOne(authUser, query.did, query.aid); + if (!asset) { + throw new BadRequestException('Asset does not exist'); + } // Handle Sending Images if (asset.type == AssetType.IMAGE || query.isThumb == 'true') { res.set({